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:
+
+
+ {renderNotificationRoom(
+ "Policy change",
+ info.policyChangeNotificationRoomID
+ )}
+ {renderNotificationRoom(
+ "Room discovery",
+ info.roomDiscoveryNotificationRoomID
+ )}
+
+
+ );
+}
+
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