diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index 82f5ef5..cb903cf 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -26,8 +26,12 @@ import { import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; import { MatrixRoomID, + MatrixRoomReference, StringRoomID, } from "@the-draupnir-project/matrix-basic-types"; +import { PolicyChangeNotification } from "../protections/PolicyChangeNotification"; +import { RoomTakedownProtection } from "../protections/RoomTakedown/RoomTakedownProtection"; +import { renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; export const DraupnirStatusCommand = describeCommand({ summary: "Show the status of the bot.", @@ -49,7 +53,8 @@ export type StatusInfo = { version: string; repository: string; documentationURL: string; -} & WatchedPolicyRoomsInfo; +} & DraupnirNotificationRoomsInfo & + WatchedPolicyRoomsInfo; export function groupWatchedPolicyRoomsByProtectionStatus( watchedPolicyRooms: WatchedPolicyRooms, @@ -92,6 +97,28 @@ DraupnirInterfaceAdaptor.describeRenderer(DraupnirStatusCommand, { }, }); +type DraupnirNotificationRoomsInfo = { + readonly policyChangeNotificationRoomID: StringRoomID | undefined; + readonly roomDiscoveryNotificationRoomID: StringRoomID | undefined; +}; + +function extractProtectionNotificationRooms( + draupnir: Draupnir +): DraupnirNotificationRoomsInfo { + return { + policyChangeNotificationRoomID: ( + draupnir.protectedRoomsSet.protections.findEnabledProtection( + PolicyChangeNotification.name + ) as PolicyChangeNotification | undefined + )?.notificationRoomID, + roomDiscoveryNotificationRoomID: ( + draupnir.protectedRoomsSet.protections.findEnabledProtection( + RoomTakedownProtection.name + ) as RoomTakedownProtection | undefined + )?.discoveryNotificationRoom, + }; +} + // FIXME: need a shoutout to dependencies in here and NOTICE info. export function draupnirStatusInfo(draupnir: Draupnir): StatusInfo { const watchedListInfo = groupWatchedPolicyRoomsByProtectionStatus( @@ -109,6 +136,7 @@ export function draupnirStatusInfo(draupnir: Draupnir): StatusInfo { documentationURL: DOCUMENTATION_URL, version: SOFTWARE_VERSION, repository: PACKAGE_JSON["repository"] ?? "Unknown", + ...extractProtectionNotificationRooms(draupnir), }; } @@ -129,6 +157,44 @@ export function renderPolicyList(list: WatchedPolicyRoom): DocumentNode { ); } +function renderNotificationRooms(info: StatusInfo): DocumentNode { + if ( + info.roomDiscoveryNotificationRoomID === undefined && + info.policyChangeNotificationRoomID === undefined + ) { + return ; + } + const renderNotificationRoom = ( + name: string, + roomID: StringRoomID | undefined + ) => { + if (roomID === undefined) { + return ; + } + return ( +
  • + {name}: {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} +
  • + ); + }; + return ( + + Notification rooms: +
    + +
    + ); +} + export function renderStatusInfo(info: StatusInfo): DocumentNode { const renderPolicyLists = (header: string, lists: WatchedPolicyRoom[]) => { const renderedLists = lists.map(renderPolicyList); @@ -161,6 +227,7 @@ export function renderStatusInfo(info: StatusInfo): DocumentNode { "Subscribed and protected policy rooms", info.subscribedAndProtectedLists )} + {renderNotificationRooms(info)} Version: {info.version}
    diff --git a/src/protections/NotificationRoom/NotificationRoom.ts b/src/protections/NotificationRoom/NotificationRoom.ts new file mode 100644 index 0000000..677030e --- /dev/null +++ b/src/protections/NotificationRoom/NotificationRoom.ts @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Ok, Result, ResultError } from "@gnuxie/typescript-result"; +import { + MatrixRoomID, + StringRoomID, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { + isError, + Logger, + ProtectedRoomsManager, + ProtectionDescription, + RoomCreator, + RoomMembershipManager, + RoomStateEventSender, +} from "matrix-protection-suite"; +import { Draupnir } from "../../Draupnir"; + +export type SettingChangeAndProtectionEnableCB = ( + roomID: StringRoomID +) => Promise>; + +export class NotificationRoomCreator { + public constructor( + private readonly protectedRoomsManager: ProtectedRoomsManager, + private readonly settingChangeCB: SettingChangeAndProtectionEnableCB, + private readonly roomCreateCapability: RoomCreator, + private readonly roomStateEventCapability: RoomStateEventSender, + private readonly roomName: string, + private readonly draupnirUserID: StringUserID, + private readonly draupnirManagementRoomID: StringRoomID, + private readonly draupnirModerators: StringUserID[], + private readonly log: Logger + ) { + // nothing to do. + } + + public static async extractMembersFromManagementRoom( + managementRoom: MatrixRoomID, + draupnirUserID: StringUserID, + membershipManager: RoomMembershipManager + ): Promise> { + const membershipRevisionIssuer = + await membershipManager.getRoomMembershipRevisionIssuer(managementRoom); + if (isError(membershipRevisionIssuer)) { + return membershipRevisionIssuer; + } + const revision = membershipRevisionIssuer.ok.currentRevision; + return Ok( + [...revision.members()] + .map((member) => member.userID) + .filter((userID) => userID !== draupnirUserID) + ); + } + + public static async createNotificationRoomFromDraupnir( + draupnir: Draupnir, + description: ProtectionDescription, + settings: Record, + notificationRoomSettingName: string, + notificationRoomName: string, + log: Logger + ): Promise> { + const moderators = + await NotificationRoomCreator.extractMembersFromManagementRoom( + draupnir.managementRoom, + draupnir.clientUserID, + draupnir.roomMembershipManager + ); + if (isError(moderators)) { + return moderators.elaborate("Unable to find the draupnir moderators"); + } + const notificationRoomCreator = new NotificationRoomCreator( + draupnir.protectedRoomsSet.protectedRoomsManager, + async function (roomID: StringRoomID) { + const newSettings = description.protectionSettings + .toMirror() + .setValue(settings, notificationRoomSettingName, roomID); + if (isError(newSettings)) { + return newSettings; + } + const result = + await draupnir.protectedRoomsSet.protections.changeProtectionSettings( + description as unknown as ProtectionDescription, + draupnir.protectedRoomsSet, + draupnir, + newSettings.ok + ); + if (isError(result)) { + return result.elaborate( + "Unable to add the notification room to the protection settings" + ); + } + return Ok(undefined); + }, + draupnir.clientPlatform.toRoomCreator(), + draupnir.clientPlatform.toRoomStateEventSender(), + notificationRoomName, + draupnir.clientUserID, + draupnir.managementRoomID, + moderators.ok, + log + ); + return await notificationRoomCreator.createMissingNotificationRoom(); + } + + public async createMissingNotificationRoom(): Promise> { + const roomTitle = `${this.draupnirUserID}'s ${this.roomName}`; + const newRoom = await this.roomCreateCapability.createRoom({ + preset: "private_chat", + name: roomTitle, + invite: this.draupnirModerators, + }); + if (isError(newRoom)) { + this.log.error( + `Failed to create notification room for ${this.roomName}`, + newRoom.error + ); + return newRoom; + } + const protectRoomResult = await this.protectedRoomsManager.addRoom( + newRoom.ok + ); + if (isError(protectRoomResult)) { + this.log.error( + `Failed to protect notification room for ${this.roomName}`, + protectRoomResult.error + ); + return protectRoomResult; + } + const protectionEnableResult = await this.settingChangeCB( + newRoom.ok.toRoomIDOrAlias() + ); + const restrictionResult = + await this.roomStateEventCapability.sendStateEvent( + newRoom.ok, + "m.room.join_rules", + "", + { + join_rule: "restricted", + allow: [ + { + room_id: this.draupnirManagementRoomID, + type: "m.room_membership", + }, + ], + } + ); + if (isError(restrictionResult)) { + this.log.error( + `Failed to restrict notification room for ${this.roomName}`, + restrictionResult.error + ); + return restrictionResult; + } + if (isError(protectionEnableResult)) { + this.log.error( + `Failed to enable protection for notification room for ${this.roomName}`, + protectionEnableResult.error + ); + return protectionEnableResult; + } + return ResultError.Result( + `A notification room titled "${roomTitle}" has been created for this protection's messages, and the protection has been restarted separately` + ); + } +} diff --git a/src/protections/PolicyChangeNotification.tsx b/src/protections/PolicyChangeNotification.tsx index 53ff10a..978d48b 100644 --- a/src/protections/PolicyChangeNotification.tsx +++ b/src/protections/PolicyChangeNotification.tsx @@ -11,6 +11,7 @@ import { AbstractProtection, ActionResult, + EDStatic, Logger, Ok, PolicyListRevision, @@ -20,7 +21,7 @@ import { PolicyRuleMatchType, ProtectedRoomsSet, ProtectionDescription, - UnknownConfig, + StringRoomIDSchema, describeProtection, isError, } from "matrix-protection-suite"; @@ -40,15 +41,32 @@ import { } from "@the-draupnir-project/interface-manager"; import { sendMatrixEventsFromDeadDocument } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; import { renderRuleHashes, renderRuleClearText } from "../commands/Rules"; +import { NotificationRoomCreator } from "./NotificationRoom/NotificationRoom"; +import { Type } from "@sinclair/typebox"; const log = new Logger("PolicyChangeNotification"); +// FIXME: Add these rooms to the status command!!. + +const PolicyChangeNotificationSettings = Type.Object({ + notificationRoomID: Type.Optional( + Type.Union([StringRoomIDSchema, Type.Undefined()], { + default: undefined, + description: "The room where notifications should be sent.", + }) + ), +}); + +export type PolicyChangeNotificationSettings = EDStatic< + typeof PolicyChangeNotificationSettings +>; + export type PolicyChangeNotificationCapabilitites = Record; export type PolicyChangeNotificationProtectionDescription = ProtectionDescription< Draupnir, - UnknownConfig, + typeof PolicyChangeNotificationSettings, PolicyChangeNotificationCapabilitites >; @@ -62,7 +80,8 @@ export class PolicyChangeNotification description: PolicyChangeNotificationProtectionDescription, capabilities: PolicyChangeNotificationCapabilitites, protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir + private readonly draupnir: Draupnir, + public readonly notificationRoomID: StringRoomID ) { super(description, capabilities, protectedRoomsSet, {}); } @@ -92,7 +111,7 @@ export class PolicyChangeNotification } const sendResult = await sendMatrixEventsFromDeadDocument( this.draupnir.clientPlatform.toRoomMessageSender(), - this.draupnir.managementRoomID, + this.notificationRoomID, {renderGroupedChanges(groupedChanges.ok)}, {} ); @@ -159,24 +178,40 @@ function renderGroupedChanges(groupedChanges: GroupedChange[]): DocumentNode { return {groupedChanges.map(renderListChanges)}; } -describeProtection({ +describeProtection< + PolicyChangeNotificationCapabilitites, + Draupnir, + typeof PolicyChangeNotificationSettings +>({ name: PolicyChangeNotification.name, description: "Provides notification of policy changes from watched lists.", capabilityInterfaces: {}, defaultCapabilities: {}, + configSchema: PolicyChangeNotificationSettings, async factory( description, protectedRoomsSet, draupnir, capabilities, - _settings + settings ) { + if (settings.notificationRoomID === undefined) { + return await NotificationRoomCreator.createNotificationRoomFromDraupnir( + draupnir, + description as unknown as ProtectionDescription, + settings, + "notificationRoomID", + "Policy Change Notifications", + log + ); + } return Ok( new PolicyChangeNotification( description, capabilities, protectedRoomsSet, - draupnir + draupnir, + settings.notificationRoomID ) ); }, diff --git a/src/protections/RoomTakedown/RoomTakedownProtection.ts b/src/protections/RoomTakedown/RoomTakedownProtection.ts index fd3253e..1e170cb 100644 --- a/src/protections/RoomTakedown/RoomTakedownProtection.ts +++ b/src/protections/RoomTakedown/RoomTakedownProtection.ts @@ -38,6 +38,7 @@ import { wrapInRoot } from "../../commands/interface-manager/MatrixHelpRenderer" import { Type } from "@sinclair/typebox"; import { EDStatic } from "matrix-protection-suite/dist/Interface/Static"; import { renderDiscoveredRoom } from "./RoomDiscoveryRenderer"; +import { NotificationRoomCreator } from "../NotificationRoom/NotificationRoom"; const log = new Logger("RoomTakedownProtection"); @@ -52,8 +53,7 @@ const RoomTakedownProtectionSettings = Type.Object( discoveryNotificationRoom: Type.Optional( Type.Union([StringRoomIDSchema, Type.Undefined()], { default: undefined, - description: - "The room where notifications should be sent. Currently broken and needs to be edited from a state event while we figure something out", + description: "The room where notifications should be sent.", }) ), discoveryNotificationEnabled: Type.Boolean({ @@ -92,7 +92,7 @@ export class RoomTakedownProtection private readonly roomMessageSender: RoomMessageSender, private readonly discoveryNotificationEnabled: boolean, private readonly discoveryNotificationMembershipThreshold: number, - private readonly discoveryNotificationRoom: StringRoomID, + public readonly discoveryNotificationRoom: StringRoomID, private readonly roomDiscovery: RoomDiscovery | undefined ) { super(description, capabilities, protectedRoomsSet, {}); @@ -173,6 +173,19 @@ describeProtection< capabilitySet, settings ) { + if ( + settings.discoveryNotificationEnabled && + settings.discoveryNotificationRoom === undefined + ) { + return await NotificationRoomCreator.createNotificationRoomFromDraupnir( + draupnir, + description as unknown as ProtectionDescription, + settings, + "discoveryNotificationRoom", + "Room Discovery Notification", + log + ); + } if ( draupnir.stores.hashStore === undefined || draupnir.stores.roomAuditLog === undefined