diff --git a/.editorconfig b/.editorconfig index 37356d4d..6c76c5a6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 indent_style = space -indent_size = 4 +indent_size = 2 [{LICENSE,NOTICE,*.{md,yml,yaml,json}}] trim_trailing_whitespace = false diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..0e5c5264 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# The first time that we ran prettier. +3b2036c2db205e7f9a10a6b4fef2ec1760b8f51b diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 00000000..aaff6ebc --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2024 Gnuxie +# +# SPDX-License-Identifier: CC0-1.0 + +tabWidth: 2 +useTabs: false +semi: true +trailingComma: "es5" diff --git a/package.json b/package.json index 86911885..7dee7dcd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "postbuild": "corepack yarn describe-version", "describe-version": "(git describe > version.txt.tmp && mv version.txt.tmp version.txt) || true && rm -f version.txt.tmp", "remove-tests-from-lib": "rm -rf lib/test/ && cp -r lib/src/* lib/ && rm -rf lib/src/", - "lint": "corepack yarn eslint src test", + "lint": "corepack yarn eslint src test && corepack yarn prettier --check src test", "start:dev": "corepack yarn build && node --async-stack-traces lib/index.js", "test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts", "test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --forbid-only --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"", @@ -43,6 +43,7 @@ "eslint": "^9.7.0", "expect": "^29.7.0", "mocha": "^10.7.0", + "prettier": "^3.3.3", "ts-mocha": "^10.0.0", "typescript": "^5.5.3", "typescript-eslint": "^7.16.1", diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 0aee4967..094a7d19 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,36 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, Client, ClientPlatform, ClientRooms, EventReport, LoggableConfigTracker, Logger, MatrixRoomID, MatrixRoomReference, MembershipEvent, Ok, PolicyRoomManager, ProtectedRoomsSet, RoomEvent, RoomMembershipManager, RoomMembershipRevisionIssuer, RoomMessage, RoomStateManager, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite"; +import { + ActionResult, + Client, + ClientPlatform, + ClientRooms, + EventReport, + LoggableConfigTracker, + Logger, + MatrixRoomID, + MatrixRoomReference, + MembershipEvent, + Ok, + PolicyRoomManager, + ProtectedRoomsSet, + RoomEvent, + RoomMembershipManager, + RoomMembershipRevisionIssuer, + RoomMessage, + RoomStateManager, + StringRoomID, + StringUserID, + Task, + TextMessageContent, + Value, + isError, + isStringRoomAlias, + isStringRoomID, + serverName, + userLocalpart, +} from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; @@ -33,11 +62,24 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { MatrixSendClient, SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + MatrixSendClient, + SynapseAdminClient, +} from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; -import { COMMAND_PREFIX, DraupnirContext, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; +import { + COMMAND_PREFIX, + DraupnirContext, + extractCommandFromMessageBody, + handleCommand, +} from "./commands/CommandHandler"; import { LogLevel } from "matrix-bot-sdk"; -import { ARGUMENT_PROMPT_LISTENER, DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt as makeListenerForArgumentPrompt, makeListenerForPromptDefault } from "./commands/interface-manager/MatrixPromptForAccept"; +import { + ARGUMENT_PROMPT_LISTENER, + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + makeListenerForArgumentPrompt as makeListenerForArgumentPrompt, + makeListenerForPromptDefault, +} from "./commands/interface-manager/MatrixPromptForAccept"; import { RendererMessageCollector } from "./capabilities/RendererMessageCollector"; import { DraupnirRendererMessageCollector } from "./capabilities/DraupnirRendererMessageCollector"; import { renderProtectionFailedToStart } from "./protections/ProtectedRoomsSetRenderers"; @@ -45,7 +87,7 @@ import { draupnirStatusInfo, renderStatusInfo } from "./commands/StatusCommand"; import { renderMatrixAndSend } from "./commands/interface-manager/DeadDocumentMatrix"; import { isInvitationForUser } from "./protections/invitation/inviteCore"; -const log = new Logger('Draupnir'); +const log = new Logger("Draupnir"); // webAPIS should not be included on the Draupnir class. // That should be managed elsewhere. @@ -54,254 +96,333 @@ const log = new Logger('Draupnir'); // And giving it to the class was a dumb easy way of doing that. export class Draupnir implements Client { - /** - * This is for users who are not listed on a watchlist, - * but have been flagged by the automatic spam detection as suispicous - */ - public unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); + /** + * This is for users who are not listed on a watchlist, + * but have been flagged by the automatic spam detection as suispicous + */ + public unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); - private readonly commandTable = findCommandTable("draupnir"); - public taskQueue: ThrottlingQueue; - /** - * Reporting back to the management room. - */ - public readonly managementRoomOutput: ManagementRoomOutput; - public readonly managementRoomID: StringRoomID; - /* - * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports - */ - private reportPoller?: ReportPoller; - /** - * Handle user reports from the homeserver. - * FIXME: ReportManager should be a protection. - */ - public readonly reportManager: ReportManager; + private readonly commandTable = findCommandTable("draupnir"); + public taskQueue: ThrottlingQueue; + /** + * Reporting back to the management room. + */ + public readonly managementRoomOutput: ManagementRoomOutput; + public readonly managementRoomID: StringRoomID; + /* + * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports + */ + private reportPoller?: ReportPoller; + /** + * Handle user reports from the homeserver. + * FIXME: ReportManager should be a protection. + */ + public readonly reportManager: ReportManager; - public readonly reactionHandler: MatrixReactionHandler; + public readonly reactionHandler: MatrixReactionHandler; - public readonly commandContext: Omit; + public readonly commandContext: Omit; - private readonly timelineEventListener = this.handleTimelineEvent.bind(this); + private readonly timelineEventListener = this.handleTimelineEvent.bind(this); - public readonly capabilityMessageRenderer: RendererMessageCollector; + public readonly capabilityMessageRenderer: RendererMessageCollector; - private constructor( - public readonly client: MatrixSendClient, - public readonly clientUserID: StringUserID, - public readonly clientPlatform: ClientPlatform, - public readonly managementRoom: MatrixRoomID, - public readonly clientRooms: ClientRooms, - public readonly config: IConfig, - public readonly protectedRoomsSet: ProtectedRoomsSet, - public readonly roomStateManager: RoomStateManager, - public readonly policyRoomManager: PolicyRoomManager, - public readonly roomMembershipManager: RoomMembershipManager, - public readonly loggableConfigTracker: LoggableConfigTracker, - /** Mjolnir has a feature where you can choose to accept invitations from a space and not just the management room. */ - public readonly acceptInvitesFromRoom: MatrixRoomID, - public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer, - public readonly synapseAdminClient?: SynapseAdminClient, - ) { - this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); - this.managementRoomOutput = new ManagementRoomOutput( - this.managementRoomID, this.clientUserID, this.client, this.config - ); - this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID, clientPlatform); - this.reportManager = new ReportManager(this); - if (config.pollReports) { - this.reportPoller = new ReportPoller(this, this.reportManager); - } - - this.commandContext = { - draupnir: this, roomID: this.managementRoomID, client: this.client, reactionHandler: this.reactionHandler, clientPlatform: this.clientPlatform - }; - this.reactionHandler.on(ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt( - this.client, - this.clientPlatform, - this.managementRoomID, - this.reactionHandler, - this.commandTable, - this.commandContext - )); - this.reactionHandler.on(DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForPromptDefault( - this.client, - this.clientPlatform, - this.managementRoomID, - this.reactionHandler, - this.commandTable, - this.commandContext - )); - this.capabilityMessageRenderer = new DraupnirRendererMessageCollector(this.client, this.managementRoomID); + private constructor( + public readonly client: MatrixSendClient, + public readonly clientUserID: StringUserID, + public readonly clientPlatform: ClientPlatform, + public readonly managementRoom: MatrixRoomID, + public readonly clientRooms: ClientRooms, + public readonly config: IConfig, + public readonly protectedRoomsSet: ProtectedRoomsSet, + public readonly roomStateManager: RoomStateManager, + public readonly policyRoomManager: PolicyRoomManager, + public readonly roomMembershipManager: RoomMembershipManager, + public readonly loggableConfigTracker: LoggableConfigTracker, + /** Mjolnir has a feature where you can choose to accept invitations from a space and not just the management room. */ + public readonly acceptInvitesFromRoom: MatrixRoomID, + public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer, + public readonly synapseAdminClient?: SynapseAdminClient + ) { + this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); + this.managementRoomOutput = new ManagementRoomOutput( + this.managementRoomID, + this.clientUserID, + this.client, + this.config + ); + this.reactionHandler = new MatrixReactionHandler( + this.managementRoom.toRoomIDOrAlias(), + client, + clientUserID, + clientPlatform + ); + this.reportManager = new ReportManager(this); + if (config.pollReports) { + this.reportPoller = new ReportPoller(this, this.reportManager); } - public static async makeDraupnirBot( - client: MatrixSendClient, - clientUserID: StringUserID, - clientPlatform: ClientPlatform, - managementRoom: MatrixRoomID, - clientRooms: ClientRooms, - protectedRoomsSet: ProtectedRoomsSet, - roomStateManager: RoomStateManager, - policyRoomManager: PolicyRoomManager, - roomMembershipManager: RoomMembershipManager, - config: IConfig, - loggableConfigTracker: LoggableConfigTracker - ): Promise> { - const acceptInvitesFromRoom = await (async () => { - if (config.autojoinOnlyIfManager) { - return Ok(managementRoom) - } else { - if (config.acceptInvitesFromSpace === undefined) { - throw new TypeError(`You cannot leave config.acceptInvitesFromSpace undefined if you have disabled config.autojoinOnlyIfManager`); - } - const room = (() => { - if (isStringRoomID(config.acceptInvitesFromSpace) || isStringRoomAlias(config.acceptInvitesFromSpace)) { - return config.acceptInvitesFromSpace; - } else { - const parseResult = MatrixRoomReference.fromPermalink(config.acceptInvitesFromSpace); - if (isError(parseResult)) { - throw new TypeError(`config.acceptInvitesFromSpace: ${config.acceptInvitesFromSpace} needs to be a room id, alias or permalink`); - } - return parseResult.ok; - } - })(); - return await clientPlatform.toRoomJoiner().joinRoom(room); + this.commandContext = { + draupnir: this, + roomID: this.managementRoomID, + client: this.client, + reactionHandler: this.reactionHandler, + clientPlatform: this.clientPlatform, + }; + this.reactionHandler.on( + ARGUMENT_PROMPT_LISTENER, + makeListenerForArgumentPrompt( + this.client, + this.clientPlatform, + this.managementRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + ) + ); + this.reactionHandler.on( + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + makeListenerForPromptDefault( + this.client, + this.clientPlatform, + this.managementRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + ) + ); + this.capabilityMessageRenderer = new DraupnirRendererMessageCollector( + this.client, + this.managementRoomID + ); + } + + public static async makeDraupnirBot( + client: MatrixSendClient, + clientUserID: StringUserID, + clientPlatform: ClientPlatform, + managementRoom: MatrixRoomID, + clientRooms: ClientRooms, + protectedRoomsSet: ProtectedRoomsSet, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager, + config: IConfig, + loggableConfigTracker: LoggableConfigTracker + ): Promise> { + const acceptInvitesFromRoom = await (async () => { + if (config.autojoinOnlyIfManager) { + return Ok(managementRoom); + } else { + if (config.acceptInvitesFromSpace === undefined) { + throw new TypeError( + `You cannot leave config.acceptInvitesFromSpace undefined if you have disabled config.autojoinOnlyIfManager` + ); + } + const room = (() => { + if ( + isStringRoomID(config.acceptInvitesFromSpace) || + isStringRoomAlias(config.acceptInvitesFromSpace) + ) { + return config.acceptInvitesFromSpace; + } else { + const parseResult = MatrixRoomReference.fromPermalink( + config.acceptInvitesFromSpace + ); + if (isError(parseResult)) { + throw new TypeError( + `config.acceptInvitesFromSpace: ${config.acceptInvitesFromSpace} needs to be a room id, alias or permalink` + ); } + return parseResult.ok; + } })(); - if (isError(acceptInvitesFromRoom)) { - return acceptInvitesFromRoom; - } - const acceptInvitesFromRoomIssuer = await roomMembershipManager.getRoomMembershipRevisionIssuer(acceptInvitesFromRoom.ok); - if (isError(acceptInvitesFromRoomIssuer)) { - return acceptInvitesFromRoomIssuer; - } - const draupnir = new Draupnir( - client, - clientUserID, - clientPlatform, - managementRoom, - clientRooms, - config, - protectedRoomsSet, - roomStateManager, - policyRoomManager, - roomMembershipManager, - loggableConfigTracker, - acceptInvitesFromRoom.ok, - acceptInvitesFromRoomIssuer.ok, - new SynapseAdminClient( - client, - clientUserID - ) - ); - const loadResult = await protectedRoomsSet.protections.loadProtections( - protectedRoomsSet, - draupnir, - (error, protectionName, description) => renderProtectionFailedToStart( - client, managementRoom.toRoomIDOrAlias(), error, protectionName, description - ) - ); - if (isError(loadResult)) { - return loadResult; - } - // we need to make sure that we are protecting the management room so we - // have immediate access to its membership (for accepting invitations). - const managementRoomProtectResult = await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( - managementRoom - ); - if (isError(managementRoomProtectResult)) { - return managementRoomProtectResult; - } - void Task(draupnir.startupComplete()); - return Ok(draupnir); + return await clientPlatform.toRoomJoiner().joinRoom(room); + } + })(); + if (isError(acceptInvitesFromRoom)) { + return acceptInvitesFromRoom; } - - private async startupComplete(): Promise { - const statusInfo = await draupnirStatusInfo(this); - try { - await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.") - await renderMatrixAndSend( - renderStatusInfo(statusInfo), - this.managementRoomID, - undefined, - this.client - ) - } catch (ex) { - log.error(`Caught an error when trying to show status at startup`, ex); - } + const acceptInvitesFromRoomIssuer = + await roomMembershipManager.getRoomMembershipRevisionIssuer( + acceptInvitesFromRoom.ok + ); + if (isError(acceptInvitesFromRoomIssuer)) { + return acceptInvitesFromRoomIssuer; } - - public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { - if (Value.Check(MembershipEvent, event) && isInvitationForUser(event, this.clientUserID)) { - this.protectedRoomsSet.handleExternalInvite(roomID, event); - } - this.managementRoomMessageListener(roomID, event); - void Task((async () => { await this.reactionHandler.handleEvent(roomID, event); })()); - if (this.protectedRoomsSet.isProtectedRoom(roomID)) { - this.protectedRoomsSet.handleTimelineEvent(roomID, event); - } + const draupnir = new Draupnir( + client, + clientUserID, + clientPlatform, + managementRoom, + clientRooms, + config, + protectedRoomsSet, + roomStateManager, + policyRoomManager, + roomMembershipManager, + loggableConfigTracker, + acceptInvitesFromRoom.ok, + acceptInvitesFromRoomIssuer.ok, + new SynapseAdminClient(client, clientUserID) + ); + const loadResult = await protectedRoomsSet.protections.loadProtections( + protectedRoomsSet, + draupnir, + (error, protectionName, description) => + renderProtectionFailedToStart( + client, + managementRoom.toRoomIDOrAlias(), + error, + protectionName, + description + ) + ); + if (isError(loadResult)) { + return loadResult; } - - private managementRoomMessageListener(roomID: StringRoomID, event: RoomEvent): void { - if (roomID !== this.managementRoomID) { - return; - } - if (Value.Check(RoomMessage, event) && Value.Check(TextMessageContent, event.content)) { - if (event.content.body === "** Unable to decrypt: The sender's device has not sent us the keys for this message. **") { - log.info(`Unable to decrypt an event ${event.event_id} from ${event.sender} in the management room ${this.managementRoom.toPermalink()}.`); - void Task(this.client.unstableApis.addReactionToEvent(roomID, event.event_id, '⚠').then(_ => Ok(undefined))); - void Task(this.client.unstableApis.addReactionToEvent(roomID, event.event_id, 'UISI').then(_ => Ok(undefined))); - void Task(this.client.unstableApis.addReactionToEvent(roomID, event.event_id, '🚨').then(_ => Ok(undefined))); - return; - } - const commandBeingRun = extractCommandFromMessageBody( - event.content.body, - { - prefix: COMMAND_PREFIX, - localpart: userLocalpart(this.clientUserID), - userId: this.clientUserID, - additionalPrefixes: this.config.commands.additionalPrefixes, - allowNoPrefix: this.config.commands.allowNoPrefix, - } - ); - if (commandBeingRun === undefined) { - return; - } - log.info(`Command being run by ${event.sender}: ${commandBeingRun}`); - void Task(this.client.sendReadReceipt(roomID, event.event_id).then((_) => Ok(undefined))) - void Task(handleCommand(roomID, event, commandBeingRun, this, this.commandTable).then((_) => Ok(undefined))); - } - this.reportManager.handleTimelineEvent(roomID, event); + // we need to make sure that we are protecting the management room so we + // have immediate access to its membership (for accepting invitations). + const managementRoomProtectResult = + await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( + managementRoom + ); + if (isError(managementRoomProtectResult)) { + return managementRoomProtectResult; } + void Task(draupnir.startupComplete()); + return Ok(draupnir); + } - /** - * Start responding to events. - * This will not start the appservice from listening and responding - * to events. Nor will it start any syncing client. - */ - public async start(): Promise { - this.clientRooms.on('timeline', this.timelineEventListener); - if (this.reportPoller) { - const reportPollSetting = await ReportPoller.getReportPollSetting( - this.client, - this.managementRoomOutput - ); - this.reportPoller.start(reportPollSetting); - } + private async startupComplete(): Promise { + const statusInfo = await draupnirStatusInfo(this); + try { + await this.managementRoomOutput.logMessage( + LogLevel.INFO, + "Mjolnir@startup", + "Startup complete. Now monitoring rooms." + ); + await renderMatrixAndSend( + renderStatusInfo(statusInfo), + this.managementRoomID, + undefined, + this.client + ); + } catch (ex) { + log.error(`Caught an error when trying to show status at startup`, ex); } + } - public stop(): void { - this.clientRooms.off('timeline', this.timelineEventListener); - this.reportPoller?.stop() + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + if ( + Value.Check(MembershipEvent, event) && + isInvitationForUser(event, this.clientUserID) + ) { + this.protectedRoomsSet.handleExternalInvite(roomID, event); + } + this.managementRoomMessageListener(roomID, event); + void Task( + (async () => { + await this.reactionHandler.handleEvent(roomID, event); + })() + ); + if (this.protectedRoomsSet.isProtectedRoom(roomID)) { + this.protectedRoomsSet.handleTimelineEvent(roomID, event); } + } - public createRoomReference(roomID: StringRoomID): MatrixRoomID { - return new MatrixRoomID( - roomID, - [serverName(this.clientUserID)] + private managementRoomMessageListener( + roomID: StringRoomID, + event: RoomEvent + ): void { + if (roomID !== this.managementRoomID) { + return; + } + if ( + Value.Check(RoomMessage, event) && + Value.Check(TextMessageContent, event.content) + ) { + if ( + event.content.body === + "** Unable to decrypt: The sender's device has not sent us the keys for this message. **" + ) { + log.info( + `Unable to decrypt an event ${event.event_id} from ${event.sender} in the management room ${this.managementRoom.toPermalink()}.` + ); + void Task( + this.client.unstableApis + .addReactionToEvent(roomID, event.event_id, "⚠") + .then((_) => Ok(undefined)) + ); + void Task( + this.client.unstableApis + .addReactionToEvent(roomID, event.event_id, "UISI") + .then((_) => Ok(undefined)) + ); + void Task( + this.client.unstableApis + .addReactionToEvent(roomID, event.event_id, "🚨") + .then((_) => Ok(undefined)) ); + return; + } + const commandBeingRun = extractCommandFromMessageBody( + event.content.body, + { + prefix: COMMAND_PREFIX, + localpart: userLocalpart(this.clientUserID), + userId: this.clientUserID, + additionalPrefixes: this.config.commands.additionalPrefixes, + allowNoPrefix: this.config.commands.allowNoPrefix, + } + ); + if (commandBeingRun === undefined) { + return; + } + log.info(`Command being run by ${event.sender}: ${commandBeingRun}`); + void Task( + this.client + .sendReadReceipt(roomID, event.event_id) + .then((_) => Ok(undefined)) + ); + void Task( + handleCommand( + roomID, + event, + commandBeingRun, + this, + this.commandTable + ).then((_) => Ok(undefined)) + ); } - public handleEventReport(report: EventReport): void { - this.protectedRoomsSet.handleEventReport(report); + this.reportManager.handleTimelineEvent(roomID, event); + } + + /** + * Start responding to events. + * This will not start the appservice from listening and responding + * to events. Nor will it start any syncing client. + */ + public async start(): Promise { + this.clientRooms.on("timeline", this.timelineEventListener); + if (this.reportPoller) { + const reportPollSetting = await ReportPoller.getReportPollSetting( + this.client, + this.managementRoomOutput + ); + this.reportPoller.start(reportPollSetting); } + } + + public stop(): void { + this.clientRooms.off("timeline", this.timelineEventListener); + this.reportPoller?.stop(); + } + + public createRoomReference(roomID: StringRoomID): MatrixRoomID { + return new MatrixRoomID(roomID, [serverName(this.clientUserID)]); + } + public handleEventReport(report: EventReport): void { + this.protectedRoomsSet.handleEventReport(report); + } } diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 889a5e7f..4aa2c355 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -26,25 +26,25 @@ limitations under the License. */ import { - isError, - StringUserID, - MatrixRoomReference, - isStringUserID, - isStringRoomAlias, - isStringRoomID, - StandardClientsInRoomMap, - DefaultEventDecoder, - setGlobalLoggerProvider, - RoomStateBackingStore, + isError, + StringUserID, + MatrixRoomReference, + isStringUserID, + isStringRoomAlias, + isStringRoomID, + StandardClientsInRoomMap, + DefaultEventDecoder, + setGlobalLoggerProvider, + RoomStateBackingStore, } from "matrix-protection-suite"; import { - BotSDKLogServiceLogger, - ClientCapabilityFactory, - MatrixSendClient, - RoomStateManagerFactory, - SafeMatrixEmitter, - resolveRoomReferenceSafe -} from 'matrix-protection-suite-for-matrix-bot-sdk'; + BotSDKLogServiceLogger, + ClientCapabilityFactory, + MatrixSendClient, + RoomStateManagerFactory, + SafeMatrixEmitter, + resolveRoomReferenceSafe, +} from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { Draupnir } from "./Draupnir"; import { DraupnirFactory } from "./draupnirfactory/DraupnirFactory"; @@ -53,7 +53,7 @@ import { WebAPIs } from "./webapis/WebAPIs"; setGlobalLoggerProvider(new BotSDKLogServiceLogger()); export function constructWebAPIs(draupnir: Draupnir): WebAPIs { - return new WebAPIs(draupnir.reportManager, draupnir.config); + return new WebAPIs(draupnir.reportManager, draupnir.config); } /** @@ -65,59 +65,75 @@ export function constructWebAPIs(draupnir: Draupnir): WebAPIs { */ export async function makeDraupnirBotModeFromConfig( - client: MatrixSendClient, - matrixEmitter: SafeMatrixEmitter, - config: IConfig, - backingStore?: RoomStateBackingStore + client: MatrixSendClient, + matrixEmitter: SafeMatrixEmitter, + config: IConfig, + backingStore?: RoomStateBackingStore ): Promise { - const clientUserId = await client.getUserId(); - if (!isStringUserID(clientUserId)) { - throw new TypeError(`${clientUserId} is not a valid mxid`); - } - if (!isStringRoomAlias(config.managementRoom) && !isStringRoomID(config.managementRoom)) { - throw new TypeError(`${config.managementRoom} is not a valid room id or alias`); - } - const configManagementRoomReference = MatrixRoomReference.fromRoomIDOrAlias(config.managementRoom); - const managementRoom = await resolveRoomReferenceSafe(client, configManagementRoomReference); - if (isError(managementRoom)) { - throw managementRoom.error; - } - await client.joinRoom(managementRoom.ok.toRoomIDOrAlias(), managementRoom.ok.getViaServers()); - const clientsInRoomMap = new StandardClientsInRoomMap(); - const clientProvider = async (userID: StringUserID) => { - if (userID !== clientUserId) { - throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); - } - return client; - }; - const roomStateManagerFactory = new RoomStateManagerFactory( - clientsInRoomMap, - clientProvider, - DefaultEventDecoder, - backingStore + const clientUserId = await client.getUserId(); + if (!isStringUserID(clientUserId)) { + throw new TypeError(`${clientUserId} is not a valid mxid`); + } + if ( + !isStringRoomAlias(config.managementRoom) && + !isStringRoomID(config.managementRoom) + ) { + throw new TypeError( + `${config.managementRoom} is not a valid room id or alias` ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap, DefaultEventDecoder); - const draupnirFactory = new DraupnirFactory( - clientsInRoomMap, - clientCapabilityFactory, - clientProvider, - roomStateManagerFactory - ); - const draupnir = await draupnirFactory.makeDraupnir( - clientUserId, - managementRoom.ok, - config - ); - if (isError(draupnir)) { - const error = draupnir.error; - throw new Error(`Unable to create Draupnir: ${error.message}`); + } + const configManagementRoomReference = MatrixRoomReference.fromRoomIDOrAlias( + config.managementRoom + ); + const managementRoom = await resolveRoomReferenceSafe( + client, + configManagementRoomReference + ); + if (isError(managementRoom)) { + throw managementRoom.error; + } + await client.joinRoom( + managementRoom.ok.toRoomIDOrAlias(), + managementRoom.ok.getViaServers() + ); + const clientsInRoomMap = new StandardClientsInRoomMap(); + const clientProvider = async (userID: StringUserID) => { + if (userID !== clientUserId) { + throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); } - matrixEmitter.on('room.invite', (roomID, event) => { - clientsInRoomMap.handleTimelineEvent(roomID, event); - }) - matrixEmitter.on('room.event', (roomID, event) => { - roomStateManagerFactory.handleTimelineEvent(roomID, event); - clientsInRoomMap.handleTimelineEvent(roomID, event); - }) - return draupnir.ok; + return client; + }; + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + clientProvider, + DefaultEventDecoder, + backingStore + ); + const clientCapabilityFactory = new ClientCapabilityFactory( + clientsInRoomMap, + DefaultEventDecoder + ); + const draupnirFactory = new DraupnirFactory( + clientsInRoomMap, + clientCapabilityFactory, + clientProvider, + roomStateManagerFactory + ); + const draupnir = await draupnirFactory.makeDraupnir( + clientUserId, + managementRoom.ok, + config + ); + if (isError(draupnir)) { + const error = draupnir.error; + throw new Error(`Unable to create Draupnir: ${error.message}`); + } + matrixEmitter.on("room.invite", (roomID, event) => { + clientsInRoomMap.handleTimelineEvent(roomID, event); + }); + matrixEmitter.on("room.event", (roomID, event) => { + roomStateManagerFactory.handleTimelineEvent(roomID, event); + clientsInRoomMap.handleTimelineEvent(roomID, event); + }); + return draupnir.ok; } diff --git a/src/ManagementRoomOutput.ts b/src/ManagementRoomOutput.ts index ef7fbe5f..3991cbba 100644 --- a/src/ManagementRoomOutput.ts +++ b/src/ManagementRoomOutput.ts @@ -26,124 +26,165 @@ limitations under the License. */ import * as Sentry from "@sentry/node"; -import { LogLevel, LogService, MessageType, TextualMessageEventContent } from "matrix-bot-sdk"; +import { + LogLevel, + LogService, + MessageType, + TextualMessageEventContent, +} from "matrix-bot-sdk"; import { IConfig } from "./config"; import { htmlEscape } from "./utils"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { Permalinks, StringRoomAlias, StringRoomID, StringUserID, serverName } from "matrix-protection-suite"; +import { + Permalinks, + StringRoomAlias, + StringRoomID, + StringUserID, + serverName, +} from "matrix-protection-suite"; const levelToFn = { - [LogLevel.DEBUG.toString()]: LogService.debug, - [LogLevel.INFO.toString()]: LogService.info, - [LogLevel.WARN.toString()]: LogService.warn, - [LogLevel.ERROR.toString()]: LogService.error, + [LogLevel.DEBUG.toString()]: LogService.debug, + [LogLevel.INFO.toString()]: LogService.info, + [LogLevel.WARN.toString()]: LogService.warn, + [LogLevel.ERROR.toString()]: LogService.error, }; /** * Allows the different componenets of mjolnir to send messages back to the management room without introducing a dependency on the entirity of a `Mjolnir` instance. */ export default class ManagementRoomOutput { - - constructor( - private readonly managementRoomID: StringRoomID, - private readonly clientUserID: StringUserID, - private readonly client: MatrixSendClient, - private readonly config: IConfig, - ) { - + constructor( + private readonly managementRoomID: StringRoomID, + private readonly clientUserID: StringUserID, + private readonly client: MatrixSendClient, + private readonly config: IConfig + ) {} + + /** + * Take an arbitrary string and a set of room IDs, and return a + * TextualMessageEventContent whose plaintext component replaces those room + * IDs with their canonical aliases, and whose html component replaces those + * room IDs with their matrix.to room pills. + * + * @param client The matrix client on which to query for room aliases + * @param text An arbitrary string to rewrite with room aliases and pills + * @param roomIds A set of room IDs to find and replace in `text` + * @param msgtype The desired message type of the returned TextualMessageEventContent + * @returns A TextualMessageEventContent with replaced room IDs + */ + private async replaceRoomIdsWithPills( + text: string, + roomIds: Set, + msgtype: MessageType = "m.text" + ): Promise { + const content: TextualMessageEventContent = { + body: text, + formatted_body: htmlEscape(text), + msgtype: msgtype, + format: "org.matrix.custom.html", + }; + + // Though spec doesn't say so, room ids that have slashes in them are accepted by Synapse and Dendrite unfortunately for us. + const escapeRegex = (v: string): string => { + return v.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + }; + + const viaServers = [serverName(this.clientUserID)]; + for (const roomId of roomIds) { + let alias = roomId; + try { + alias = (await this.client.getPublishedAlias(roomId)) || roomId; + } catch (e) { + // This is a recursive call, so tell the function not to try and call us + await this.logMessage( + LogLevel.WARN, + "utils", + `Failed to resolve room alias for ${roomId} - see console for details`, + null, + true + ); + LogService.warn( + "ManagementRoomOutput", + "Failed resolving room alias when formatting a message", + e + ); + } + const regexRoomId = new RegExp(escapeRegex(roomId), "g"); + content.body = content.body.replace(regexRoomId, alias); + if (content.formatted_body) { + const permalink = Permalinks.forRoom( + alias as StringRoomAlias, + alias !== roomId ? [] : viaServers + ); + content.formatted_body = content.formatted_body.replace( + regexRoomId, + `${htmlEscape(alias)}` + ); + } } - /** - * Take an arbitrary string and a set of room IDs, and return a - * TextualMessageEventContent whose plaintext component replaces those room - * IDs with their canonical aliases, and whose html component replaces those - * room IDs with their matrix.to room pills. - * - * @param client The matrix client on which to query for room aliases - * @param text An arbitrary string to rewrite with room aliases and pills - * @param roomIds A set of room IDs to find and replace in `text` - * @param msgtype The desired message type of the returned TextualMessageEventContent - * @returns A TextualMessageEventContent with replaced room IDs - */ - private async replaceRoomIdsWithPills(text: string, roomIds: Set, msgtype: MessageType = "m.text"): Promise { - const content: TextualMessageEventContent = { - body: text, - formatted_body: htmlEscape(text), - msgtype: msgtype, - format: "org.matrix.custom.html", - }; - - // Though spec doesn't say so, room ids that have slashes in them are accepted by Synapse and Dendrite unfortunately for us. - const escapeRegex = (v: string): string => { - return v.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); - }; - - const viaServers = [serverName(this.clientUserID)]; - for (const roomId of roomIds) { - let alias = roomId; - try { - alias = (await this.client.getPublishedAlias(roomId)) || roomId; - } catch (e) { - // This is a recursive call, so tell the function not to try and call us - await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true); - LogService.warn("ManagementRoomOutput", "Failed resolving room alias when formatting a message", e); - } - const regexRoomId = new RegExp(escapeRegex(roomId), "g"); - content.body = content.body.replace(regexRoomId, alias); - if (content.formatted_body) { - const permalink = Permalinks.forRoom(alias as StringRoomAlias, alias !== roomId ? [] : viaServers); - content.formatted_body = content.formatted_body.replace(regexRoomId, `${htmlEscape(alias)}`); - } - } - - return content; + return content; + } + + /** + * Log a message to the management room and the console, replaces any room ids in additionalRoomIds with pills. + * + * @param level Used to determine whether to hide the message or not depending on `config.verboseLogging`. + * @param module Used to help find where in the source the message is coming from (when logging to the console). + * @param message The message we want to log. + * @param additionalRoomIds The roomIds in the message that we want to be replaced by room pills. + * @param isRecursive Whether logMessage is being called from logMessage. + */ + public async logMessage( + level: LogLevel, + module: string, + message: string, + additionalRoomIds: string[] | string | null = null, + isRecursive = false + ): Promise { + if (level === LogLevel.ERROR) { + Sentry.captureMessage(`${module}: ${message}`, "error"); } - - /** - * Log a message to the management room and the console, replaces any room ids in additionalRoomIds with pills. - * - * @param level Used to determine whether to hide the message or not depending on `config.verboseLogging`. - * @param module Used to help find where in the source the message is coming from (when logging to the console). - * @param message The message we want to log. - * @param additionalRoomIds The roomIds in the message that we want to be replaced by room pills. - * @param isRecursive Whether logMessage is being called from logMessage. - */ - public async logMessage(level: LogLevel, module: string, message: string, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise { - if (level === LogLevel.ERROR) { - Sentry.captureMessage(`${module}: ${message}`, 'error'); - } - if (!additionalRoomIds) additionalRoomIds = []; - if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds]; - - if (this.config.verboseLogging || LogLevel.INFO.includes(level)) { - let clientMessage = message; - if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`; - if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`; - - const client = this.client; - const roomIds = [this.managementRoomID, ...additionalRoomIds]; - - let evContent: TextualMessageEventContent = { - body: message, - formatted_body: htmlEscape(message), - msgtype: "m.notice", - format: "org.matrix.custom.html", - }; - if (!isRecursive) { - evContent = await this.replaceRoomIdsWithPills(clientMessage, new Set(roomIds), "m.notice"); - } - - try { - await client.sendMessage(this.managementRoomID, evContent); - } catch (ex) { - // We want to be informed if we cannot log a message. - Sentry.captureException(ex); - } - } - const logFunction = levelToFn[level.toString()]; - if (logFunction === undefined) { - throw new TypeError(`Unable to find logFunction for log level: ${level.toString()}`); - } - logFunction(module, message); + if (!additionalRoomIds) additionalRoomIds = []; + if (!Array.isArray(additionalRoomIds)) + additionalRoomIds = [additionalRoomIds]; + + if (this.config.verboseLogging || LogLevel.INFO.includes(level)) { + let clientMessage = message; + if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`; + if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`; + + const client = this.client; + const roomIds = [this.managementRoomID, ...additionalRoomIds]; + + let evContent: TextualMessageEventContent = { + body: message, + formatted_body: htmlEscape(message), + msgtype: "m.notice", + format: "org.matrix.custom.html", + }; + if (!isRecursive) { + evContent = await this.replaceRoomIdsWithPills( + clientMessage, + new Set(roomIds), + "m.notice" + ); + } + + try { + await client.sendMessage(this.managementRoomID, evContent); + } catch (ex) { + // We want to be informed if we cannot log a message. + Sentry.captureException(ex); + } + } + const logFunction = levelToFn[level.toString()]; + if (logFunction === undefined) { + throw new TypeError( + `Unable to find logFunction for log level: ${level.toString()}` + ); } + logFunction(module, message); + } } diff --git a/src/appservice/AccessControl.ts b/src/appservice/AccessControl.ts index c04b7a69..180aa3ec 100644 --- a/src/appservice/AccessControl.ts +++ b/src/appservice/AccessControl.ts @@ -25,7 +25,21 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, EntityAccess, MatrixRoomID, Ok, PolicyListRevisionIssuer, PolicyRoomManager, StringUserID, isError, AccessControl as MPSAccess, PolicyRoomEditor, PolicyRuleType, Recommendation, RoomJoiner } from "matrix-protection-suite"; +import { + ActionResult, + EntityAccess, + MatrixRoomID, + Ok, + PolicyListRevisionIssuer, + PolicyRoomManager, + StringUserID, + isError, + AccessControl as MPSAccess, + PolicyRoomEditor, + PolicyRuleType, + Recommendation, + RoomJoiner, +} from "matrix-protection-suite"; /** * Utility to manage which users have access to the application service, @@ -33,60 +47,76 @@ import { ActionResult, EntityAccess, MatrixRoomID, Ok, PolicyListRevisionIssuer, * Internally we use a policy list within matrix to determine who has access via the `AccessControlUnit`. */ export class AccessControl { + private constructor( + private readonly accessControlRevisionIssuer: PolicyListRevisionIssuer, + private readonly editor: PolicyRoomEditor + ) { + // nothing to do. + } - private constructor( - private readonly accessControlRevisionIssuer: PolicyListRevisionIssuer, - private readonly editor: PolicyRoomEditor - ) { - // nothing to do. + /** + * Construct and initialize access control for the `MjolnirAppService`. + * @param accessControlListId The room id of a policy list used to manage access to the appservice (who can provision & use mjolniren) + * @param bridge The matrix-appservice-bridge, used to get the appservice bot. + * @returns A new instance of `AccessControl` to be used by `MjolnirAppService`. + */ + public static async setupAccessControlForRoom( + /** The room id for the access control list. */ + accessControlRoom: MatrixRoomID, + policyRoomManager: PolicyRoomManager, + bridgeBotJoiner: RoomJoiner + ): Promise> { + const joinResult = await bridgeBotJoiner.joinRoom( + accessControlRoom.toRoomIDOrAlias() + ); + if (isError(joinResult)) { + return joinResult; } - - /** - * Construct and initialize access control for the `MjolnirAppService`. - * @param accessControlListId The room id of a policy list used to manage access to the appservice (who can provision & use mjolniren) - * @param bridge The matrix-appservice-bridge, used to get the appservice bot. - * @returns A new instance of `AccessControl` to be used by `MjolnirAppService`. - */ - public static async setupAccessControlForRoom( - /** The room id for the access control list. */ - accessControlRoom: MatrixRoomID, - policyRoomManager: PolicyRoomManager, - bridgeBotJoiner: RoomJoiner, - ): Promise> { - const joinResult = await bridgeBotJoiner.joinRoom(accessControlRoom.toRoomIDOrAlias()); - if (isError(joinResult)) { - return joinResult; - } - const revisionIssuer = await policyRoomManager.getPolicyRoomRevisionIssuer(accessControlRoom); - if (isError(revisionIssuer)) { - return revisionIssuer; - } - const editor = await policyRoomManager.getPolicyRoomEditor(accessControlRoom); - if (isError(editor)) { - return editor; - } - return Ok(new AccessControl(revisionIssuer.ok, editor.ok)); + const revisionIssuer = + await policyRoomManager.getPolicyRoomRevisionIssuer(accessControlRoom); + if (isError(revisionIssuer)) { + return revisionIssuer; } - - public getUserAccess(mxid: StringUserID): EntityAccess { - return MPSAccess.getAccessForUser(this.accessControlRevisionIssuer.currentRevision, mxid, "CHECK_SERVER"); + const editor = + await policyRoomManager.getPolicyRoomEditor(accessControlRoom); + if (isError(editor)) { + return editor; } + return Ok(new AccessControl(revisionIssuer.ok, editor.ok)); + } + + public getUserAccess(mxid: StringUserID): EntityAccess { + return MPSAccess.getAccessForUser( + this.accessControlRevisionIssuer.currentRevision, + mxid, + "CHECK_SERVER" + ); + } - public async allow(mxid: StringUserID, reason = ""): Promise> { - const result = await this.editor.createPolicy(PolicyRuleType.User, Recommendation.Allow, mxid, reason, {}); - if (isError(result)) { - return result - } else { - return Ok(undefined); - } + public async allow( + mxid: StringUserID, + reason = "" + ): Promise> { + const result = await this.editor.createPolicy( + PolicyRuleType.User, + Recommendation.Allow, + mxid, + reason, + {} + ); + if (isError(result)) { + return result; + } else { + return Ok(undefined); } + } - public async remove(mxid: StringUserID): Promise> { - const result = await this.editor.unbanEntity(PolicyRuleType.User, mxid); - if (isError(result)) { - return result; - } else { - return Ok(undefined); - } + public async remove(mxid: StringUserID): Promise> { + const result = await this.editor.unbanEntity(PolicyRuleType.User, mxid); + if (isError(result)) { + return result; + } else { + return Ok(undefined); } + } } diff --git a/src/appservice/Api.ts b/src/appservice/Api.ts index 3632bbb2..85cfd3d3 100644 --- a/src/appservice/Api.ts +++ b/src/appservice/Api.ts @@ -11,221 +11,246 @@ const log = new Logger("Api"); * This provides a web api that is designed to power the mjolnir widget https://github.com/matrix-org/mjolnir-widget. */ export class Api { - private httpdConfig: express.Express = express(); - private httpServer?: http.Server; - - constructor( - private homeserver: string, - private mjolnirManager: AppServiceDraupnirManager, - ) {} - - /** - * Resolves an open id access token to find a matching user that the token is valid for. - * @param accessToken An openID token. - * @returns The mxid of the user that this token belongs to or null if the token could not be authenticated. - */ - private resolveAccessToken(accessToken: string): Promise { - return new Promise((resolve, reject) => { - request({ - url: `${this.homeserver}/_matrix/federation/v1/openid/userinfo`, - qs: { access_token: accessToken }, - }, (err, homeserver_response, body) => { - if (err) { - log.error(`Error resolving openID token from ${this.homeserver}`, err); - if (err instanceof Error) { - reject(err) - } else { - reject(new Error(`There was an error when resolving openID token from ${this.homeserver}`)); - } - } - let response: { sub: string}; - try { - response = JSON.parse(body); - } catch (e) { - log.error(`Received ill formed response from ${this.homeserver} when resolving an openID token`, e); - if (err instanceof Error) { - reject(err); - } - reject(new Error(`Received ill formed response from ${this.homeserver} when resolving an openID token ${e}`)); - return; - } - - resolve(response.sub); - }); - }); - } - - public async close(): Promise { - await new Promise((resolve, reject) => { - if (!this.httpServer) { - throw new TypeError("Server was never started"); + private httpdConfig: express.Express = express(); + private httpServer?: http.Server; + + constructor( + private homeserver: string, + private mjolnirManager: AppServiceDraupnirManager + ) {} + + /** + * Resolves an open id access token to find a matching user that the token is valid for. + * @param accessToken An openID token. + * @returns The mxid of the user that this token belongs to or null if the token could not be authenticated. + */ + private resolveAccessToken(accessToken: string): Promise { + return new Promise((resolve, reject) => { + request( + { + url: `${this.homeserver}/_matrix/federation/v1/openid/userinfo`, + qs: { access_token: accessToken }, + }, + (err, homeserver_response, body) => { + if (err) { + log.error( + `Error resolving openID token from ${this.homeserver}`, + err + ); + if (err instanceof Error) { + reject(err); + } else { + reject( + new Error( + `There was an error when resolving openID token from ${this.homeserver}` + ) + ); } - this.httpServer.close(error => { error ? reject(error) : resolve(undefined); }) - }); - } + } + let response: { sub: string }; + try { + response = JSON.parse(body); + } catch (e) { + log.error( + `Received ill formed response from ${this.homeserver} when resolving an openID token`, + e + ); + if (err instanceof Error) { + reject(err); + } + reject( + new Error( + `Received ill formed response from ${this.homeserver} when resolving an openID token ${e}` + ) + ); + return; + } - public start(port: number) { - if (this.httpServer) { - throw new TypeError("server already started"); + resolve(response.sub); } - this.httpdConfig.use(bodyParser.json()); + ); + }); + } - this.httpdConfig.get("/get", this.pathGet.bind(this)); - this.httpdConfig.get("/list", this.pathList.bind(this)); - this.httpdConfig.post("/create", this.pathCreate.bind(this)); - this.httpdConfig.post("/join", this.pathJoin.bind(this)); + public async close(): Promise { + await new Promise((resolve, reject) => { + if (!this.httpServer) { + throw new TypeError("Server was never started"); + } + this.httpServer.close((error) => { + error ? reject(error) : resolve(undefined); + }); + }); + } - this.httpServer = this.httpdConfig.listen(port); + public start(port: number) { + if (this.httpServer) { + throw new TypeError("server already started"); } + this.httpdConfig.use(bodyParser.json()); - /** - * Finds the management room for a mjolnir. - * @param req.body.openId An OpenID token to verify that the sender of the request owns the mjolnir described in `req.body.mxid`. - * @param req.body.mxid The mxid of the mjolnir we want to find the management room for. - */ - private async pathGet(req: express.Request, response: express.Response) { - const accessToken = req.body["openId"]; - if (accessToken === undefined) { - response.status(401).send("unauthorised"); - return; - } + this.httpdConfig.get("/get", this.pathGet.bind(this)); + this.httpdConfig.get("/list", this.pathList.bind(this)); + this.httpdConfig.post("/create", this.pathCreate.bind(this)); + this.httpdConfig.post("/join", this.pathJoin.bind(this)); - const userId = await this.resolveAccessToken(accessToken); - if (userId === null) { - response.status(401).send("unauthorised"); - return; - } - if (!isStringUserID(userId)) { - response.status(400).send("invalid user mxid"); - return; - } + this.httpServer = this.httpdConfig.listen(port); + } - const mjolnirId = req.body["mxid"]; - if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { - response.status(400).send("invalid request"); - return; - } + /** + * Finds the management room for a mjolnir. + * @param req.body.openId An OpenID token to verify that the sender of the request owns the mjolnir described in `req.body.mxid`. + * @param req.body.mxid The mxid of the mjolnir we want to find the management room for. + */ + private async pathGet(req: express.Request, response: express.Response) { + const accessToken = req.body["openId"]; + if (accessToken === undefined) { + response.status(401).send("unauthorised"); + return; + } - const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId); - if (mjolnir === undefined) { - response.status(400).send("unknown mjolnir mxid"); - return; - } + const userId = await this.resolveAccessToken(accessToken); + if (userId === null) { + response.status(401).send("unauthorised"); + return; + } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - response.status(200).json({ managementRoom: mjolnir.managementRoomID }); + const mjolnirId = req.body["mxid"]; + if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { + response.status(400).send("invalid request"); + return; } - /** - * Return the mxids of mjolnirs that this user has provisioned. - * @param req.body.openId An OpenID token to find the sender of the request with and find their provisioned mjolnirs. - */ - private async pathList(req: express.Request, response: express.Response) { - const accessToken = req.body["openId"]; - if (accessToken === undefined) { - response.status(401).send("unauthorised"); - return; - } + const mjolnir = await this.mjolnirManager.getRunningDraupnir( + mjolnirId, + userId + ); + if (mjolnir === undefined) { + response.status(400).send("unknown mjolnir mxid"); + return; + } - const userId = await this.resolveAccessToken(accessToken); - if (userId === null) { - response.status(401).send("unauthorised"); - return; - } - if (!isStringUserID(userId)) { - response.status(400).send("invalid user mxid"); - return; - } + response.status(200).json({ managementRoom: mjolnir.managementRoomID }); + } - const existing = this.mjolnirManager.getOwnedDraupnir(userId) - response.status(200).json(existing); + /** + * Return the mxids of mjolnirs that this user has provisioned. + * @param req.body.openId An OpenID token to find the sender of the request with and find their provisioned mjolnirs. + */ + private async pathList(req: express.Request, response: express.Response) { + const accessToken = req.body["openId"]; + if (accessToken === undefined) { + response.status(401).send("unauthorised"); + return; } - /** - * Creates a new mjolnir for the requesting user and protects their first room. - * @param req.body.roomId The room id that the request to create a mjolnir originates from. - * This is so that mjolnir can protect the room once the authenticity of the request has been verified. - * @param req.body.openId An OpenID token to find the sender of the request with. - */ - private async pathCreate(req: express.Request, response: express.Response) { - const accessToken = req.body["openId"]; - if (accessToken === undefined) { - response.status(401).send("unauthorised"); - return; - } + const userId = await this.resolveAccessToken(accessToken); + if (userId === null) { + response.status(401).send("unauthorised"); + return; + } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - const roomId = req.body["roomId"]; - if (roomId === undefined) { - response.status(400).send("invalid request"); - return; - } + const existing = this.mjolnirManager.getOwnedDraupnir(userId); + response.status(200).json(existing); + } - const userId = await this.resolveAccessToken(accessToken); - if (userId === null) { - response.status(401).send("unauthorised"); - return; - } - if (!isStringUserID(userId)) { - response.status(400).send("invalid user mxid"); - return; - } + /** + * Creates a new mjolnir for the requesting user and protects their first room. + * @param req.body.roomId The room id that the request to create a mjolnir originates from. + * This is so that mjolnir can protect the room once the authenticity of the request has been verified. + * @param req.body.openId An OpenID token to find the sender of the request with. + */ + private async pathCreate(req: express.Request, response: express.Response) { + const accessToken = req.body["openId"]; + if (accessToken === undefined) { + response.status(401).send("unauthorised"); + return; + } - const record = await this.mjolnirManager.provisionNewDraupnir(userId); - if (isError(record)) { - response.status(500).send(record.error.message); - return; - } - response.status(200).json({ - mxid: this.mjolnirManager.draupnirMXID(record.ok), - roomId: record.ok.management_room - }); - } - - /** - * Request a mjolnir to join and protect a room. - * @param req.body.openId An OpenID token to find the sender of the request with and that they own the mjolnir described in `req.body.mxid`. - * @param req.body.mxid The mxid of the mjolnir that should join the room. - * @param req.body.roomId The room that this mjolnir should join and protect. - */ - private async pathJoin(req: express.Request, response: express.Response) { - const accessToken = req.body["openId"]; - if (accessToken === undefined) { - response.status(401).send("unauthorised"); - return; - } + const roomId = req.body["roomId"]; + if (roomId === undefined) { + response.status(400).send("invalid request"); + return; + } - const userId = await this.resolveAccessToken(accessToken); - if (userId === null) { - response.status(401).send("unauthorised"); - return; - } - if (!isStringUserID(userId)) { - response.status(400).send("invalid user mxid"); - return; - } + const userId = await this.resolveAccessToken(accessToken); + if (userId === null) { + response.status(401).send("unauthorised"); + return; + } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - const mjolnirId = req.body["mxid"]; - if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { - response.status(400).send("invalid request"); - return; - } + const record = await this.mjolnirManager.provisionNewDraupnir(userId); + if (isError(record)) { + response.status(500).send(record.error.message); + return; + } + response.status(200).json({ + mxid: this.mjolnirManager.draupnirMXID(record.ok), + roomId: record.ok.management_room, + }); + } - const roomId = req.body["roomId"]; - if (roomId === undefined) { - response.status(400).send("invalid request"); - return; - } + /** + * Request a mjolnir to join and protect a room. + * @param req.body.openId An OpenID token to find the sender of the request with and that they own the mjolnir described in `req.body.mxid`. + * @param req.body.mxid The mxid of the mjolnir that should join the room. + * @param req.body.roomId The room that this mjolnir should join and protect. + */ + private async pathJoin(req: express.Request, response: express.Response) { + const accessToken = req.body["openId"]; + if (accessToken === undefined) { + response.status(401).send("unauthorised"); + return; + } - // TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId. - // https://github.com/matrix-org/mjolnir/issues/408 - const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId); - if (mjolnir === undefined) { - response.status(400).send("unknown mjolnir mxid"); - return; - } + const userId = await this.resolveAccessToken(accessToken); + if (userId === null) { + response.status(401).send("unauthorised"); + return; + } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - await mjolnir.client.joinRoom(roomId); - await mjolnir.protectedRoomsSet.protectedRoomsManager.addRoom(roomId); + const mjolnirId = req.body["mxid"]; + if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { + response.status(400).send("invalid request"); + return; + } - response.status(200).json({}); + const roomId = req.body["roomId"]; + if (roomId === undefined) { + response.status(400).send("invalid request"); + return; } + + // TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId. + // https://github.com/matrix-org/mjolnir/issues/408 + const mjolnir = await this.mjolnirManager.getRunningDraupnir( + mjolnirId, + userId + ); + if (mjolnir === undefined) { + response.status(400).send("unknown mjolnir mxid"); + return; + } + + await mjolnir.client.joinRoom(roomId); + await mjolnir.protectedRoomsSet.protectedRoomsManager.addRoom(roomId); + + response.status(200).json({}); + } } diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 7f6db958..81804829 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -25,7 +25,16 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AppServiceRegistration, Bridge, Request, WeakEvent, MatrixUser, Logger, setBridgeVersion, PrometheusMetrics } from "matrix-appservice-bridge"; +import { + AppServiceRegistration, + Bridge, + Request, + WeakEvent, + MatrixUser, + Logger, + setBridgeVersion, + PrometheusMetrics, +} from "matrix-appservice-bridge"; import { DataStore } from ".//datastore"; import { PgDataStore } from "./postgres/PgDataStore"; import { Api } from "./Api"; @@ -33,9 +42,26 @@ import { IConfig } from "./config/config"; import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; -import { Registry } from 'prom-client'; -import { ClientCapabilityFactory, RoomStateManagerFactory, joinedRoomsSafe, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringRoomID, StringUserID, Task, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; +import { Registry } from "prom-client"; +import { + ClientCapabilityFactory, + RoomStateManagerFactory, + joinedRoomsSafe, + resolveRoomReferenceSafe, +} from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + ClientsInRoomMap, + DefaultEventDecoder, + EventDecoder, + MatrixRoomReference, + StandardClientsInRoomMap, + StringRoomID, + StringUserID, + Task, + isError, + isStringRoomAlias, + isStringRoomID, +} from "matrix-protection-suite"; import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); @@ -44,268 +70,310 @@ const log = new Logger("AppService"); * the entrypoint of the application. */ export class MjolnirAppService { + private readonly api: Api; + private readonly commands: AppserviceCommandHandler; - private readonly api: Api; - private readonly commands: AppserviceCommandHandler; + /** + * The constructor is private because we want to ensure intialization steps are followed, + * use `makeMjolnirAppService`. + */ + private constructor( + public readonly config: IConfig, + public readonly bridge: Bridge, + public readonly draupnirManager: AppServiceDraupnirManager, + public readonly accessControl: AccessControl, + private readonly dataStore: DataStore, + private readonly eventDecoder: EventDecoder, + private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly clientCapabilityFactory: ClientCapabilityFactory, + private readonly clientsInRoomMap: ClientsInRoomMap, + private readonly prometheusMetrics: PrometheusMetrics, + public readonly accessControlRoomID: StringRoomID, + public readonly botUserID: StringUserID + ) { + this.api = new Api(config.homeserver.url, draupnirManager); + const client = this.bridge.getBot().getClient(); + this.commands = new AppserviceCommandHandler( + botUserID, + client, + accessControlRoomID, + this.clientCapabilityFactory.makeClientPlatform(botUserID, client), + this + ); + } - /** - * The constructor is private because we want to ensure intialization steps are followed, - * use `makeMjolnirAppService`. - */ - private constructor( - public readonly config: IConfig, - public readonly bridge: Bridge, - public readonly draupnirManager: AppServiceDraupnirManager, - public readonly accessControl: AccessControl, - private readonly dataStore: DataStore, - private readonly eventDecoder: EventDecoder, - private readonly roomStateManagerFactory: RoomStateManagerFactory, - private readonly clientCapabilityFactory: ClientCapabilityFactory, - private readonly clientsInRoomMap: ClientsInRoomMap, - private readonly prometheusMetrics: PrometheusMetrics, - public readonly accessControlRoomID: StringRoomID, - public readonly botUserID: StringUserID, - ) { - this.api = new Api(config.homeserver.url, draupnirManager); - const client = this.bridge.getBot().getClient(); - this.commands = new AppserviceCommandHandler( - botUserID, - client, - accessControlRoomID, - this.clientCapabilityFactory.makeClientPlatform(botUserID, client), - this - ); + /** + * Make and initialize the app service from the config, ready to be started. + * @param config The appservice's config, not mjolnir's, see `src/appservice/config`. + * @param dataStore A datastore to persist infomration about the mjolniren to. + * @param registrationFilePath A file path to the registration file to read the namespace and tokens from. + * @returns A new `MjolnirAppService`. + */ + public static async makeMjolnirAppService( + config: IConfig, + dataStore: DataStore, + eventDecoder: EventDecoder, + registrationFilePath: string + ) { + const bridge = new Bridge({ + homeserverUrl: config.homeserver.url, + domain: config.homeserver.domain, + registration: registrationFilePath, + // We lazily initialize the controller to avoid null checks + // It also allows us to combine constructor/initialize logic + // to make the code base much simpler. A small hack to pay for an overall less hacky code base. + controller: { + onUserQuery: () => { + throw new Error("Mjolnir uninitialized"); + }, + onEvent: () => { + throw new Error("Mjolnir uninitialized"); + }, + }, + suppressEcho: false, + disableStores: true, + }); + await bridge.initialise(); + const adminRoom = (() => { + if (isStringRoomID(config.adminRoom)) { + return MatrixRoomReference.fromRoomID(config.adminRoom); + } else if (isStringRoomAlias(config.adminRoom)) { + return MatrixRoomReference.fromRoomIDOrAlias(config.adminRoom); + } else { + const parseResult = MatrixRoomReference.fromPermalink(config.adminRoom); + if (isError(parseResult)) { + throw new TypeError( + `${config.adminRoom} needs to be a room id, alias or permalink` + ); + } + return parseResult.ok; + } + })(); + const accessControlRoom = await resolveRoomReferenceSafe( + bridge.getBot().getClient(), + adminRoom + ); + if (isError(accessControlRoom)) { + throw accessControlRoom.error; + } + const clientsInRoomMap = new StandardClientsInRoomMap(); + const clientProvider = async (clientUserID: StringUserID) => + bridge.getIntent(clientUserID).matrixClient; + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + clientProvider, + eventDecoder + ); + const clientCapabilityFactory = new ClientCapabilityFactory( + clientsInRoomMap, + eventDecoder + ); + const botUserID = bridge.getBot().getUserId() as StringUserID; + const clientRooms = await clientsInRoomMap.makeClientRooms( + botUserID, + async () => joinedRoomsSafe(bridge.getBot().getClient()) + ); + if (isError(clientRooms)) { + throw clientRooms.error; } + const botRoomJoiner = clientCapabilityFactory + .makeClientPlatform(botUserID, bridge.getBot().getClient()) + .toRoomJoiner(); + const appserviceBotPolicyRoomManager = + await roomStateManagerFactory.getPolicyRoomManager(botUserID); + const accessControl = await AccessControl.setupAccessControlForRoom( + accessControlRoom.ok, + appserviceBotPolicyRoomManager, + botRoomJoiner + ); + if (isError(accessControl)) { + throw accessControl.error; + } + // Activate /metrics endpoint for Prometheus - /** - * Make and initialize the app service from the config, ready to be started. - * @param config The appservice's config, not mjolnir's, see `src/appservice/config`. - * @param dataStore A datastore to persist infomration about the mjolniren to. - * @param registrationFilePath A file path to the registration file to read the namespace and tokens from. - * @returns A new `MjolnirAppService`. - */ - public static async makeMjolnirAppService(config: IConfig, dataStore: DataStore, eventDecoder: EventDecoder, registrationFilePath: string) { - const bridge = new Bridge({ - homeserverUrl: config.homeserver.url, - domain: config.homeserver.domain, - registration: registrationFilePath, - // We lazily initialize the controller to avoid null checks - // It also allows us to combine constructor/initialize logic - // to make the code base much simpler. A small hack to pay for an overall less hacky code base. - controller: { - onUserQuery: () => { - throw new Error("Mjolnir uninitialized") - }, - onEvent: () => { - throw new Error("Mjolnir uninitialized") - }, - }, - suppressEcho: false, - disableStores: true, - }); - await bridge.initialise(); - const adminRoom = (() => { - if (isStringRoomID(config.adminRoom)) { - return MatrixRoomReference.fromRoomID(config.adminRoom); - } else if (isStringRoomAlias(config.adminRoom)) { - return MatrixRoomReference.fromRoomIDOrAlias(config.adminRoom); - } else { - const parseResult = MatrixRoomReference.fromPermalink(config.adminRoom); - if (isError(parseResult)) { - throw new TypeError(`${config.adminRoom} needs to be a room id, alias or permalink`); - } - return parseResult.ok; - } - })(); - const accessControlRoom = await resolveRoomReferenceSafe(bridge.getBot().getClient(), adminRoom); - if (isError(accessControlRoom)) { - throw accessControlRoom.error; - } - const clientsInRoomMap = new StandardClientsInRoomMap(); - const clientProvider = async (clientUserID: StringUserID) => bridge.getIntent(clientUserID).matrixClient; - const roomStateManagerFactory = new RoomStateManagerFactory( - clientsInRoomMap, - clientProvider, - eventDecoder - ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap, eventDecoder); - const botUserID = bridge.getBot().getUserId() as StringUserID; - const clientRooms = await clientsInRoomMap.makeClientRooms( - botUserID, - async () => joinedRoomsSafe(bridge.getBot().getClient()), - ); - if (isError(clientRooms)) { - throw clientRooms.error; - } - const botRoomJoiner = clientCapabilityFactory.makeClientPlatform(botUserID, bridge.getBot().getClient()).toRoomJoiner(); - const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(botUserID); - const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, botRoomJoiner); - if (isError(accessControl)) { - throw accessControl.error; - } - // Activate /metrics endpoint for Prometheus + // This should happen automatically but in testing this didn't happen in the docker image + setBridgeVersion(SOFTWARE_VERSION); - // This should happen automatically but in testing this didn't happen in the docker image - setBridgeVersion(SOFTWARE_VERSION); + // Due to the way the tests and this prom library works we need to explicitly create a new one each time. + const prometheus = bridge.getPrometheusMetrics(true, new Registry()); + const instanceCountGauge = prometheus.addGauge({ + name: "draupnir_instances", + help: "Count of Draupnir Instances", + labels: ["status", "uuid"], + }); - // Due to the way the tests and this prom library works we need to explicitly create a new one each time. - const prometheus = bridge.getPrometheusMetrics(true, new Registry()); - const instanceCountGauge = prometheus.addGauge({ - name: "draupnir_instances", - help: "Count of Draupnir Instances", - labels: ["status", "uuid"], - }); + const serverName = config.homeserver.domain; + const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager( + serverName, + dataStore, + bridge, + accessControl.ok, + roomStateManagerFactory, + clientCapabilityFactory, + clientProvider, + instanceCountGauge + ); + const appService = new MjolnirAppService( + config, + bridge, + mjolnirManager, + accessControl.ok, + dataStore, + eventDecoder, + roomStateManagerFactory, + clientCapabilityFactory, + clientsInRoomMap, + prometheus, + accessControlRoom.ok.toRoomIDOrAlias(), + botUserID + ); + bridge.opts.controller = { + onUserQuery: appService.onUserQuery.bind(appService), + onEvent: appService.onEvent.bind(appService), + }; + return appService; + } - const serverName = config.homeserver.domain; - const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager( - serverName, - dataStore, - bridge, - accessControl.ok, - roomStateManagerFactory, - clientCapabilityFactory, - clientProvider, - instanceCountGauge - ); - const appService = new MjolnirAppService( - config, - bridge, - mjolnirManager, - accessControl.ok, - dataStore, - eventDecoder, - roomStateManagerFactory, - clientCapabilityFactory, - clientsInRoomMap, - prometheus, - accessControlRoom.ok.toRoomIDOrAlias(), - botUserID - ); - bridge.opts.controller = { - onUserQuery: appService.onUserQuery.bind(appService), - onEvent: appService.onEvent.bind(appService), - }; - return appService; - } + /** + * Start the appservice for the end user with the appropriate settings from their config and registration file. + * @param port The port to make the appservice listen for transactions from the homeserver on (usually sourced from the cli). + * @param config The parsed configuration file. + * @param registrationFilePath A path to their homeserver registration file. + */ + public static async run( + port: number, + config: IConfig, + registrationFilePath: string + ): Promise { + Logger.configure(config.logging ?? { console: "debug" }); + const dataStore = new PgDataStore(config.db.connectionString); + await dataStore.init(); + const service = await MjolnirAppService.makeMjolnirAppService( + config, + dataStore, + DefaultEventDecoder, + registrationFilePath + ); + // The call to `start` MUST happen last. As it needs the datastore, and the mjolnir manager to be initialized before it can process events from the homeserver. + await service.start(port); + return service; + } - /** - * Start the appservice for the end user with the appropriate settings from their config and registration file. - * @param port The port to make the appservice listen for transactions from the homeserver on (usually sourced from the cli). - * @param config The parsed configuration file. - * @param registrationFilePath A path to their homeserver registration file. - */ - public static async run(port: number, config: IConfig, registrationFilePath: string): Promise { - Logger.configure(config.logging ?? { console: "debug" }); - const dataStore = new PgDataStore(config.db.connectionString); - await dataStore.init(); - const service = await MjolnirAppService.makeMjolnirAppService(config, dataStore, DefaultEventDecoder, registrationFilePath); - // The call to `start` MUST happen last. As it needs the datastore, and the mjolnir manager to be initialized before it can process events from the homeserver. - await service.start(port); - return service; - } + public onUserQuery(_queriedUser: MatrixUser) { + return {}; // auto-provision users with no additonal data + } - public onUserQuery(_queriedUser: MatrixUser) { - return {}; // auto-provision users with no additonal data + // Provision a new mjolnir for the invitee when the appservice bot (designated by this.bridge.botUserId) is invited to a room. + // Acts as an alternative to the web api provided for the widget. + private async handleProvisionInvite(mxEvent: WeakEvent): Promise { + log.info( + `${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a mjolnir` + ); + try { + const result = await this.draupnirManager.provisionNewDraupnir( + mxEvent.sender as StringUserID + ); + if (isError(result)) { + log.error( + `Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`, + result.error + ); + } + } catch (e: unknown) { + log.error( + `Failed to provision a mjolnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, + e + ); + // continue, we still want to reject this invitation. } - - // Provision a new mjolnir for the invitee when the appservice bot (designated by this.bridge.botUserId) is invited to a room. - // Acts as an alternative to the web api provided for the widget. - private async handleProvisionInvite(mxEvent: WeakEvent): Promise { - log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a mjolnir`); - try { - const result = await this.draupnirManager.provisionNewDraupnir(mxEvent.sender as StringUserID); - if (isError(result)) { - log.error(`Failed to provision a draupnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}`, result.error); - } - } catch (e: unknown) { - log.error(`Failed to provision a mjolnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e); - // continue, we still want to reject this invitation. - } - try { - // reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their mjolnir. - await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id); - } catch (e: unknown) { - log.warn("Unable to reject an invite to a room", e); - } + try { + // reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their mjolnir. + await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id); + } catch (e: unknown) { + log.warn("Unable to reject an invite to a room", e); } + } - /** - * Handle an individual event pushed by the homeserver to us. - * This function is async (and anything downstream would be anyway), which does mean that events can be processed out of order. - * Not a huge problem for us, but is something to be aware of. - * @param request A matrix-appservice-bridge request encapsulating a Matrix event. - * @param context Additional context for the Matrix event. - */ - public onEvent(request: Request): void { - const mxEvent = request.getData(); - if ('m.room.member' === mxEvent.type) { - if ('invite' === mxEvent.content['membership']) { - if (mxEvent.room_id === this.accessControlRoomID) { - // do nothing, setup code should handle this. - } else if (mxEvent.state_key === this.bridge.botUserId) { - void Task(this.handleProvisionInvite(mxEvent)) - } - } + /** + * Handle an individual event pushed by the homeserver to us. + * This function is async (and anything downstream would be anyway), which does mean that events can be processed out of order. + * Not a huge problem for us, but is something to be aware of. + * @param request A matrix-appservice-bridge request encapsulating a Matrix event. + * @param context Additional context for the Matrix event. + */ + public onEvent(request: Request): void { + const mxEvent = request.getData(); + if ("m.room.member" === mxEvent.type) { + if ("invite" === mxEvent.content["membership"]) { + if (mxEvent.room_id === this.accessControlRoomID) { + // do nothing, setup code should handle this. + } else if (mxEvent.state_key === this.bridge.botUserId) { + void Task(this.handleProvisionInvite(mxEvent)); } - this.commands.handleEvent(mxEvent); - const decodeResult = this.eventDecoder.decodeEvent(mxEvent); - if (isError(decodeResult)) { - log.error( - `Got an error when decoding an event for the appservice`, - decodeResult.error.uuid, - decodeResult.error - ); - return; - } - const roomID = decodeResult.ok.room_id; - this.roomStateManagerFactory.handleTimelineEvent(roomID, decodeResult.ok); - this.clientsInRoomMap.handleTimelineEvent(roomID, decodeResult.ok); + } } - - /** - * Start the appservice. See `run`. - * @param port The port that the appservice should listen on to receive transactions from the homeserver. - */ - private async start(port: number) { - log.info("Starting MjolnirAppService, Matrix-side to listen on port", port); - this.api.start(this.config.webAPI.port); - await this.bridge.listen(port); - this.prometheusMetrics.addAppServicePath(this.bridge); - this.bridge.addAppServicePath({ - method: "GET", - path: "/healthz", - authenticate: false, - handler: (_req, res) => { - res.status(200).send('ok'); - } - }); - log.info("MjolnirAppService started successfully"); + this.commands.handleEvent(mxEvent); + const decodeResult = this.eventDecoder.decodeEvent(mxEvent); + if (isError(decodeResult)) { + log.error( + `Got an error when decoding an event for the appservice`, + decodeResult.error.uuid, + decodeResult.error + ); + return; } + const roomID = decodeResult.ok.room_id; + this.roomStateManagerFactory.handleTimelineEvent(roomID, decodeResult.ok); + this.clientsInRoomMap.handleTimelineEvent(roomID, decodeResult.ok); + } - /** - * Stop listening to requests from both the homeserver and web api and disconnect from the datastore. - */ - public async close(): Promise { - await this.bridge.close(); - await this.dataStore.close(); - await this.api.close(); - } + /** + * Start the appservice. See `run`. + * @param port The port that the appservice should listen on to receive transactions from the homeserver. + */ + private async start(port: number) { + log.info("Starting MjolnirAppService, Matrix-side to listen on port", port); + this.api.start(this.config.webAPI.port); + await this.bridge.listen(port); + this.prometheusMetrics.addAppServicePath(this.bridge); + this.bridge.addAppServicePath({ + method: "GET", + path: "/healthz", + authenticate: false, + handler: (_req, res) => { + res.status(200).send("ok"); + }, + }); + log.info("MjolnirAppService started successfully"); + } - /** - * Generate a registration file for a fresh deployment of the appservice. - * Included to satisfy `matrix-appservice-bridge`'s `Cli` utility which allows a registration file to be registered when setting up a deployment of an appservice. - * @param reg Any existing parameters to be included in the registration, to be mutated by this method. - * @param callback To call when the registration has been generated with the final registration. - */ - public static generateRegistration(reg: AppServiceRegistration, callback: (finalRegistration: AppServiceRegistration) => void) { - reg.setId(AppServiceRegistration.generateToken()); - reg.setHomeserverToken(AppServiceRegistration.generateToken()); - reg.setAppServiceToken(AppServiceRegistration.generateToken()); - reg.setSenderLocalpart('draupnir-moderation'); - // This is maintained for backwards compatibility with mjolnir4all. - reg.addRegexPattern("users", "@mjolnir_.*", true); - reg.addRegexPattern("users", "@draupnir_.*", true); - reg.setRateLimited(false); - callback(reg); - } + /** + * Stop listening to requests from both the homeserver and web api and disconnect from the datastore. + */ + public async close(): Promise { + await this.bridge.close(); + await this.dataStore.close(); + await this.api.close(); + } + + /** + * Generate a registration file for a fresh deployment of the appservice. + * Included to satisfy `matrix-appservice-bridge`'s `Cli` utility which allows a registration file to be registered when setting up a deployment of an appservice. + * @param reg Any existing parameters to be included in the registration, to be mutated by this method. + * @param callback To call when the registration has been generated with the final registration. + */ + public static generateRegistration( + reg: AppServiceRegistration, + callback: (finalRegistration: AppServiceRegistration) => void + ) { + reg.setId(AppServiceRegistration.generateToken()); + reg.setHomeserverToken(AppServiceRegistration.generateToken()); + reg.setAppServiceToken(AppServiceRegistration.generateToken()); + reg.setSenderLocalpart("draupnir-moderation"); + // This is maintained for backwards compatibility with mjolnir4all. + reg.addRegexPattern("users", "@mjolnir_.*", true); + reg.addRegexPattern("users", "@draupnir_.*", true); + reg.setRateLimited(false); + callback(reg); + } } diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index 7dc72bba..fddc2505 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -33,14 +33,36 @@ import { AccessControl } from "./AccessControl"; import { randomUUID } from "crypto"; import { Gauge } from "prom-client"; import { decrementGaugeValue, incrementGaugeValue } from "../utils"; -import { Access, ActionError, ActionException, ActionExceptionKind, ActionResult, MatrixRoomReference, Ok, PropagationType, StringRoomID, StringUserID, Task, isError, isStringRoomID, userLocalpart } from "matrix-protection-suite"; +import { + Access, + ActionError, + ActionException, + ActionExceptionKind, + ActionResult, + MatrixRoomReference, + Ok, + PropagationType, + StringRoomID, + StringUserID, + Task, + isError, + isStringRoomID, + userLocalpart, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -import { ClientCapabilityFactory, ClientForUserID, RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { DraupnirFailType, StandardDraupnirManager, UnstartedDraupnir } from "../draupnirfactory/StandardDraupnirManager"; +import { + ClientCapabilityFactory, + ClientForUserID, + RoomStateManagerFactory, +} from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + DraupnirFailType, + StandardDraupnirManager, + UnstartedDraupnir, +} from "../draupnirfactory/StandardDraupnirManager"; import { DraupnirFactory } from "../draupnirfactory/DraupnirFactory"; -const log = new Logger('AppServiceDraupnirManager'); - +const log = new Logger("AppServiceDraupnirManager"); /** * The DraupnirManager is responsible for: @@ -49,268 +71,358 @@ const log = new Logger('AppServiceDraupnirManager'); * * Informing draupnir about new events. */ export class AppServiceDraupnirManager { + private readonly baseManager: StandardDraupnirManager; - private readonly baseManager: StandardDraupnirManager; - - private constructor( - private readonly serverName: string, - private readonly dataStore: DataStore, - private readonly bridge: Bridge, - private readonly accessControl: AccessControl, - private readonly roomStateManagerFactory: RoomStateManagerFactory, - private readonly clientCapabilityFactory: ClientCapabilityFactory, - clientProvider: ClientForUserID, - private readonly instanceCountGauge: Gauge<"status" | "uuid"> - ) { - const draupnirFactory = new DraupnirFactory( - this.roomStateManagerFactory.clientsInRoomMap, - this.clientCapabilityFactory, - clientProvider, - this.roomStateManagerFactory - ); - this.baseManager = new StandardDraupnirManager( - draupnirFactory - ); - } + private constructor( + private readonly serverName: string, + private readonly dataStore: DataStore, + private readonly bridge: Bridge, + private readonly accessControl: AccessControl, + private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly clientCapabilityFactory: ClientCapabilityFactory, + clientProvider: ClientForUserID, + private readonly instanceCountGauge: Gauge<"status" | "uuid"> + ) { + const draupnirFactory = new DraupnirFactory( + this.roomStateManagerFactory.clientsInRoomMap, + this.clientCapabilityFactory, + clientProvider, + this.roomStateManagerFactory + ); + this.baseManager = new StandardDraupnirManager(draupnirFactory); + } - public draupnirMXID(mjolnirRecord: MjolnirRecord): StringUserID { - return `@${mjolnirRecord.local_part}:${this.serverName}` as StringUserID; - } + public draupnirMXID(mjolnirRecord: MjolnirRecord): StringUserID { + return `@${mjolnirRecord.local_part}:${this.serverName}` as StringUserID; + } - /** - * Create the draupnir manager from the datastore and the access control. - * @param dataStore The data store interface that has the details for provisioned draupnirs. - * @param bridge The bridge abstraction that encapsulates details about the appservice. - * @param accessControl Who has access to the bridge. - * @returns A new mjolnir manager. - */ - public static async makeDraupnirManager( - serverName: string, - dataStore: DataStore, - bridge: Bridge, - accessControl: AccessControl, - roomStateManagerFactory: RoomStateManagerFactory, - clientCapabilityFactory: ClientCapabilityFactory, - clientProvider: ClientForUserID, - instanceCountGauge: Gauge<"status" | "uuid"> - ): Promise { - const draupnirManager = new AppServiceDraupnirManager( - serverName, - dataStore, - bridge, - accessControl, - roomStateManagerFactory, - clientCapabilityFactory, - clientProvider, - instanceCountGauge - ); - await draupnirManager.startDraupnirs(await dataStore.list()); - return draupnirManager; - } + /** + * Create the draupnir manager from the datastore and the access control. + * @param dataStore The data store interface that has the details for provisioned draupnirs. + * @param bridge The bridge abstraction that encapsulates details about the appservice. + * @param accessControl Who has access to the bridge. + * @returns A new mjolnir manager. + */ + public static async makeDraupnirManager( + serverName: string, + dataStore: DataStore, + bridge: Bridge, + accessControl: AccessControl, + roomStateManagerFactory: RoomStateManagerFactory, + clientCapabilityFactory: ClientCapabilityFactory, + clientProvider: ClientForUserID, + instanceCountGauge: Gauge<"status" | "uuid"> + ): Promise { + const draupnirManager = new AppServiceDraupnirManager( + serverName, + dataStore, + bridge, + accessControl, + roomStateManagerFactory, + clientCapabilityFactory, + clientProvider, + instanceCountGauge + ); + await draupnirManager.startDraupnirs(await dataStore.list()); + return draupnirManager; + } - /** - * Creates a new mjolnir for a user. - * @param requestingUserID The user that is requesting this mjolnir and who will own it. - * @param managementRoomId An existing matrix room to act as the management room. - * @param client A client for the appservice virtual user that the new mjolnir should use. - * @returns A new managed mjolnir. - */ - public async makeInstance(localPart: string, requestingUserID: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise> { - const mxid = await client.getUserId() as StringUserID; - const managedDraupnir = await this.baseManager.makeDraupnir( - mxid, - MatrixRoomReference.fromRoomID(managementRoomID), - getProvisionedMjolnirConfig(managementRoomID) - ); - if (isError(managedDraupnir)) { - return managedDraupnir; - } - this.baseManager.startDraupnir(mxid); - incrementGaugeValue(this.instanceCountGauge, "offline", localPart); - decrementGaugeValue(this.instanceCountGauge, "disabled", localPart); - incrementGaugeValue(this.instanceCountGauge, "online", localPart); - return managedDraupnir; + /** + * Creates a new mjolnir for a user. + * @param requestingUserID The user that is requesting this mjolnir and who will own it. + * @param managementRoomId An existing matrix room to act as the management room. + * @param client A client for the appservice virtual user that the new mjolnir should use. + * @returns A new managed mjolnir. + */ + public async makeInstance( + localPart: string, + requestingUserID: StringUserID, + managementRoomID: StringRoomID, + client: MatrixClient + ): Promise> { + const mxid = (await client.getUserId()) as StringUserID; + const managedDraupnir = await this.baseManager.makeDraupnir( + mxid, + MatrixRoomReference.fromRoomID(managementRoomID), + getProvisionedMjolnirConfig(managementRoomID) + ); + if (isError(managedDraupnir)) { + return managedDraupnir; } + this.baseManager.startDraupnir(mxid); + incrementGaugeValue(this.instanceCountGauge, "offline", localPart); + decrementGaugeValue(this.instanceCountGauge, "disabled", localPart); + incrementGaugeValue(this.instanceCountGauge, "online", localPart); + return managedDraupnir; + } - /** - * Gets a draupnir for the corresponding mxid that is owned by a specific user. - * @param draupnirID The mxid of the draupnir we are trying to get. - * @param ownerID The owner of the draupnir. We ask for it explicitly to not leak access to another user's draupnir. - * @returns The matching managed draupnir instance. - */ - public async getRunningDraupnir(draupnirClientID: StringUserID, ownerID: StringUserID): Promise { - const records = await this.dataStore.lookupByOwner(ownerID); - if (records.length === 0) { - return undefined; - } - const associatedRecord = records.find(record => record.local_part === userLocalpart(draupnirClientID)); - if (associatedRecord === undefined || associatedRecord.owner !== ownerID) { - return undefined; - } - return this.baseManager.findRunningDraupnir(draupnirClientID); + /** + * Gets a draupnir for the corresponding mxid that is owned by a specific user. + * @param draupnirID The mxid of the draupnir we are trying to get. + * @param ownerID The owner of the draupnir. We ask for it explicitly to not leak access to another user's draupnir. + * @returns The matching managed draupnir instance. + */ + public async getRunningDraupnir( + draupnirClientID: StringUserID, + ownerID: StringUserID + ): Promise { + const records = await this.dataStore.lookupByOwner(ownerID); + if (records.length === 0) { + return undefined; } - - /** - * Find all of the running Draupnir that are owned by this specific user. - * @param ownerID An owner of multiple draupnir. - * @returns Any draupnir that they own. - */ - public async getOwnedDraupnir(ownerID: StringUserID): Promise { - const records = await this.dataStore.lookupByOwner(ownerID); - return records.map(record => this.draupnirMXID(record)); + const associatedRecord = records.find( + (record) => record.local_part === userLocalpart(draupnirClientID) + ); + if (associatedRecord === undefined || associatedRecord.owner !== ownerID) { + return undefined; } + return this.baseManager.findRunningDraupnir(draupnirClientID); + } - /** - * provision a new Draupnir for a matrix user. - * @param requestingUserID The mxid of the user we are creating a Draupnir for. - * @returns The matrix id of the new Draupnir and its management room. - */ - public async provisionNewDraupnir(requestingUserID: StringUserID): Promise> { - const access = this.accessControl.getUserAccess(requestingUserID); - if (access.outcome !== Access.Allowed) { - return ActionError.Result(`${requestingUserID} tried to provision a draupnir when they do not have access ${access.outcome} ${access.rule?.reason ?? 'no reason specified'}`); - } - const provisionedMjolnirs = await this.dataStore.lookupByOwner(requestingUserID); - if (provisionedMjolnirs.length === 0) { - const mjolnirLocalPart = `draupnir_${randomUUID()}`; - const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart); + /** + * Find all of the running Draupnir that are owned by this specific user. + * @param ownerID An owner of multiple draupnir. + * @returns Any draupnir that they own. + */ + public async getOwnedDraupnir( + ownerID: StringUserID + ): Promise { + const records = await this.dataStore.lookupByOwner(ownerID); + return records.map((record) => this.draupnirMXID(record)); + } - const managementRoomID = await mjIntent.matrixClient.createRoom({ - preset: 'private_chat', - invite: [requestingUserID], - name: `${requestingUserID}'s Draupnir`, - power_level_content_override: { - users: { - [requestingUserID]: 100, - // Give the mjolnir a higher PL so that can avoid issues with managing the management room. - [await mjIntent.matrixClient.getUserId()]: 101 - } - } - }); - if (!isStringRoomID(managementRoomID)) { - throw new TypeError(`${managementRoomID} malformed managmentRoomID`); - } - const draupnir = await this.makeInstance(mjolnirLocalPart, requestingUserID, managementRoomID, mjIntent.matrixClient); - if (isError(draupnir)) { - return draupnir; - } - const policyListResult = await createFirstList(draupnir.ok, requestingUserID, "list"); - if (isError(policyListResult)) { - return policyListResult; - } - const record = { - local_part: mjolnirLocalPart, - owner: requestingUserID, - management_room: managementRoomID, - } as MjolnirRecord; - await this.dataStore.store(record); - return Ok(record); - } else { - return ActionError.Result(`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.`); - } + /** + * provision a new Draupnir for a matrix user. + * @param requestingUserID The mxid of the user we are creating a Draupnir for. + * @returns The matrix id of the new Draupnir and its management room. + */ + public async provisionNewDraupnir( + requestingUserID: StringUserID + ): Promise> { + const access = this.accessControl.getUserAccess(requestingUserID); + if (access.outcome !== Access.Allowed) { + return ActionError.Result( + `${requestingUserID} tried to provision a draupnir when they do not have access ${access.outcome} ${access.rule?.reason ?? "no reason specified"}` + ); } + const provisionedMjolnirs = + await this.dataStore.lookupByOwner(requestingUserID); + if (provisionedMjolnirs.length === 0) { + const mjolnirLocalPart = `draupnir_${randomUUID()}`; + const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart); - public getUnstartedDraupnirs(): UnstartedDraupnir[] { - return this.baseManager.getUnstartedDraupnirs(); + const managementRoomID = await mjIntent.matrixClient.createRoom({ + preset: "private_chat", + invite: [requestingUserID], + name: `${requestingUserID}'s Draupnir`, + power_level_content_override: { + users: { + [requestingUserID]: 100, + // Give the mjolnir a higher PL so that can avoid issues with managing the management room. + [await mjIntent.matrixClient.getUserId()]: 101, + }, + }, + }); + if (!isStringRoomID(managementRoomID)) { + throw new TypeError(`${managementRoomID} malformed managmentRoomID`); + } + const draupnir = await this.makeInstance( + mjolnirLocalPart, + requestingUserID, + managementRoomID, + mjIntent.matrixClient + ); + if (isError(draupnir)) { + return draupnir; + } + const policyListResult = await createFirstList( + draupnir.ok, + requestingUserID, + "list" + ); + if (isError(policyListResult)) { + return policyListResult; + } + const record = { + local_part: mjolnirLocalPart, + owner: requestingUserID, + management_room: managementRoomID, + } as MjolnirRecord; + await this.dataStore.store(record); + return Ok(record); + } else { + return ActionError.Result( + `User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.` + ); } + } - public findUnstartedDraupnir(clientUserID: StringUserID): UnstartedDraupnir | undefined { - return this.baseManager.findUnstartedDraupnir(clientUserID); - } + public getUnstartedDraupnirs(): UnstartedDraupnir[] { + return this.baseManager.getUnstartedDraupnirs(); + } - /** - * Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart. - * @param localPart The localpart of the virtual user we need a client for. - * @returns A bridge intent with the complete mxid of the virtual user and a MatrixClient. - */ - private async makeMatrixIntent(localPart: string): Promise { - const mjIntent = this.bridge.getIntentFromLocalpart(localPart); - await mjIntent.ensureRegistered(); - return mjIntent; - } + public findUnstartedDraupnir( + clientUserID: StringUserID + ): UnstartedDraupnir | undefined { + return this.baseManager.findUnstartedDraupnir(clientUserID); + } - public async startDraupnirFromMXID(draupnirClientID: StringUserID): Promise> { - const records = await this.dataStore.lookupByLocalPart(userLocalpart(draupnirClientID)); - const firstRecord = records[0]; - if (firstRecord === undefined) { - return ActionError.Result(`There is no record of a draupnir with the mxid ${draupnirClientID}`); - } else { - return await this.startDraupnirFromRecord(firstRecord); - } + /** + * Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart. + * @param localPart The localpart of the virtual user we need a client for. + * @returns A bridge intent with the complete mxid of the virtual user and a MatrixClient. + */ + private async makeMatrixIntent(localPart: string): Promise { + const mjIntent = this.bridge.getIntentFromLocalpart(localPart); + await mjIntent.ensureRegistered(); + return mjIntent; + } + + public async startDraupnirFromMXID( + draupnirClientID: StringUserID + ): Promise> { + const records = await this.dataStore.lookupByLocalPart( + userLocalpart(draupnirClientID) + ); + const firstRecord = records[0]; + if (firstRecord === undefined) { + return ActionError.Result( + `There is no record of a draupnir with the mxid ${draupnirClientID}` + ); + } else { + return await this.startDraupnirFromRecord(firstRecord); } + } - /** - * Attempt to start a mjolnir, and notify its management room of any failure to start. - * Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running. - * @param mjolnirRecord The record for the mjolnir that we want to start. - */ - public async startDraupnirFromRecord(mjolnirRecord: MjolnirRecord): Promise> { - const clientUserID = this.draupnirMXID(mjolnirRecord); - if (this.baseManager.isDraupnirListening(clientUserID)) { - throw new TypeError(`${mjolnirRecord.local_part} is already running, we cannot start it.`); - } - const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); - const access = this.accessControl.getUserAccess(mjolnirRecord.owner); - if (access.outcome !== Access.Allowed) { - // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. - void Task((async () => { - await mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your draupnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); - })()); - this.baseManager.reportUnstartedDraupnir(DraupnirFailType.Unauthorized, access.outcome, clientUserID); - decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); - incrementGaugeValue(this.instanceCountGauge, "disabled", mjolnirRecord.local_part); - return ActionError.Result(`Tried to start a draupnir that has been disabled by the administrator: ${access.rule?.reason ?? 'no reason supplied'}`); - } else { - const startResult = await this.makeInstance( - mjolnirRecord.local_part, - mjolnirRecord.owner, - mjolnirRecord.management_room, - mjIntent.matrixClient, - ).catch((e: unknown) => { - log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e); - this.baseManager.reportUnstartedDraupnir(DraupnirFailType.StartError, e, clientUserID); - return ActionException.Result(`Could not start draupnir ${clientUserID} for owner ${mjolnirRecord.owner}`, { - exception: e, - exceptionKind: ActionExceptionKind.Unknown - }) - }); - if (isError(startResult)) { - // Don't await, we don't want to clobber initialization if this fails. - void Task((async () => { - await mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your draupnir could not be started. Please alert the administrator`); - })()); - decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); - incrementGaugeValue(this.instanceCountGauge, "offline", mjolnirRecord.local_part); - return startResult; - } - return Ok(undefined); - } + /** + * Attempt to start a mjolnir, and notify its management room of any failure to start. + * Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running. + * @param mjolnirRecord The record for the mjolnir that we want to start. + */ + public async startDraupnirFromRecord( + mjolnirRecord: MjolnirRecord + ): Promise> { + const clientUserID = this.draupnirMXID(mjolnirRecord); + if (this.baseManager.isDraupnirListening(clientUserID)) { + throw new TypeError( + `${mjolnirRecord.local_part} is already running, we cannot start it.` + ); } + const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part); + const access = this.accessControl.getUserAccess(mjolnirRecord.owner); + if (access.outcome !== Access.Allowed) { + // Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed. + void Task( + (async () => { + await mjIntent.matrixClient.sendNotice( + mjolnirRecord.management_room, + `Your draupnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}` + ); + })() + ); + this.baseManager.reportUnstartedDraupnir( + DraupnirFailType.Unauthorized, + access.outcome, + clientUserID + ); + decrementGaugeValue( + this.instanceCountGauge, + "online", + mjolnirRecord.local_part + ); + incrementGaugeValue( + this.instanceCountGauge, + "disabled", + mjolnirRecord.local_part + ); + return ActionError.Result( + `Tried to start a draupnir that has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}` + ); + } else { + const startResult = await this.makeInstance( + mjolnirRecord.local_part, + mjolnirRecord.owner, + mjolnirRecord.management_room, + mjIntent.matrixClient + ).catch((e: unknown) => { + log.error( + `Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, + e + ); + this.baseManager.reportUnstartedDraupnir( + DraupnirFailType.StartError, + e, + clientUserID + ); + return ActionException.Result( + `Could not start draupnir ${clientUserID} for owner ${mjolnirRecord.owner}`, + { + exception: e, + exceptionKind: ActionExceptionKind.Unknown, + } + ); + }); + if (isError(startResult)) { + // Don't await, we don't want to clobber initialization if this fails. + void Task( + (async () => { + await mjIntent.matrixClient.sendNotice( + mjolnirRecord.management_room, + `Your draupnir could not be started. Please alert the administrator` + ); + })() + ); + decrementGaugeValue( + this.instanceCountGauge, + "online", + mjolnirRecord.local_part + ); + incrementGaugeValue( + this.instanceCountGauge, + "offline", + mjolnirRecord.local_part + ); + return startResult; + } + return Ok(undefined); + } + } - // TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api. - // https://github.com/matrix-org/mjolnir/issues/410 - /** - * Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users. - */ - public async startDraupnirs(mjolnirRecords: MjolnirRecord[]): Promise { - for (const mjolnirRecord of mjolnirRecords) { - await this.startDraupnirFromRecord(mjolnirRecord); - } + // TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api. + // https://github.com/matrix-org/mjolnir/issues/410 + /** + * Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users. + */ + public async startDraupnirs(mjolnirRecords: MjolnirRecord[]): Promise { + for (const mjolnirRecord of mjolnirRecords) { + await this.startDraupnirFromRecord(mjolnirRecord); } + } } -async function createFirstList(draupnir: Draupnir, draupnirOwnerID: StringUserID, shortcode: string): Promise> { - const policyRoom = await draupnir.policyRoomManager.createPolicyRoom( - shortcode, - [draupnirOwnerID], - { name: `${draupnirOwnerID}'s policy room` } +async function createFirstList( + draupnir: Draupnir, + draupnirOwnerID: StringUserID, + shortcode: string +): Promise> { + const policyRoom = await draupnir.policyRoomManager.createPolicyRoom( + shortcode, + [draupnirOwnerID], + { name: `${draupnirOwnerID}'s policy room` } + ); + if (isError(policyRoom)) { + throw policyRoom.error; + } + const addRoomResult = + await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( + policyRoom.ok ); - if (isError(policyRoom)) { - throw policyRoom.error; - } - const addRoomResult = await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(policyRoom.ok); - if (isError(addRoomResult)) { - return addRoomResult; - } - return await draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); + if (isError(addRoomResult)) { + return addRoomResult; + } + return await draupnir.protectedRoomsSet.issuerManager.watchList( + PropagationType.Direct, + policyRoom.ok, + {} + ); } diff --git a/src/appservice/bot/AccessCommands.tsx b/src/appservice/bot/AccessCommands.tsx index ad40f597..41d28304 100644 --- a/src/appservice/bot/AccessCommands.tsx +++ b/src/appservice/bot/AccessCommands.tsx @@ -3,51 +3,68 @@ * All rights reserved. */ -import { defineInterfaceCommand, findTableCommand } from "../../commands/interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, ParsedKeywords } from "../../commands/interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "../../commands/interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, +} from "../../commands/interface-manager/ParameterParsing"; import { AppserviceContext } from "./AppserviceCommandHandler"; -import { UserID, ActionResult } from "matrix-protection-suite" +import { UserID, ActionResult } from "matrix-protection-suite"; import { defineMatrixInterfaceAdaptor } from "../../commands/interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "../../commands/interface-manager/MatrixHelpRenderer"; defineInterfaceCommand({ - designator: ["allow"], - table: "appservice bot", - parameters: parameters([ - { - name: 'user', - acceptor: findPresentationType('UserID'), - description: 'The user that should be allowed to provision a bot' - } - ]), - command: async function (this: AppserviceContext, _keywords: ParsedKeywords, user: UserID): Promise> { - return await this.appservice.accessControl.allow(user.toString()); + designator: ["allow"], + table: "appservice bot", + parameters: parameters([ + { + name: "user", + acceptor: findPresentationType("UserID"), + description: "The user that should be allowed to provision a bot", }, - summary: "Allow a user to provision themselves a draupnir using the appservice." -}) + ]), + command: async function ( + this: AppserviceContext, + _keywords: ParsedKeywords, + user: UserID + ): Promise> { + return await this.appservice.accessControl.allow(user.toString()); + }, + summary: + "Allow a user to provision themselves a draupnir using the appservice.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("appservice bot", "allow"), - renderer: tickCrossRenderer, + interfaceCommand: findTableCommand("appservice bot", "allow"), + renderer: tickCrossRenderer, }); defineInterfaceCommand({ - designator: ["remove"], - table: "appservice bot", - parameters: parameters([ - { - name: 'user', - acceptor: findPresentationType('UserID'), - description: 'The user which shall not be allowed to provision bots anymore' - } - ]), - command: async function (this: AppserviceContext, _keywords: ParsedKeywords, user: UserID): Promise> { - return await this.appservice.accessControl.remove(user.toString()); + designator: ["remove"], + table: "appservice bot", + parameters: parameters([ + { + name: "user", + acceptor: findPresentationType("UserID"), + description: + "The user which shall not be allowed to provision bots anymore", }, - summary: "Stop a user from using any provisioned draupnir in the appservice." -}) + ]), + command: async function ( + this: AppserviceContext, + _keywords: ParsedKeywords, + user: UserID + ): Promise> { + return await this.appservice.accessControl.remove(user.toString()); + }, + summary: "Stop a user from using any provisioned draupnir in the appservice.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("appservice bot", "remove"), - renderer: tickCrossRenderer, + interfaceCommand: findTableCommand("appservice bot", "remove"), + renderer: tickCrossRenderer, }); diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index ff5f1677..4e4b7493 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -3,111 +3,155 @@ * All rights reserved. */ -import { WeakEvent } from 'matrix-appservice-bridge'; -import { readCommand } from '../../commands/interface-manager/CommandReader'; -import { defineCommandTable, defineInterfaceCommand, findCommandTable, findTableCommand } from '../../commands/interface-manager/InterfaceCommand'; -import { defineMatrixInterfaceAdaptor, findMatrixInterfaceAdaptor, MatrixContext } from '../../commands/interface-manager/MatrixInterfaceAdaptor'; -import { ArgumentStream, RestDescription, findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; -import { MjolnirAppService } from '../AppService'; -import { renderHelp } from '../../commands/interface-manager/MatrixHelpRenderer'; -import { ActionResult, ClientPlatform, Ok, RoomMessage, StringRoomID, StringUserID, Task, Value, isError } from 'matrix-protection-suite'; -import { MatrixSendClient } from 'matrix-protection-suite-for-matrix-bot-sdk'; -import { MatrixReactionHandler } from '../../commands/interface-manager/MatrixReactionHandler'; -import { ARGUMENT_PROMPT_LISTENER, DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt, makeListenerForPromptDefault } from '../../commands/interface-manager/MatrixPromptForAccept'; +import { WeakEvent } from "matrix-appservice-bridge"; +import { readCommand } from "../../commands/interface-manager/CommandReader"; +import { + defineCommandTable, + defineInterfaceCommand, + findCommandTable, + findTableCommand, +} from "../../commands/interface-manager/InterfaceCommand"; +import { + defineMatrixInterfaceAdaptor, + findMatrixInterfaceAdaptor, + MatrixContext, +} from "../../commands/interface-manager/MatrixInterfaceAdaptor"; +import { + ArgumentStream, + RestDescription, + findPresentationType, + parameters, +} from "../../commands/interface-manager/ParameterParsing"; +import { MjolnirAppService } from "../AppService"; +import { renderHelp } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { + ActionResult, + ClientPlatform, + Ok, + RoomMessage, + StringRoomID, + StringUserID, + Task, + Value, + isError, +} from "matrix-protection-suite"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MatrixReactionHandler } from "../../commands/interface-manager/MatrixReactionHandler"; +import { + ARGUMENT_PROMPT_LISTENER, + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + makeListenerForArgumentPrompt, + makeListenerForPromptDefault, +} from "../../commands/interface-manager/MatrixPromptForAccept"; defineCommandTable("appservice bot"); export interface AppserviceContext extends MatrixContext { - appservice: MjolnirAppService; + appservice: MjolnirAppService; } -export type AppserviceBaseExecutor = (this: AppserviceContext, ...args: unknown[]) => Promise>; +export type AppserviceBaseExecutor = ( + this: AppserviceContext, + ...args: unknown[] +) => Promise>; -import '../../commands/interface-manager/MatrixPresentations'; -import './ListCommand'; -import './AccessCommands'; +import "../../commands/interface-manager/MatrixPresentations"; +import "./ListCommand"; +import "./AccessCommands"; defineInterfaceCommand({ - parameters: parameters([], new RestDescription('command parts', findPresentationType("any"))), - table: "appservice bot", - command: async function () { - return Ok(findCommandTable("appservice bot")) - }, - designator: ["help"], - summary: "Display this message" -}) + parameters: parameters( + [], + new RestDescription("command parts", findPresentationType("any")) + ), + table: "appservice bot", + command: async function () { + return Ok(findCommandTable("appservice bot")); + }, + designator: ["help"], + summary: "Display this message", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("appservice bot", "help"), - renderer: renderHelp -}) + interfaceCommand: findTableCommand("appservice bot", "help"), + renderer: renderHelp, +}); export class AppserviceCommandHandler { - private readonly commandTable = findCommandTable("appservice bot"); - private commandContext: Omit; - private readonly reactionHandler: MatrixReactionHandler; + private readonly commandTable = findCommandTable("appservice bot"); + private commandContext: Omit; + private readonly reactionHandler: MatrixReactionHandler; - constructor( - public readonly clientUserID: StringUserID, - private readonly client: MatrixSendClient, - private readonly adminRoomID: StringRoomID, - private readonly clientPlatform: ClientPlatform, - private readonly appservice: MjolnirAppService, - ) { - this.reactionHandler = new MatrixReactionHandler( - this.appservice.accessControlRoomID, - this.appservice.bridge.getBot().getClient(), - this.appservice.botUserID, - clientPlatform - ); - this.commandContext = { - appservice: this.appservice, - client: this.client, - clientPlatform: this.clientPlatform, - reactionHandler: this.reactionHandler, - roomID: this.appservice.accessControlRoomID - }; - this.reactionHandler.on(ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt( - this.commandContext.client, - this.clientPlatform, - this.appservice.accessControlRoomID, - this.reactionHandler, - this.commandTable, - this.commandContext - )); - this.reactionHandler.on(DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForPromptDefault( - this.commandContext.client, - this.clientPlatform, - this.appservice.accessControlRoomID, - this.reactionHandler, - this.commandTable, - this.commandContext - )); - } + constructor( + public readonly clientUserID: StringUserID, + private readonly client: MatrixSendClient, + private readonly adminRoomID: StringRoomID, + private readonly clientPlatform: ClientPlatform, + private readonly appservice: MjolnirAppService + ) { + this.reactionHandler = new MatrixReactionHandler( + this.appservice.accessControlRoomID, + this.appservice.bridge.getBot().getClient(), + this.appservice.botUserID, + clientPlatform + ); + this.commandContext = { + appservice: this.appservice, + client: this.client, + clientPlatform: this.clientPlatform, + reactionHandler: this.reactionHandler, + roomID: this.appservice.accessControlRoomID, + }; + this.reactionHandler.on( + ARGUMENT_PROMPT_LISTENER, + makeListenerForArgumentPrompt( + this.commandContext.client, + this.clientPlatform, + this.appservice.accessControlRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + ) + ); + this.reactionHandler.on( + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + makeListenerForPromptDefault( + this.commandContext.client, + this.clientPlatform, + this.appservice.accessControlRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + ) + ); + } - public handleEvent(mxEvent: WeakEvent): void { - if (mxEvent.room_id !== this.adminRoomID) { - return; - } - const parsedEventResult = Value.Decode(RoomMessage, mxEvent); - if (isError(parsedEventResult)) { - return; - } - const parsedEvent = parsedEventResult.ok; - const body = typeof mxEvent.content['body'] === 'string' ? mxEvent.content['body'] : ''; - if (body.startsWith(this.appservice.bridge.getBot().getUserId())) { - const readItems = readCommand(body).slice(1); // remove "!mjolnir" - const argumentStream = new ArgumentStream(readItems); - const command = this.commandTable.findAMatchingCommand(argumentStream); - if (command) { - const adaptor = findMatrixInterfaceAdaptor(command); - const context: AppserviceContext = { - ...this.commandContext, - event: parsedEvent, - }; - void Task(adaptor.invoke(context, context, ...argumentStream.rest())); - return; - } - } + public handleEvent(mxEvent: WeakEvent): void { + if (mxEvent.room_id !== this.adminRoomID) { + return; + } + const parsedEventResult = Value.Decode(RoomMessage, mxEvent); + if (isError(parsedEventResult)) { + return; + } + const parsedEvent = parsedEventResult.ok; + const body = + typeof mxEvent.content["body"] === "string" + ? mxEvent.content["body"] + : ""; + if (body.startsWith(this.appservice.bridge.getBot().getUserId())) { + const readItems = readCommand(body).slice(1); // remove "!mjolnir" + const argumentStream = new ArgumentStream(readItems); + const command = this.commandTable.findAMatchingCommand(argumentStream); + if (command) { + const adaptor = findMatrixInterfaceAdaptor(command); + const context: AppserviceContext = { + ...this.commandContext, + event: parsedEvent, + }; + void Task(adaptor.invoke(context, context, ...argumentStream.rest())); + return; + } } + } } diff --git a/src/appservice/bot/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index 1efa38f9..52f6eefe 100644 --- a/src/appservice/bot/ListCommand.tsx +++ b/src/appservice/bot/ListCommand.tsx @@ -3,16 +3,30 @@ * All rights reserved. */ -import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from '../../commands/interface-manager/MatrixInterfaceAdaptor'; -import { defineInterfaceCommand } from '../../commands/interface-manager/InterfaceCommand'; -import { findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; -import { AppserviceBaseExecutor } from './AppserviceCommandHandler'; -import { tickCrossRenderer } from '../../commands/interface-manager/MatrixHelpRenderer'; -import { DeadDocumentJSX } from '../../commands/interface-manager/JSXFactory'; -import { renderMatrixAndSend } from '../../commands/interface-manager/DeadDocumentMatrix'; -import { ActionError, ActionResult, isError, Ok, RoomEvent, UserID } from 'matrix-protection-suite'; -import { MatrixSendClient } from 'matrix-protection-suite-for-matrix-bot-sdk'; -import { UnstartedDraupnir } from '../../draupnirfactory/StandardDraupnirManager'; +import { + defineMatrixInterfaceAdaptor, + MatrixContext, + MatrixInterfaceAdaptor, +} from "../../commands/interface-manager/MatrixInterfaceAdaptor"; +import { defineInterfaceCommand } from "../../commands/interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, +} from "../../commands/interface-manager/ParameterParsing"; +import { AppserviceBaseExecutor } from "./AppserviceCommandHandler"; +import { tickCrossRenderer } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { DeadDocumentJSX } from "../../commands/interface-manager/JSXFactory"; +import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; +import { + ActionError, + ActionResult, + isError, + Ok, + RoomEvent, + UserID, +} from "matrix-protection-suite"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { UnstartedDraupnir } from "../../draupnirfactory/StandardDraupnirManager"; /** * There is ovbiously something we're doing very wrong here, @@ -24,72 +38,88 @@ import { UnstartedDraupnir } from '../../draupnirfactory/StandardDraupnirManager */ const listUnstarted = defineInterfaceCommand({ - designator: ["list", "unstarted"], - table: "appservice bot", - parameters: parameters([]), - command: async function () { - return Ok(this.appservice.draupnirManager.getUnstartedDraupnirs()); - }, - summary: "List any Draupnir that failed to start." + designator: ["list", "unstarted"], + table: "appservice bot", + parameters: parameters([]), + command: async function () { + return Ok(this.appservice.draupnirManager.getUnstartedDraupnirs()); + }, + summary: "List any Draupnir that failed to start.", }); // Hmm what if leter on we used OL and the numbers could be a presentation type // and be used similar to like #=1 and #1. defineMatrixInterfaceAdaptor({ - interfaceCommand: listUnstarted, - renderer: async function (this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: RoomEvent, result: ActionResult) { - tickCrossRenderer.call(this, client, commandRoomId, event, result); // don't await, it doesn't really matter. - if (isError(result)) { - return; // just let the default handler deal with it. - } - const unstarted = result.ok; - await renderMatrixAndSend( - - Unstarted Mjolnir: {unstarted.length} -
    - {unstarted.map(draupnir => { - return
  • - {draupnir.clientUserID} - {draupnir.failType}: -
    - {String(draupnir.cause)} -
  • - })} -
-
, - commandRoomId, - event, - client - ); + interfaceCommand: listUnstarted, + renderer: async function ( + this: MatrixInterfaceAdaptor, + client: MatrixSendClient, + commandRoomId: string, + event: RoomEvent, + result: ActionResult + ) { + tickCrossRenderer.call(this, client, commandRoomId, event, result); // don't await, it doesn't really matter. + if (isError(result)) { + return; // just let the default handler deal with it. } -}) + const unstarted = result.ok; + await renderMatrixAndSend( + + Unstarted Mjolnir: {unstarted.length} +
    + {unstarted.map((draupnir) => { + return ( +
  • + {draupnir.clientUserID} + {draupnir.failType}: +
    + {String(draupnir.cause)} +
  • + ); + })} +
+
, + commandRoomId, + event, + client + ); + }, +}); // We need a "default" adaptor that needs to be explicitly defined still // (since you need to know if you have not created an adaptor) // but can be composed onto the end of existing adaptors easily // e.g. read recipt and tick vs cross. const restart = defineInterfaceCommand({ - designator: ["restart"], - table: "appservice bot", - parameters: parameters([ - { - name: "draupnir", - acceptor: findPresentationType("UserID"), - description: 'The userid of the draupnir to restart' - } - ]), - command: async function (this, _keywords, draupnirUser: UserID): Promise> { - const draupnirManager = this.appservice.draupnirManager; - const draupnir = draupnirManager.findUnstartedDraupnir(draupnirUser.toString()); - if (draupnir !== undefined) { - return ActionError.Result(`We can't find the unstarted draupnir ${draupnirUser.toString()}, is it already running?`); - } - return await draupnirManager.startDraupnirFromMXID(draupnirUser.toString()); + designator: ["restart"], + table: "appservice bot", + parameters: parameters([ + { + name: "draupnir", + acceptor: findPresentationType("UserID"), + description: "The userid of the draupnir to restart", }, - summary: "Attempt to restart a Mjolnir." -}) + ]), + command: async function ( + this, + _keywords, + draupnirUser: UserID + ): Promise> { + const draupnirManager = this.appservice.draupnirManager; + const draupnir = draupnirManager.findUnstartedDraupnir( + draupnirUser.toString() + ); + if (draupnir !== undefined) { + return ActionError.Result( + `We can't find the unstarted draupnir ${draupnirUser.toString()}, is it already running?` + ); + } + return await draupnirManager.startDraupnirFromMXID(draupnirUser.toString()); + }, + summary: "Attempt to restart a Mjolnir.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: restart, - renderer: tickCrossRenderer -}) + interfaceCommand: restart, + renderer: tickCrossRenderer, +}); diff --git a/src/appservice/cli.ts b/src/appservice/cli.ts index 94d3d4ac..860f84e8 100644 --- a/src/appservice/cli.ts +++ b/src/appservice/cli.ts @@ -9,22 +9,28 @@ import { Task } from "matrix-protection-suite"; * and the appservice can be started with `ts-node src/appservice/cli -p 9000 -c your-confg.yaml`. */ const cli = new Cli({ - registrationPath: "mjolnir-registration.yaml", - bridgeConfig: { - schema: {}, - affectsRegistration: false, - defaults: {} - }, - generateRegistration: MjolnirAppService.generateRegistration, - run: function(port: number) { - const config: IConfig | null = cli.getConfig() as unknown as IConfig | null; - if (config === null) { - throw new Error("Couldn't load config"); - } - void Task((async () => { - await MjolnirAppService.run(port, config, cli.getRegistrationFilePath()) - })()); + registrationPath: "mjolnir-registration.yaml", + bridgeConfig: { + schema: {}, + affectsRegistration: false, + defaults: {}, + }, + generateRegistration: MjolnirAppService.generateRegistration, + run: function (port: number) { + const config: IConfig | null = cli.getConfig() as unknown as IConfig | null; + if (config === null) { + throw new Error("Couldn't load config"); } + void Task( + (async () => { + await MjolnirAppService.run( + port, + config, + cli.getRegistrationFilePath() + ); + })() + ); + }, }); cli.run(); diff --git a/src/appservice/config/config.example.yaml b/src/appservice/config/config.example.yaml index f44bbe44..4cd3c1c8 100644 --- a/src/appservice/config/config.example.yaml +++ b/src/appservice/config/config.example.yaml @@ -1,4 +1,3 @@ - homeserver: # The Matrix server name, this will be the name of the server in your matrix id. domain: "localhost:9999" diff --git a/src/appservice/config/config.harness.yaml b/src/appservice/config/config.harness.yaml index e2a2679e..fabfab88 100644 --- a/src/appservice/config/config.harness.yaml +++ b/src/appservice/config/config.harness.yaml @@ -1,4 +1,3 @@ - homeserver: domain: "localhost:9999" url: http://localhost:8081 diff --git a/src/appservice/config/config.ts b/src/appservice/config/config.ts index 4d68c103..10a87dbb 100644 --- a/src/appservice/config/config.ts +++ b/src/appservice/config/config.ts @@ -30,31 +30,31 @@ import { load } from "js-yaml"; import { LoggingOpts } from "matrix-appservice-bridge"; export interface IConfig { - /** Details for the homeserver the appservice will be serving */ - homeserver: { - /** The domain of the homeserver that is found at the end of mxids */ - domain: string, - /** The url to use to acccess the client server api e.g. "https://matrix-client.matrix.org" */ - url: string - }, - /** Details for the database backend */ - db: { - /** Postgres connection string */ - connectionString: string - }, - /** Config for the web api used to access the appservice via the widget */ - webAPI: { - port: number - }, - /** The admin room for the appservice bot. Not called managementRoom like mjolnir on purpose, so they're not mixed in code somehow. */ - adminRoom: string, - /** configuration for matrix-appservice-bridge's Logger */ - logging?: LoggingOpts, + /** Details for the homeserver the appservice will be serving */ + homeserver: { + /** The domain of the homeserver that is found at the end of mxids */ + domain: string; + /** The url to use to acccess the client server api e.g. "https://matrix-client.matrix.org" */ + url: string; + }; + /** Details for the database backend */ + db: { + /** Postgres connection string */ + connectionString: string; + }; + /** Config for the web api used to access the appservice via the widget */ + webAPI: { + port: number; + }; + /** The admin room for the appservice bot. Not called managementRoom like mjolnir on purpose, so they're not mixed in code somehow. */ + adminRoom: string; + /** configuration for matrix-appservice-bridge's Logger */ + logging?: LoggingOpts; } export function read(configPath: string): IConfig { - const content = fs.readFileSync(configPath, "utf8"); - const parsed = load(content); - const config = (parsed as object) as IConfig; - return config; + const content = fs.readFileSync(configPath, "utf8"); + const parsed = load(content); + const config = parsed as object as IConfig; + return config; } diff --git a/src/appservice/datastore.ts b/src/appservice/datastore.ts index 64f4c86d..450b2bb0 100644 --- a/src/appservice/datastore.ts +++ b/src/appservice/datastore.ts @@ -28,42 +28,42 @@ limitations under the License. import { StringRoomID, StringUserID } from "matrix-protection-suite"; export interface MjolnirRecord { - local_part: string, - owner: StringUserID, - management_room: StringRoomID, + local_part: string; + owner: StringUserID; + management_room: StringRoomID; } /** * Used to persist mjolnirs that have been provisioned by the mjolnir manager. */ export interface DataStore { - /** - * Initialize any resources that the datastore needs to function. - */ - init(): Promise; + /** + * Initialize any resources that the datastore needs to function. + */ + init(): Promise; - /** - * Close any resources that the datastore is using. - */ - close(): Promise; + /** + * Close any resources that the datastore is using. + */ + close(): Promise; - /** - * List all of the mjolnirs we have provisioned. - */ - list(): Promise; + /** + * List all of the mjolnirs we have provisioned. + */ + list(): Promise; - /** - * Persist a new `MjolnirRecord`. - */ - store(mjolnirRecord: MjolnirRecord): Promise; + /** + * Persist a new `MjolnirRecord`. + */ + store(mjolnirRecord: MjolnirRecord): Promise; - /** - * @param owner The mxid of the user who provisioned this mjolnir. - */ - lookupByOwner(owner: string): Promise; + /** + * @param owner The mxid of the user who provisioned this mjolnir. + */ + lookupByOwner(owner: string): Promise; - /** - * @param localPart the mxid of the provisioned mjolnir. - */ - lookupByLocalPart(localPart: string): Promise; + /** + * @param localPart the mxid of the provisioned mjolnir. + */ + lookupByLocalPart(localPart: string): Promise; } diff --git a/src/appservice/postgres/PgDataStore.ts b/src/appservice/postgres/PgDataStore.ts index 2b33c797..e1b043f6 100644 --- a/src/appservice/postgres/PgDataStore.ts +++ b/src/appservice/postgres/PgDataStore.ts @@ -18,52 +18,54 @@ import { PostgresStore, SchemaUpdateFunction } from "matrix-appservice-bridge"; import { DataStore, MjolnirRecord } from "../datastore"; function getSchema(): SchemaUpdateFunction[] { - const nSchema = 2; - const schema = []; - for (let schemaID = 1; schemaID < nSchema + 1; schemaID++) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - schema.push(require(`./schema/v${schemaID}`).runSchema); - } - return schema; + const nSchema = 2; + const schema = []; + for (let schemaID = 1; schemaID < nSchema + 1; schemaID++) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + schema.push(require(`./schema/v${schemaID}`).runSchema); + } + return schema; } export class PgDataStore extends PostgresStore implements DataStore { + constructor(connectionString: string) { + super(getSchema(), { url: connectionString }); + } - constructor(connectionString: string) { - super(getSchema(), { url: connectionString }) - } + public async init(): Promise { + await this.ensureSchema(); + } - public async init(): Promise { - await this.ensureSchema(); - } + public async close(): Promise { + await this.destroy(); + } - public async close(): Promise { - await this.destroy(); + public async list(): Promise { + const result = await this + .sql`SELECT local_part, owner, management_room FROM draupnir`; + if (!result.count) { + return []; } - public async list(): Promise { - const result = await this.sql`SELECT local_part, owner, management_room FROM draupnir`; - if (!result.count) { - return []; - } - - return result.flat() as MjolnirRecord[]; - } + return result.flat() as MjolnirRecord[]; + } - public async store(mjolnirRecord: MjolnirRecord): Promise { - await this.sql`INSERT INTO draupnir (local_part, owner, management_room) + public async store(mjolnirRecord: MjolnirRecord): Promise { + await this.sql`INSERT INTO draupnir (local_part, owner, management_room) VALUES (${mjolnirRecord.local_part}, ${mjolnirRecord.owner}, ${mjolnirRecord.management_room})`; - } + } - public async lookupByOwner(owner: string): Promise { - const result = await this.sql`SELECT local_part, owner, management_room FROM draupnir + public async lookupByOwner(owner: string): Promise { + const result = await this + .sql`SELECT local_part, owner, management_room FROM draupnir WHERE owner = ${owner}`; - return result.flat() as MjolnirRecord[]; - } + return result.flat() as MjolnirRecord[]; + } - public async lookupByLocalPart(localPart: string): Promise { - const result = await this.sql`SELECT local_part, owner, management_room FROM draupnir + public async lookupByLocalPart(localPart: string): Promise { + const result = await this + .sql`SELECT local_part, owner, management_room FROM draupnir WHERE local_part = ${localPart}`; - return result.flat() as MjolnirRecord[]; - } + return result.flat() as MjolnirRecord[]; + } } diff --git a/src/appservice/postgres/schema/v1.ts b/src/appservice/postgres/schema/v1.ts index 4972208b..1f5409ef 100644 --- a/src/appservice/postgres/schema/v1.ts +++ b/src/appservice/postgres/schema/v1.ts @@ -1,8 +1,7 @@ - -import postgres from 'postgres'; +import postgres from "postgres"; export async function runSchema(sql: postgres.Sql) { - await sql.begin(s => [ - s`CREATE TABLE mjolnir (local_part VARCHAR(255), owner VARCHAR(255), management_room TEXT);` - ]); + await sql.begin((s) => [ + s`CREATE TABLE mjolnir (local_part VARCHAR(255), owner VARCHAR(255), management_room TEXT);`, + ]); } diff --git a/src/appservice/postgres/schema/v2.ts b/src/appservice/postgres/schema/v2.ts index 4219d194..b97ef289 100644 --- a/src/appservice/postgres/schema/v2.ts +++ b/src/appservice/postgres/schema/v2.ts @@ -1,8 +1,7 @@ - -import postgres from 'postgres'; +import postgres from "postgres"; export async function runSchema(sql: postgres.Sql) { - await sql.begin(s => [ - s`ALTER TABLE IF EXISTS mjolnir RENAME TO draupnir;` - ]); + await sql.begin((s) => [ + s`ALTER TABLE IF EXISTS mjolnir RENAME TO draupnir;`, + ]); } diff --git a/src/backingstore/better-sqlite3/BetterSqliteStore.ts b/src/backingstore/better-sqlite3/BetterSqliteStore.ts index f4f99cdb..daf6215e 100644 --- a/src/backingstore/better-sqlite3/BetterSqliteStore.ts +++ b/src/backingstore/better-sqlite3/BetterSqliteStore.ts @@ -14,25 +14,27 @@ import { Logger } from "matrix-protection-suite"; const log = new Logger("BetterSqliteStore"); export function sqliteV0Schema(db: Database) { - // we have to prepare and run them seperatley becasue prepare checks if the - // table exists. - const createTable = db.transaction(() => { - db.prepare(`CREATE TABLE schema ( + // we have to prepare and run them seperatley becasue prepare checks if the + // table exists. + const createTable = db.transaction(() => { + db.prepare( + `CREATE TABLE schema ( version INTEGER UNIQUE NOT NULL - ) STRICT;`).run(); - db.prepare('INSERT INTO schema VALUES (0);').run(); - }); - createTable(); + ) STRICT;` + ).run(); + db.prepare("INSERT INTO schema VALUES (0);").run(); + }); + createTable(); } export interface BetterSqliteOptions extends BetterSqlite3.Options { - path: string, - /** - * Should the schema table be automatically created (the v0 schema effectively). - * Defaults to `true`. - */ - autocreateSchemaTable?: boolean; -}; + path: string; + /** + * Should the schema table be automatically created (the v0 schema effectively). + * Defaults to `true`. + */ + autocreateSchemaTable?: boolean; +} export type SchemaUpdateFunction = (db: Database) => void; @@ -58,112 +60,119 @@ export type SchemaUpdateFunction = (db: Database) => void; * const data = await store.getData(); */ export abstract class BetterSqliteStore { - private hasEnded = false; - public readonly db: Database; + private hasEnded = false; + public readonly db: Database; - public get latestSchema() { - return this.schemas.length; - } + public get latestSchema() { + return this.schemas.length; + } - /** - * Construct a new store. - * @param schemas The set of schema functions to apply to a database. The ordering of this array determines the - * schema number. - * @param opts Options to supply to the BetterSqliteStore client, such as `path`. - */ - constructor(private readonly schemas: SchemaUpdateFunction[], private readonly opts: BetterSqliteOptions) { - opts.autocreateSchemaTable = opts.autocreateSchemaTable ?? true; - this.db = new BetterSqlite3(opts.path, opts); - process.once("beforeExit", () => { - // Ensure we clean up on exit - try { - this.destroy() - } catch (ex) { - log.warn('Failed to cleanly exit', ex); - } - }) - } - - /** - * Ensure the database schema is up to date. If you supplied - * `autocreateSchemaTable` to `opts` in the constructor, a fresh database - * will have a `schema` table created for it. - * - * @throws If a schema could not be applied cleanly. - */ - public ensureSchema(): void { - log.info("Starting database engine"); - let currentVersion = this.getSchemaVersion(); + /** + * Construct a new store. + * @param schemas The set of schema functions to apply to a database. The ordering of this array determines the + * schema number. + * @param opts Options to supply to the BetterSqliteStore client, such as `path`. + */ + constructor( + private readonly schemas: SchemaUpdateFunction[], + private readonly opts: BetterSqliteOptions + ) { + opts.autocreateSchemaTable = opts.autocreateSchemaTable ?? true; + this.db = new BetterSqlite3(opts.path, opts); + process.once("beforeExit", () => { + // Ensure we clean up on exit + try { + this.destroy(); + } catch (ex) { + log.warn("Failed to cleanly exit", ex); + } + }); + } - if (currentVersion === -1) { - if (this.opts.autocreateSchemaTable) { - log.info(`Applying v0 schema (schema table)`); - sqliteV0Schema(this.db); - currentVersion = 0; - } else { - // We aren't autocreating the schema table, so assume schema 0. - currentVersion = 0; - } - } + /** + * Ensure the database schema is up to date. If you supplied + * `autocreateSchemaTable` to `opts` in the constructor, a fresh database + * will have a `schema` table created for it. + * + * @throws If a schema could not be applied cleanly. + */ + public ensureSchema(): void { + log.info("Starting database engine"); + let currentVersion = this.getSchemaVersion(); - // Zero-indexed, so schema 1 would be in slot 0. - while (this.schemas[currentVersion]) { - log.info(`Updating schema to v${currentVersion + 1}`); - const runSchema = this.schemas[currentVersion]; - if (runSchema === undefined) { - throw new TypeError(`there is no schema defined for the schema version: ${currentVersion}`); - } - try { - runSchema(this.db); - currentVersion++; - this.updateSchemaVersion(currentVersion); - } catch (ex) { - log.warn(`Failed to run schema v${currentVersion + 1}:`, ex); - throw Error("Failed to update database schema"); - } - } - log.info(`Database schema is at version v${currentVersion}`); + if (currentVersion === -1) { + if (this.opts.autocreateSchemaTable) { + log.info(`Applying v0 schema (schema table)`); + sqliteV0Schema(this.db); + currentVersion = 0; + } else { + // We aren't autocreating the schema table, so assume schema 0. + currentVersion = 0; + } } - /** - * Clean away any resources used by the database. This is automatically - * called before the process exits. - */ - public destroy(): void { - log.info("Destroy called"); - if (this.hasEnded) { - // No-op if end has already been called. - return; - } - this.hasEnded = true; - this.db.close(); - log.info("connection ended"); + // Zero-indexed, so schema 1 would be in slot 0. + while (this.schemas[currentVersion]) { + log.info(`Updating schema to v${currentVersion + 1}`); + const runSchema = this.schemas[currentVersion]; + if (runSchema === undefined) { + throw new TypeError( + `there is no schema defined for the schema version: ${currentVersion}` + ); + } + try { + runSchema(this.db); + currentVersion++; + this.updateSchemaVersion(currentVersion); + } catch (ex) { + log.warn(`Failed to run schema v${currentVersion + 1}:`, ex); + throw Error("Failed to update database schema"); + } } + log.info(`Database schema is at version v${currentVersion}`); + } - /** - * Update the current schema version. - * @param version - */ - protected updateSchemaVersion(version: number): void { - log.debug(`updateSchemaVersion: ${version}`); - this.db.prepare(`UPDATE schema SET version = ?;`).run(version); + /** + * Clean away any resources used by the database. This is automatically + * called before the process exits. + */ + public destroy(): void { + log.info("Destroy called"); + if (this.hasEnded) { + // No-op if end has already been called. + return; } + this.hasEnded = true; + this.db.close(); + log.info("connection ended"); + } + + /** + * Update the current schema version. + * @param version + */ + protected updateSchemaVersion(version: number): void { + log.debug(`updateSchemaVersion: ${version}`); + this.db.prepare(`UPDATE schema SET version = ?;`).run(version); + } - /** - * Get the current schema version. - * @returns The current schema version, or `-1` if no schema table is found. - */ - protected getSchemaVersion(): number { - try { - const result = this.db.prepare(`SELECT version FROM SCHEMA;`).get() as {version: number} - return result.version; - } catch (ex) { - if (ex instanceof Error && ex.message === 'no such table: SCHEMA') { - return -1; - } else { - log.error("Failed to get schema version", ex); - } - } - throw Error("Couldn't fetch schema version"); + /** + * Get the current schema version. + * @returns The current schema version, or `-1` if no schema table is found. + */ + protected getSchemaVersion(): number { + try { + const result = this.db.prepare(`SELECT version FROM SCHEMA;`).get() as { + version: number; + }; + return result.version; + } catch (ex) { + if (ex instanceof Error && ex.message === "no such table: SCHEMA") { + return -1; + } else { + log.error("Failed to get schema version", ex); + } } + throw Error("Couldn't fetch schema version"); + } } diff --git a/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts b/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts index 75b989b4..c3ba37c5 100644 --- a/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts +++ b/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts @@ -2,143 +2,187 @@ // // SPDX-License-Identifier: AFL-3.0 -import { ActionException, ActionExceptionKind, ActionResult, EventDecoder, Logger, Ok, RoomStateBackingStore, RoomStateRevision, StateChange, StateEvent, StringRoomID, isError } from "matrix-protection-suite"; +import { + ActionException, + ActionExceptionKind, + ActionResult, + EventDecoder, + Logger, + Ok, + RoomStateBackingStore, + RoomStateRevision, + StateChange, + StateEvent, + StringRoomID, + isError, +} from "matrix-protection-suite"; import { BetterSqliteStore } from "./BetterSqliteStore"; import { jsonReviver } from "../../utils"; -const log = new Logger('SqliteRoomStateBackingStore'); +const log = new Logger("SqliteRoomStateBackingStore"); const schema = [ - `CREATE TABLE room_info ( + `CREATE TABLE room_info ( room_id TEXT PRIMARY KEY NOT NULL, last_complete_writeback INTEGER NOT NULL ) STRICT;`, - `CREATE TABLE room_state_event ( + `CREATE TABLE room_state_event ( room_id TEXT NOT NULL, event_type TEXT NOT NULL, state_key TEXT NOT NULL, event BLOB NOT NULL, PRIMARY KEY (room_id, event_type, state_key), FOREIGN KEY (room_id) REFERENCES room_info(room_id) - ) STRICT;` + ) STRICT;`, ]; type RoomStateEventReplaceValue = [StringRoomID, string, string, string]; -type RoomInfo = { last_complete_writeback: number, room_id: StringRoomID }; +type RoomInfo = { last_complete_writeback: number; room_id: StringRoomID }; -export class SqliteRoomStateBackingStore extends BetterSqliteStore implements RoomStateBackingStore { - private readonly roomInfoMap = new Map; - public readonly revisionListener = this.handleRevision.bind(this); - public constructor(path: string, private readonly eventDecoder: EventDecoder) { - super(schema.map(text => function(db) { +export class SqliteRoomStateBackingStore + extends BetterSqliteStore + implements RoomStateBackingStore +{ + private readonly roomInfoMap = new Map(); + public readonly revisionListener = this.handleRevision.bind(this); + public constructor( + path: string, + private readonly eventDecoder: EventDecoder + ) { + super( + schema.map( + (text) => + function (db) { db.prepare(text).run(); - }), { - path, - fileMustExist: false, - }) - this.db.pragma('journal_mode = WAL'); - this.db.pragma('foreign_keys = ON'); - this.ensureSchema(); - } + } + ), + { + path, + fileMustExist: false, + } + ); + this.db.pragma("journal_mode = WAL"); + this.db.pragma("foreign_keys = ON"); + this.ensureSchema(); + } - public handleRevision(revision: RoomStateRevision, changes: StateChange[]): void { - const roomMetaStatement = this.db.prepare(`REPLACE INTO room_info VALUES(?, ?)`); - const replaceStatement = this.db.prepare(`REPLACE INTO room_state_event VALUES(?, ?, ?, jsonb(?))`); - const createValue = (event: StateEvent): RoomStateEventReplaceValue => { - return [ - event.room_id, - event.type, - event.state_key, - JSON.stringify(event) - ]; - } - // i don't understand why the library makes us do this but ok. - const replace = this.db.transaction((events: StateEvent[]) => { - for (const event of events) { - replaceStatement.run(createValue(event)); - } - }); - const doCompleteWriteback = this.db.transaction(() => { - const info: RoomInfo = { room_id: revision.room.toRoomIDOrAlias(), last_complete_writeback: Date.now() }; - roomMetaStatement.run(info.room_id, info.last_complete_writeback); - replace(revision.allState); - this.roomInfoMap.set(info.room_id, info); - }) - const roomInfo = this.getRoomMeta(revision.room.toRoomIDOrAlias()); - if (roomInfo === undefined) { - doCompleteWriteback(); - } else { - replace(changes.map(change => change.state)); - } + public handleRevision( + revision: RoomStateRevision, + changes: StateChange[] + ): void { + const roomMetaStatement = this.db.prepare( + `REPLACE INTO room_info VALUES(?, ?)` + ); + const replaceStatement = this.db.prepare( + `REPLACE INTO room_state_event VALUES(?, ?, ?, jsonb(?))` + ); + const createValue = (event: StateEvent): RoomStateEventReplaceValue => { + return [ + event.room_id, + event.type, + event.state_key, + JSON.stringify(event), + ]; + }; + // i don't understand why the library makes us do this but ok. + const replace = this.db.transaction((events: StateEvent[]) => { + for (const event of events) { + replaceStatement.run(createValue(event)); + } + }); + const doCompleteWriteback = this.db.transaction(() => { + const info: RoomInfo = { + room_id: revision.room.toRoomIDOrAlias(), + last_complete_writeback: Date.now(), + }; + roomMetaStatement.run(info.room_id, info.last_complete_writeback); + replace(revision.allState); + this.roomInfoMap.set(info.room_id, info); + }); + const roomInfo = this.getRoomMeta(revision.room.toRoomIDOrAlias()); + if (roomInfo === undefined) { + doCompleteWriteback(); + } else { + replace(changes.map((change) => change.state)); } + } - private getRoomMeta(roomID: StringRoomID): RoomInfo | undefined { - const entry = this.roomInfoMap.get(roomID); - if (entry) { - return entry; - } else { - const dbEntry = this.db.prepare(`SELECT * FROM room_info WHERE room_id = ?`).get(roomID) as RoomInfo | undefined; - if (dbEntry === undefined) { - return dbEntry; - } - this.roomInfoMap.set(roomID, dbEntry); - return dbEntry; - } + private getRoomMeta(roomID: StringRoomID): RoomInfo | undefined { + const entry = this.roomInfoMap.get(roomID); + if (entry) { + return entry; + } else { + const dbEntry = this.db + .prepare(`SELECT * FROM room_info WHERE room_id = ?`) + .get(roomID) as RoomInfo | undefined; + if (dbEntry === undefined) { + return dbEntry; + } + this.roomInfoMap.set(roomID, dbEntry); + return dbEntry; } + } - public getRoomState( - roomID: StringRoomID - ): Promise> { - const roomInfo = this.getRoomMeta(roomID); - if (roomInfo === undefined) { - return Promise.resolve(Ok(undefined)); + public getRoomState( + roomID: StringRoomID + ): Promise> { + const roomInfo = this.getRoomMeta(roomID); + if (roomInfo === undefined) { + return Promise.resolve(Ok(undefined)); + } else { + const events = []; + for (const event of this.db + .prepare(`SELECT json(event) FROM room_state_event WHERE room_id = ?`) + .pluck() + .iterate(roomID) as IterableIterator) { + const rawJson = JSON.parse(event, jsonReviver); + // We can't trust what's in the store, because our event decoders might have gotten + // stricter in more recent versions. Meaning the store could have invalid events + // that we don't want to blindly intern. + const decodedEvent = this.eventDecoder.decodeStateEvent(rawJson); + if (isError(decodedEvent)) { + log.error(`Unable to decode event from store:`, decodedEvent.error); + continue; } else { - const events = []; - for (const event of this.db.prepare(`SELECT json(event) FROM room_state_event WHERE room_id = ?`).pluck().iterate(roomID) as IterableIterator) { - const rawJson = JSON.parse(event, jsonReviver); - // We can't trust what's in the store, because our event decoders might have gotten - // stricter in more recent versions. Meaning the store could have invalid events - // that we don't want to blindly intern. - const decodedEvent = this.eventDecoder.decodeStateEvent(rawJson); - if (isError(decodedEvent)) { - log.error(`Unable to decode event from store:`, decodedEvent.error); - continue; - } else { - events.push(decodedEvent.ok); - } - } - return Promise.resolve(Ok(events)); + events.push(decodedEvent.ok); } + } + return Promise.resolve(Ok(events)); } + } - public forgetRoom(roomID: StringRoomID): Promise> { - const deleteMetaStatement = this.db.prepare(`DELETE FROM room_info WHERE room_id = ?`); - const deleteStateStatement = this.db.prepare(`DELETE FROM room_state_event WHERE room_id = ?`); - const deleteRoom = this.db.transaction(() => { - deleteMetaStatement.run(roomID); - deleteStateStatement.run(roomID); + public forgetRoom(roomID: StringRoomID): Promise> { + const deleteMetaStatement = this.db.prepare( + `DELETE FROM room_info WHERE room_id = ?` + ); + const deleteStateStatement = this.db.prepare( + `DELETE FROM room_state_event WHERE room_id = ?` + ); + const deleteRoom = this.db.transaction(() => { + deleteMetaStatement.run(roomID); + deleteStateStatement.run(roomID); + }); + try { + deleteRoom(); + } catch (e) { + return Promise.resolve( + ActionException.Result(`Unable to forget the room ${roomID}`, { + exception: e, + exceptionKind: ActionExceptionKind.Unknown, }) - try { - deleteRoom(); - } catch(e) { - return Promise.resolve( - ActionException.Result(`Unable to forget the room ${roomID}`, { - exception: e, - exceptionKind: ActionExceptionKind.Unknown, - }) - ); - } - return Promise.resolve(Ok(undefined)); + ); } + return Promise.resolve(Ok(undefined)); + } - public async forgetAllRooms(): Promise> { - for (const roomID of this.roomInfoMap.keys()) { - const result = await this.forgetRoom(roomID); - if (isError(result)) { - return result; - } - } - return Ok(undefined); + public async forgetAllRooms(): Promise> { + for (const roomID of this.roomInfoMap.keys()) { + const result = await this.forgetRoom(roomID); + if (isError(result)) { + return result; + } } + return Ok(undefined); + } } diff --git a/src/capabilities/CommonRenderers.tsx b/src/capabilities/CommonRenderers.tsx index c59195bd..cab27416 100644 --- a/src/capabilities/CommonRenderers.tsx +++ b/src/capabilities/CommonRenderers.tsx @@ -8,129 +8,209 @@ // https://github.com/matrix-org/mjolnir // -import { ActionError, ActionException, ActionResult, DescriptionMeta, MatrixRoomReference, ResultForUsersInRoom, RoomSetResult, StringRoomID, StringUserID, isOk } from "matrix-protection-suite"; +import { + ActionError, + ActionException, + ActionResult, + DescriptionMeta, + MatrixRoomReference, + ResultForUsersInRoom, + RoomSetResult, + StringRoomID, + StringUserID, + isOk, +} from "matrix-protection-suite"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; -import { renderMentionPill, renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; +import { + renderMentionPill, + renderRoomPill, +} from "../commands/interface-manager/MatrixHelpRenderer"; export function renderElaborationTrail(error: ActionError): DocumentNode { - return
Elaboration trail -
    - {error.getElaborations().map((elaboration) =>
  • {elaboration}
  • )} -
+ return ( +
+ Elaboration trail +
    + {error.getElaborations().map((elaboration) => ( +
  • +
    {elaboration}
    +
  • + ))} +
+ ); } export function renderDetailsNotice(error: ActionError): DocumentNode { - if (!(error instanceof ActionException)) { - return - } - return

- Details can be found by providing the reference {error.uuid} - to an administrator. + if (!(error instanceof ActionException)) { + return ; + } + return ( +

+ Details can be found by providing the reference {error.uuid} + to an administrator.

+ ); } export function renderExceptionTrail(error: ActionError): DocumentNode { - if (!(error instanceof ActionException)) { - return - } if (!(error.exception instanceof Error)) { - return - } - return
Stack Trace for: {error.exception.name} -
{error.exception.toString()}
+ if (!(error instanceof ActionException)) { + return ; + } + if (!(error.exception instanceof Error)) { + return ; + } + return ( +
+ + Stack Trace for: {error.exception.name} + +
{error.exception.toString()}
+ ); } export function renderFailedSingularConsequence( - description: DescriptionMeta, - title: DocumentNode, - error: ActionError + description: DescriptionMeta, + title: DocumentNode, + error: ActionError ): DocumentNode { - return -
- {description.name}: {title} - {renderOutcome(false)} - {error.mostRelevantElaboration} - {renderDetailsNotice(error)} - {renderElaborationTrail(error)} - {renderExceptionTrail(error)} -
+ return ( + +
+ + {description.name}: {title} - {renderOutcome(false)} + + {error.mostRelevantElaboration} + {renderDetailsNotice(error)} + {renderElaborationTrail(error)} + {renderExceptionTrail(error)} +
+ ); } export function renderOutcome(isOutcomeOk: boolean): DocumentNode { - const colour = isOutcomeOk ? '#7cfc00' : '#E01F2B'; - return - {isOutcomeOk ? 'OK' : 'Failed'} + const colour = isOutcomeOk ? "#7cfc00" : "#E01F2B"; + return ( + + {isOutcomeOk ? "OK" : "Failed"} + ); } function renderRoomOutcomeOk(roomID: StringRoomID): DocumentNode { - return {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - {renderOutcome(true)} + return ( + + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} -{" "} + {renderOutcome(true)} + + ); } -function renderUserOutcomeOk(userID: StringUserID, _result: ActionResult): DocumentNode { - return {renderMentionPill(userID, userID)} - {renderOutcome(true)} +function renderUserOutcomeOk( + userID: StringUserID, + _result: ActionResult +): DocumentNode { + return ( + + {renderMentionPill(userID, userID)} - {renderOutcome(true)} + + ); } -function renderOutcomeError(summary: DocumentNode, error: ActionError): DocumentNode { - return -
- {summary} - {renderDetailsNotice(error)} - {renderElaborationTrail(error)} - {renderExceptionTrail(error)} -
+function renderOutcomeError( + summary: DocumentNode, + error: ActionError +): DocumentNode { + return ( + +
+ {summary} + {renderDetailsNotice(error)} + {renderElaborationTrail(error)} + {renderExceptionTrail(error)} +
+ ); } -function renderRoomOutcomeError(roomID: StringRoomID, error: ActionError): DocumentNode { - return renderOutcomeError( - {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - {renderOutcome(false)}: {error.mostRelevantElaboration}, - error - ) +function renderRoomOutcomeError( + roomID: StringRoomID, + error: ActionError +): DocumentNode { + return renderOutcomeError( + + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} -{" "} + {renderOutcome(false)}: {error.mostRelevantElaboration} + , + error + ); } -function renderUserOutcomeError(userID: StringUserID, error: ActionError): DocumentNode { - return renderOutcomeError( - {renderMentionPill(userID, userID)} - {renderOutcome(false)}, - error - ) +function renderUserOutcomeError( + userID: StringUserID, + error: ActionError +): DocumentNode { + return renderOutcomeError( + + {renderMentionPill(userID, userID)} - {renderOutcome(false)} + , + error + ); } - -export function renderRoomOutcome(roomID: StringRoomID, result: ActionResult): DocumentNode { - if (isOk(result)) { - return renderRoomOutcomeOk(roomID); - } else { - return renderRoomOutcomeError(roomID, result.error); - } +export function renderRoomOutcome( + roomID: StringRoomID, + result: ActionResult +): DocumentNode { + if (isOk(result)) { + return renderRoomOutcomeOk(roomID); + } else { + return renderRoomOutcomeError(roomID, result.error); + } } -export function renderUserOutcome(userID: StringUserID, result: ActionResult): DocumentNode { - if (isOk(result)) { - return renderUserOutcomeOk(userID, result); - } else { - return renderUserOutcomeError(userID, result.error); - } +export function renderUserOutcome( + userID: StringUserID, + result: ActionResult +): DocumentNode { + if (isOk(result)) { + return renderUserOutcomeOk(userID, result); + } else { + return renderUserOutcomeError(userID, result.error); + } } -export function renderRoomSetResult(roomResults: RoomSetResult, { summary }: { summary: DocumentNode }): DocumentNode { - return
- {summary} -
    {[...roomResults.map.entries()].map(([roomID, outcome]) => { - return
  • {renderRoomOutcome(roomID, outcome)}
  • - })}
+export function renderRoomSetResult( + roomResults: RoomSetResult, + { summary }: { summary: DocumentNode } +): DocumentNode { + return ( +
+ {summary} +
    + {[...roomResults.map.entries()].map(([roomID, outcome]) => { + return
  • {renderRoomOutcome(roomID, outcome)}
  • ; + })} +
+ ); } -export function renderResultForUsersInRoom(results: ResultForUsersInRoom, { summary }: { summary: DocumentNode}): DocumentNode { - return
- {summary} -
    - {[...results.map.entries()].map(([userID, outcome]) => -
  • {renderUserOutcome(userID, outcome)}
  • - )} -
+export function renderResultForUsersInRoom( + results: ResultForUsersInRoom, + { summary }: { summary: DocumentNode } +): DocumentNode { + return ( +
+ {summary} +
    + {[...results.map.entries()].map(([userID, outcome]) => ( +
  • {renderUserOutcome(userID, outcome)}
  • + ))} +
+ ); } diff --git a/src/capabilities/DraupnirRendererMessageCollector.tsx b/src/capabilities/DraupnirRendererMessageCollector.tsx index f648c397..e59c6d08 100644 --- a/src/capabilities/DraupnirRendererMessageCollector.tsx +++ b/src/capabilities/DraupnirRendererMessageCollector.tsx @@ -3,36 +3,47 @@ // SPDX-License-Identifier: AFL-3.0 import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { RendererMessage, RendererMessageCollector } from "./RendererMessageCollector"; +import { + RendererMessage, + RendererMessageCollector, +} from "./RendererMessageCollector"; import { DescriptionMeta, StringRoomID, Task } from "matrix-protection-suite"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; -export class DraupnirRendererMessageCollector implements RendererMessageCollector { - constructor( - private readonly client: MatrixSendClient, - private readonly managementRoomID: StringRoomID, - ) { - // nothing to do. - } - private sendMessage(document: DocumentNode): void { - void Task((async () => { - await renderMatrixAndSend( - {document}, - this.managementRoomID, - undefined, - this.client, - ) - })()); - } - addMessage(protection: DescriptionMeta, message: DocumentNode): void { - this.sendMessage(message); - } - addOneliner(protection: DescriptionMeta, message: DocumentNode): void { - this.sendMessage({protection.name}: {message}); - } - getMessages(): RendererMessage[] { - return []; - } +export class DraupnirRendererMessageCollector + implements RendererMessageCollector +{ + constructor( + private readonly client: MatrixSendClient, + private readonly managementRoomID: StringRoomID + ) { + // nothing to do. + } + private sendMessage(document: DocumentNode): void { + void Task( + (async () => { + await renderMatrixAndSend( + {document}, + this.managementRoomID, + undefined, + this.client + ); + })() + ); + } + addMessage(protection: DescriptionMeta, message: DocumentNode): void { + this.sendMessage(message); + } + addOneliner(protection: DescriptionMeta, message: DocumentNode): void { + this.sendMessage( + + {protection.name}: {message} + + ); + } + getMessages(): RendererMessage[] { + return []; + } } diff --git a/src/capabilities/RendererMessageCollector.ts b/src/capabilities/RendererMessageCollector.ts index ca23e95b..665af769 100644 --- a/src/capabilities/RendererMessageCollector.ts +++ b/src/capabilities/RendererMessageCollector.ts @@ -6,44 +6,46 @@ import { DescriptionMeta } from "matrix-protection-suite"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; export enum MessageType { - Document = 'Document', - OneLine = 'OneLine', - SingleEffectError = 'SingleEffectError', + Document = "Document", + OneLine = "OneLine", + SingleEffectError = "SingleEffectError", } export interface RendererMessageCollector { - addMessage(protection: DescriptionMeta, message: DocumentNode): void; - addOneliner(protection: DescriptionMeta, message: DocumentNode): void; - getMessages(): RendererMessage[]; + addMessage(protection: DescriptionMeta, message: DocumentNode): void; + addOneliner(protection: DescriptionMeta, message: DocumentNode): void; + getMessages(): RendererMessage[]; } export interface RendererMessage { - protection: DescriptionMeta; - message: DocumentNode; - type: MessageType; + protection: DescriptionMeta; + message: DocumentNode; + type: MessageType; } /** * Used by capabilities to send messages to the users of Draupnir. */ -export class AbstractRendererMessageCollector implements RendererMessageCollector { - private readonly messages: RendererMessage[] = []; - public getMessages(): RendererMessage[] { - return this.messages - }; - addMessage(protection: DescriptionMeta, message: DocumentNode): void { - this.messages.push({ - protection, - message, - type: MessageType.Document, - }); - } +export class AbstractRendererMessageCollector + implements RendererMessageCollector +{ + private readonly messages: RendererMessage[] = []; + public getMessages(): RendererMessage[] { + return this.messages; + } + addMessage(protection: DescriptionMeta, message: DocumentNode): void { + this.messages.push({ + protection, + message, + type: MessageType.Document, + }); + } - addOneliner(protection: DescriptionMeta, message: DocumentNode): void { - this.messages.push({ - protection, - message, - type: MessageType.OneLine, - }) - } + addOneliner(protection: DescriptionMeta, message: DocumentNode): void { + this.messages.push({ + protection, + message, + type: MessageType.OneLine, + }); + } } diff --git a/src/capabilities/ServerACLConsequencesRenderer.tsx b/src/capabilities/ServerACLConsequencesRenderer.tsx index f0965495..8597c823 100644 --- a/src/capabilities/ServerACLConsequencesRenderer.tsx +++ b/src/capabilities/ServerACLConsequencesRenderer.tsx @@ -8,85 +8,160 @@ // https://github.com/matrix-org/mjolnir // -import { ActionResult, Capability, DescriptionMeta, Ok, Permalinks, PolicyListRevision, RoomSetResult, ServerACLConsequencesContext, ServerConsequences, StringRoomID, describeCapabilityContextGlue, describeCapabilityRenderer, isError } from "matrix-protection-suite"; +import { + ActionResult, + Capability, + DescriptionMeta, + Ok, + Permalinks, + PolicyListRevision, + RoomSetResult, + ServerACLConsequencesContext, + ServerConsequences, + StringRoomID, + describeCapabilityContextGlue, + describeCapabilityRenderer, + isError, +} from "matrix-protection-suite"; import { RendererMessageCollector } from "./RendererMessageCollector"; -import { renderFailedSingularConsequence, renderRoomSetResult } from "./CommonRenderers"; +import { + renderFailedSingularConsequence, + renderRoomSetResult, +} from "./CommonRenderers"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; import { Draupnir } from "../Draupnir"; class StandardServerConsequencesRenderer implements ServerConsequences { - constructor( - private readonly description: DescriptionMeta, - private readonly messageCollector: RendererMessageCollector, - private readonly capability: ServerConsequences - ) { - // nothing to do. - } - public readonly requiredEventPermissions = this.capability.requiredEventPermissions; - public readonly requiredPermissions = this.capability.requiredPermissions; - public readonly requiredStatePermissions = this.capability.requiredStatePermissions; - public async consequenceForServersInRoom(roomID: StringRoomID, revision: PolicyListRevision): Promise> { - const capabilityResult = await this.capability.consequenceForServersInRoom(roomID, revision); - const title = - Setting server ACL in {Permalinks.forRoom(roomID)} as it is out of sync with watched policies. - ; - if (isError(capabilityResult)) { - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)) - return capabilityResult; - } - this.messageCollector.addOneliner(this.description, title); - return Ok(undefined); + constructor( + private readonly description: DescriptionMeta, + private readonly messageCollector: RendererMessageCollector, + private readonly capability: ServerConsequences + ) { + // nothing to do. + } + public readonly requiredEventPermissions = + this.capability.requiredEventPermissions; + public readonly requiredPermissions = this.capability.requiredPermissions; + public readonly requiredStatePermissions = + this.capability.requiredStatePermissions; + public async consequenceForServersInRoom( + roomID: StringRoomID, + revision: PolicyListRevision + ): Promise> { + const capabilityResult = await this.capability.consequenceForServersInRoom( + roomID, + revision + ); + const title = ( + + Setting server ACL in {Permalinks.forRoom(roomID)} as it is out of sync + with watched policies. + + ); + if (isError(capabilityResult)) { + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; } - public async consequenceForServersInRoomSet(revision: PolicyListRevision): Promise> { - const capabilityResult = await this.capability.consequenceForServersInRoomSet(revision); - const title = Updating server ACL in protected rooms.; - if (isError(capabilityResult)) { - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)) - return capabilityResult; - } - this.messageCollector.addMessage( - this.description, renderRoomSetResult(capabilityResult.ok, { - summary: {this.description.name}: {title} - }) - ); - return capabilityResult; + this.messageCollector.addOneliner(this.description, title); + return Ok(undefined); + } + public async consequenceForServersInRoomSet( + revision: PolicyListRevision + ): Promise> { + const capabilityResult = + await this.capability.consequenceForServersInRoomSet(revision); + const title = Updating server ACL in protected rooms.; + if (isError(capabilityResult)) { + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; } - public async unbanServerFromRoomSet(serverName: string, reason: string): Promise> { - const capabilityResult = await this.capability.unbanServerFromRoomSet(serverName, reason); - const title = Removing {serverName} from denied servers in protected rooms.; - if (isError(capabilityResult)) { - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)); - return capabilityResult; - } - this.messageCollector.addMessage( - this.description, renderRoomSetResult(capabilityResult.ok, { - summary: {this.description.name}: {title} - }) - ); - return capabilityResult; + this.messageCollector.addMessage( + this.description, + renderRoomSetResult(capabilityResult.ok, { + summary: ( + + {this.description.name}: {title} + + ), + }) + ); + return capabilityResult; + } + public async unbanServerFromRoomSet( + serverName: string, + reason: string + ): Promise> { + const capabilityResult = await this.capability.unbanServerFromRoomSet( + serverName, + reason + ); + const title = ( + + Removing {serverName} from denied servers in protected rooms. + + ); + if (isError(capabilityResult)) { + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; } - + this.messageCollector.addMessage( + this.description, + renderRoomSetResult(capabilityResult.ok, { + summary: ( + + {this.description.name}: {title} + + ), + }) + ); + return capabilityResult; + } } describeCapabilityRenderer({ - name: 'ServerACLConsequences', - description: 'Render server consequences.', - interface: 'ServerConsequences', - factory(description, draupnir, capability) { - return new StandardServerConsequencesRenderer( - description, - draupnir.capabilityMessageRenderer, - capability - ) - } -}) + name: "ServerACLConsequences", + description: "Render server consequences.", + interface: "ServerConsequences", + factory(description, draupnir, capability) { + return new StandardServerConsequencesRenderer( + description, + draupnir.capabilityMessageRenderer, + capability + ); + }, +}); describeCapabilityContextGlue({ - name: "ServerACLConsequences", - glueMethod: function (protectionDescription, draupnir, capabilityProvider): Capability { - return capabilityProvider.factory(protectionDescription, { - stateEventSender: draupnir.clientPlatform.toRoomStateEventSender(), - protectedRoomsSet: draupnir.protectedRoomsSet - }) - } -}) + name: "ServerACLConsequences", + glueMethod: function ( + protectionDescription, + draupnir, + capabilityProvider + ): Capability { + return capabilityProvider.factory(protectionDescription, { + stateEventSender: draupnir.clientPlatform.toRoomStateEventSender(), + protectedRoomsSet: draupnir.protectedRoomsSet, + }); + }, +}); diff --git a/src/capabilities/StandardEventConsequencesRenderer.tsx b/src/capabilities/StandardEventConsequencesRenderer.tsx index a9aaf10c..5b63828b 100644 --- a/src/capabilities/StandardEventConsequencesRenderer.tsx +++ b/src/capabilities/StandardEventConsequencesRenderer.tsx @@ -8,51 +8,88 @@ // https://github.com/matrix-org/mjolnir // -import { ActionResult, Capability, DescriptionMeta, EventConsequences, Permalinks, RoomEventRedacter, StringEventID, StringRoomID, describeCapabilityContextGlue, describeCapabilityRenderer, isError } from "matrix-protection-suite"; +import { + ActionResult, + Capability, + DescriptionMeta, + EventConsequences, + Permalinks, + RoomEventRedacter, + StringEventID, + StringRoomID, + describeCapabilityContextGlue, + describeCapabilityRenderer, + isError, +} from "matrix-protection-suite"; import { RendererMessageCollector } from "./RendererMessageCollector"; import { renderFailedSingularConsequence } from "./CommonRenderers"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; import { Draupnir } from "../Draupnir"; class StandardEventConsequencesRenderer implements EventConsequences { - constructor( - private readonly description: DescriptionMeta, - private readonly messageCollector: RendererMessageCollector, - private readonly capability: EventConsequences - ) { - // nothing to do. - } - public readonly requiredEventPermissions = this.capability.requiredEventPermissions; - public readonly requiredPermissions = this.capability.requiredPermissions; - public readonly requiredStatePermissions = this.capability.requiredStatePermissions; - public async consequenceForEvent(roomID: StringRoomID, eventID: StringEventID, reason: string): Promise> { - const capabilityResult = await this.capability.consequenceForEvent(roomID, eventID, reason); - const title = Redacting {Permalinks.forEvent(roomID, eventID)}.; - if (isError(capabilityResult)) { - this.messageCollector.addOneliner(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)) - return capabilityResult; - } - this.messageCollector.addOneliner(this.description, title) - return capabilityResult; + constructor( + private readonly description: DescriptionMeta, + private readonly messageCollector: RendererMessageCollector, + private readonly capability: EventConsequences + ) { + // nothing to do. + } + public readonly requiredEventPermissions = + this.capability.requiredEventPermissions; + public readonly requiredPermissions = this.capability.requiredPermissions; + public readonly requiredStatePermissions = + this.capability.requiredStatePermissions; + public async consequenceForEvent( + roomID: StringRoomID, + eventID: StringEventID, + reason: string + ): Promise> { + const capabilityResult = await this.capability.consequenceForEvent( + roomID, + eventID, + reason + ); + const title = ( + Redacting {Permalinks.forEvent(roomID, eventID)}. + ); + if (isError(capabilityResult)) { + this.messageCollector.addOneliner( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; } + this.messageCollector.addOneliner(this.description, title); + return capabilityResult; + } } describeCapabilityRenderer({ - name: 'StandardEventConsequencesRenderer', - description: 'Renders the standard event consequences capability', - interface: 'EventConsequences', - factory(description, draupnir, capability) { - return new StandardEventConsequencesRenderer( - description, - draupnir.capabilityMessageRenderer, - capability - ); - } -}) + name: "StandardEventConsequencesRenderer", + description: "Renders the standard event consequences capability", + interface: "EventConsequences", + factory(description, draupnir, capability) { + return new StandardEventConsequencesRenderer( + description, + draupnir.capabilityMessageRenderer, + capability + ); + }, +}); describeCapabilityContextGlue({ - name: "StandardEventConsequencesRenderer", - glueMethod: function (protectionDescription, draupnir, capabilityProvider): Capability { - return capabilityProvider.factory(protectionDescription, { eventRedacter: draupnir.clientPlatform.toRoomEventRedacter() }) - } -}) + name: "StandardEventConsequencesRenderer", + glueMethod: function ( + protectionDescription, + draupnir, + capabilityProvider + ): Capability { + return capabilityProvider.factory(protectionDescription, { + eventRedacter: draupnir.clientPlatform.toRoomEventRedacter(), + }); + }, +}); diff --git a/src/capabilities/StandardUserConsequencesRenderer.tsx b/src/capabilities/StandardUserConsequencesRenderer.tsx index 996b850c..224ee5ab 100644 --- a/src/capabilities/StandardUserConsequencesRenderer.tsx +++ b/src/capabilities/StandardUserConsequencesRenderer.tsx @@ -9,156 +9,272 @@ // import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; -import { ActionResult, Capability, DescriptionMeta, MatrixRoomReference, Ok, Permalinks, PolicyListRevision, ResultForUsersInRoom, ResultForUsersInSet, RoomSetResult, StandardUserConsequencesContext, StringRoomID, StringUserID, UserConsequences, describeCapabilityContextGlue, describeCapabilityRenderer, isError } from "matrix-protection-suite"; +import { + ActionResult, + Capability, + DescriptionMeta, + MatrixRoomReference, + Ok, + Permalinks, + PolicyListRevision, + ResultForUsersInRoom, + ResultForUsersInSet, + RoomSetResult, + StandardUserConsequencesContext, + StringRoomID, + StringUserID, + UserConsequences, + describeCapabilityContextGlue, + describeCapabilityRenderer, + isError, +} from "matrix-protection-suite"; import { RendererMessageCollector } from "./RendererMessageCollector"; -import { renderFailedSingularConsequence, renderOutcome, renderResultForUsersInRoom, renderRoomSetResult } from "./CommonRenderers"; +import { + renderFailedSingularConsequence, + renderOutcome, + renderResultForUsersInRoom, + renderRoomSetResult, +} from "./CommonRenderers"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { Draupnir } from "../Draupnir"; import { renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; // yeah i know this is a bit insane but whatever, it can be our secret. -function renderResultForUserInSetMap(usersInSetMap: ResultForUsersInSet, { +function renderResultForUserInSetMap( + usersInSetMap: ResultForUsersInSet, + { ingword, nnedword, - description -}: { - ingword: string, - nnedword: string, - description: DescriptionMeta, -}): DocumentNode { - return
- - {description.name}: {ingword} {usersInSetMap.map.size} - {usersInSetMap.map.size === 1 ? 'user' : 'users'} from protected rooms - - {renderOutcome(usersInSetMap.isEveryResultOk)}. - - {[...usersInSetMap.map.entries()].map(([userID, roomResults]) => - renderRoomSetResultForUser(roomResults, userID, nnedword, {}))} + description, + }: { + ingword: string; + nnedword: string; + description: DescriptionMeta; + } +): DocumentNode { + return ( +
+ + {description.name}: {ingword} {usersInSetMap.map.size}{" "} + + {usersInSetMap.map.size === 1 ? "user" : "users"} from protected rooms - + + {renderOutcome(usersInSetMap.isEveryResultOk)}. + + {[...usersInSetMap.map.entries()].map(([userID, roomResults]) => + renderRoomSetResultForUser(roomResults, userID, nnedword, {}) + )}
+ ); } function renderRoomSetResultForUser( - roomResults: RoomSetResult, - userID: StringUserID, - nnedword: string, - { description }: { description?: DescriptionMeta } + roomResults: RoomSetResult, + userID: StringUserID, + nnedword: string, + { description }: { description?: DescriptionMeta } ): DocumentNode { - return renderRoomSetResult( - roomResults, - { - summary: - {description === undefined ? '' : {description.name}:} - {userID} will be {nnedword} from {roomResults.map.size} rooms - - {renderOutcome(roomResults.isEveryResultOk)}. - - } - ) + return renderRoomSetResult(roomResults, { + summary: ( + + {description === undefined ? ( + "" + ) : ( + + {description.name}: + + )} + {userID} will be {nnedword} from {roomResults.map.size} rooms - + {renderOutcome(roomResults.isEveryResultOk)}. + + ), + }); } class StandardUserConsequencesRenderer implements UserConsequences { - constructor( - private readonly description: DescriptionMeta, - private readonly messageCollector: RendererMessageCollector, - private readonly capability: UserConsequences - ) { - // nothing to do. - } - public readonly requiredEventPermissions = this.capability.requiredEventPermissions; - public readonly requiredPermissions = this.capability.requiredPermissions; - public readonly requiredStatePermissions = this.capability.requiredStatePermissions; + constructor( + private readonly description: DescriptionMeta, + private readonly messageCollector: RendererMessageCollector, + private readonly capability: UserConsequences + ) { + // nothing to do. + } + public readonly requiredEventPermissions = + this.capability.requiredEventPermissions; + public readonly requiredPermissions = this.capability.requiredPermissions; + public readonly requiredStatePermissions = + this.capability.requiredStatePermissions; - public async consequenceForUserInRoom(roomID: StringRoomID, userID: StringUserID, reason: string): Promise> { - const capabilityResult = await this.capability.consequenceForUserInRoom(roomID, userID, reason); - const title = + public async consequenceForUserInRoom( + roomID: StringRoomID, + userID: StringUserID, + reason: string + ): Promise> { + const capabilityResult = await this.capability.consequenceForUserInRoom( + roomID, + userID, + reason + ); + const title = ( + Banning user {userID} in {Permalinks.forRoom(roomID)} for {reason}. - ; - if (isError(capabilityResult)) { - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)) - return capabilityResult; - } - this.messageCollector.addOneliner(this.description, title) - return Ok(undefined); - + + ); + if (isError(capabilityResult)) { + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; } - public async consequenceForUsersInRoomSet(revision: PolicyListRevision): Promise> { - const capabilityResult = await this.capability.consequenceForUsersInRoomSet(revision); - if (isError(capabilityResult)) { - const title = Applying policy revision to protected rooms - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)) - return capabilityResult; - } - const usersInSetMap = capabilityResult.ok; - if (usersInSetMap.map.size === 0) { - return capabilityResult; - } - this.messageCollector.addMessage(this.description, renderResultForUserInSetMap(usersInSetMap, { - ingword: 'Banning', - nnedword: 'banned', - description: this.description, - })); - return capabilityResult; - + this.messageCollector.addOneliner(this.description, title); + return Ok(undefined); + } + public async consequenceForUsersInRoomSet( + revision: PolicyListRevision + ): Promise> { + const capabilityResult = + await this.capability.consequenceForUsersInRoomSet(revision); + if (isError(capabilityResult)) { + const title = ( + Applying policy revision to protected rooms + ); + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; + } + const usersInSetMap = capabilityResult.ok; + if (usersInSetMap.map.size === 0) { + return capabilityResult; } - public async consequenceForUsersInRoom(roomID: StringRoomID, revision: PolicyListRevision): Promise> { - const capabilityResult = await this.capability.consequenceForUsersInRoom(roomID, revision); - if (isError(capabilityResult)) { - const title = Applying policy revision to {renderRoomPill(MatrixRoomReference.fromRoomID(roomID, []))} - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)); - return capabilityResult; - } - const resultMap = capabilityResult.ok; - if (resultMap.map.size === 0) { - return capabilityResult; - } - this.messageCollector.addMessage(this.description, renderResultForUsersInRoom(resultMap, + this.messageCollector.addMessage( + this.description, + renderResultForUserInSetMap(usersInSetMap, { + ingword: "Banning", + nnedword: "banned", + description: this.description, + }) + ); + return capabilityResult; + } + public async consequenceForUsersInRoom( + roomID: StringRoomID, + revision: PolicyListRevision + ): Promise> { + const capabilityResult = await this.capability.consequenceForUsersInRoom( + roomID, + revision + ); + if (isError(capabilityResult)) { + const title = ( + + Applying policy revision to{" "} + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID, []))} + + ); + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; + } + const resultMap = capabilityResult.ok; + if (resultMap.map.size === 0) { + return capabilityResult; + } + this.messageCollector.addMessage( + this.description, + renderResultForUsersInRoom(resultMap, { + summary: ( + { - summary: - {{this.description.name}:} - {resultMap.map.size} will be banned from {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - - {renderOutcome(resultMap.isEveryResultOk)}. - + + {this.description.name}: + } - )); - return capabilityResult; + {resultMap.map.size} will be banned from{" "} + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - + {renderOutcome(resultMap.isEveryResultOk)}. + + ), + }) + ); + return capabilityResult; + } + public async unbanUserFromRoomSet( + userID: StringUserID, + reason: string + ): Promise> { + const capabilityResult = await this.capability.unbanUserFromRoomSet( + userID, + reason + ); + if (isError(capabilityResult)) { + const title = ( + Unbanning {userID} from protected rooms + ); + this.messageCollector.addMessage( + this.description, + renderFailedSingularConsequence( + this.description, + title, + capabilityResult.error + ) + ); + return capabilityResult; } - public async unbanUserFromRoomSet(userID: StringUserID, reason: string): Promise> { - const capabilityResult = await this.capability.unbanUserFromRoomSet(userID, reason); - if (isError(capabilityResult)) { - const title = Unbanning {userID} from protected rooms - this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, title, capabilityResult.error)) - return capabilityResult; - } - const usersInSetMap = capabilityResult.ok; - if (usersInSetMap.map.size === 0) { - return capabilityResult; - } - this.messageCollector.addMessage(this.description, renderRoomSetResultForUser( - usersInSetMap, - userID, - 'unbanned', - { description: this.description }, - )); - return capabilityResult; + const usersInSetMap = capabilityResult.ok; + if (usersInSetMap.map.size === 0) { + return capabilityResult; } - + this.messageCollector.addMessage( + this.description, + renderRoomSetResultForUser(usersInSetMap, userID, "unbanned", { + description: this.description, + }) + ); + return capabilityResult; + } } describeCapabilityRenderer({ - name: 'StandardUserConsequences', - description: 'Renders your mum uselesss', - interface: 'UserConsequences', - factory(description, draupnir, capability) { - return new StandardUserConsequencesRenderer(description, draupnir.capabilityMessageRenderer, capability) - } -}) + name: "StandardUserConsequences", + description: "Renders your mum uselesss", + interface: "UserConsequences", + factory(description, draupnir, capability) { + return new StandardUserConsequencesRenderer( + description, + draupnir.capabilityMessageRenderer, + capability + ); + }, +}); describeCapabilityContextGlue({ - name: "StandardUserConsequences", - glueMethod: function (protectionDescription, draupnir, capabilityProvider): Capability { - return capabilityProvider.factory(protectionDescription, { - roomBanner: draupnir.clientPlatform.toRoomBanner(), - roomUnbanner: draupnir.clientPlatform.toRoomUnbanner(), - setMembership: draupnir.protectedRoomsSet.setMembership - }) - } -}) + name: "StandardUserConsequences", + glueMethod: function ( + protectionDescription, + draupnir, + capabilityProvider + ): Capability { + return capabilityProvider.factory(protectionDescription, { + roomBanner: draupnir.clientPlatform.toRoomBanner(), + roomUnbanner: draupnir.clientPlatform.toRoomUnbanner(), + setMembership: draupnir.protectedRoomsSet.setMembership, + }); + }, +}); diff --git a/src/commands/AliasCommands.ts b/src/commands/AliasCommands.ts index cb85c93e..0c50073a 100644 --- a/src/commands/AliasCommands.ts +++ b/src/commands/AliasCommands.ts @@ -25,12 +25,26 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, +} from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; -import { ActionError, ActionResult, isError, MatrixRoomAlias, MatrixRoomReference, Ok } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + isError, + MatrixRoomAlias, + MatrixRoomReference, + Ok, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; // TODO: we should probably add an --admin keyword to these commands @@ -38,98 +52,129 @@ import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot // then we'd have to call the table "alias table" or something. defineInterfaceCommand({ - table: "synapse admin", - designator: ["alias", "move"], - summary: "Move an alias from one room to another.", - parameters: parameters([ - { - name: 'alias', - acceptor: findPresentationType("MatrixRoomAlias"), - description: 'The alias that should be moved.' - }, - { - name: 'new room', - acceptor: findPresentationType("MatrixRoomReference"), - description: 'The room to move the alias to.' - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, movingAlias: MatrixRoomAlias, room: MatrixRoomReference): Promise> { - const isAdminResult = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); - if (isAdminResult === undefined || isError(isAdminResult) || !isAdminResult.ok) { - return ActionError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); - } - const newRoomID = await resolveRoomReferenceSafe(this.client, room); - if (isError(newRoomID)) { - return newRoomID; - } - await this.draupnir.client.deleteRoomAlias(movingAlias.toRoomIDOrAlias()); - await this.draupnir.client.createRoomAlias(movingAlias.toRoomIDOrAlias(), newRoomID.ok.toRoomIDOrAlias()); - return Ok(undefined); + table: "synapse admin", + designator: ["alias", "move"], + summary: "Move an alias from one room to another.", + parameters: parameters([ + { + name: "alias", + acceptor: findPresentationType("MatrixRoomAlias"), + description: "The alias that should be moved.", }, -}) + { + name: "new room", + acceptor: findPresentationType("MatrixRoomReference"), + description: "The room to move the alias to.", + }, + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + movingAlias: MatrixRoomAlias, + room: MatrixRoomReference + ): Promise> { + const isAdminResult = + await this.draupnir.synapseAdminClient?.isSynapseAdmin(); + if ( + isAdminResult === undefined || + isError(isAdminResult) || + !isAdminResult.ok + ) { + return ActionError.Result( + "I am not a Synapse administrator, or the endpoint to deactivate a user is blocked" + ); + } + const newRoomID = await resolveRoomReferenceSafe(this.client, room); + if (isError(newRoomID)) { + return newRoomID; + } + await this.draupnir.client.deleteRoomAlias(movingAlias.toRoomIDOrAlias()); + await this.draupnir.client.createRoomAlias( + movingAlias.toRoomIDOrAlias(), + newRoomID.ok.toRoomIDOrAlias() + ); + return Ok(undefined); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "alias", "move"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("synapse admin", "alias", "move"), + renderer: tickCrossRenderer, +}); defineInterfaceCommand({ - table: "synapse admin", - designator: ["alias", "add"], - summary: "Add a new alias to a room.", - parameters: parameters([ - { - name: 'alias', - acceptor: findPresentationType("MatrixRoomAlias"), - description: 'The alias that should be created.' - }, - { - name: 'target room', - acceptor: findPresentationType("MatrixRoomReference"), - description: 'The room to add the alias to.' - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, movingAlias: MatrixRoomAlias, room: MatrixRoomReference): Promise> { - const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); - if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { - return ActionError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); - } - const roomID = await resolveRoomReferenceSafe(this.draupnir.client, room); - if (isError(roomID)) { - return roomID; - } - await this.draupnir.client.createRoomAlias(movingAlias.toRoomIDOrAlias(), roomID.ok.toRoomIDOrAlias()); - return Ok(undefined); + table: "synapse admin", + designator: ["alias", "add"], + summary: "Add a new alias to a room.", + parameters: parameters([ + { + name: "alias", + acceptor: findPresentationType("MatrixRoomAlias"), + description: "The alias that should be created.", + }, + { + name: "target room", + acceptor: findPresentationType("MatrixRoomReference"), + description: "The room to add the alias to.", }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + movingAlias: MatrixRoomAlias, + room: MatrixRoomReference + ): Promise> { + const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); + if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { + return ActionError.Result( + "I am not a Synapse administrator, or the endpoint to deactivate a user is blocked" + ); + } + const roomID = await resolveRoomReferenceSafe(this.draupnir.client, room); + if (isError(roomID)) { + return roomID; + } + await this.draupnir.client.createRoomAlias( + movingAlias.toRoomIDOrAlias(), + roomID.ok.toRoomIDOrAlias() + ); + return Ok(undefined); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "alias", "add"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("synapse admin", "alias", "add"), + renderer: tickCrossRenderer, +}); defineInterfaceCommand({ - table: "synapse admin", - designator: ["alias", "remove"], - summary: "Removes an alias from a room.", - parameters: parameters([ - { - name: 'alias', - acceptor: findPresentationType("MatrixRoomAlias"), - description: 'The alias that should be deleted.' - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, alias: MatrixRoomAlias): Promise> { - const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); - if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { - return ActionError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); - } - await this.draupnir.client.deleteRoomAlias(alias.toRoomIDOrAlias()); - return Ok(undefined); + table: "synapse admin", + designator: ["alias", "remove"], + summary: "Removes an alias from a room.", + parameters: parameters([ + { + name: "alias", + acceptor: findPresentationType("MatrixRoomAlias"), + description: "The alias that should be deleted.", }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + alias: MatrixRoomAlias + ): Promise> { + const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); + if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { + return ActionError.Result( + "I am not a Synapse administrator, or the endpoint to deactivate a user is blocked" + ); + } + await this.draupnir.client.deleteRoomAlias(alias.toRoomIDOrAlias()); + return Ok(undefined); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "alias", "remove"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("synapse admin", "alias", "remove"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/Ban.tsx b/src/commands/Ban.tsx index 5dcb55ed..0d12e4df 100644 --- a/src/commands/Ban.tsx +++ b/src/commands/Ban.tsx @@ -26,106 +26,148 @@ limitations under the License. */ import { DraupnirContext } from "./CommandHandler"; -import { defineInterfaceCommand,findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, ParameterDescription, parameters, ParsedKeywords, RestDescription, union } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + ParameterDescription, + parameters, + ParsedKeywords, + RestDescription, + union, +} from "./interface-manager/ParameterParsing"; import "./interface-manager/MatrixPresentations"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { PromptOptions } from "./interface-manager/PromptForAccept"; import { Draupnir } from "../Draupnir"; -import { ActionResult, MatrixRoomReference, PolicyRoomEditor, PolicyRuleType, isError, UserID, Ok } from "matrix-protection-suite"; +import { + ActionResult, + MatrixRoomReference, + PolicyRoomEditor, + PolicyRuleType, + isError, + UserID, + Ok, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { findPolicyRoomIDFromShortcode } from "./CreateBanListCommand"; - -export async function findPolicyRoomEditorFromRoomReference(draupnir: Draupnir, policyRoomReference: MatrixRoomReference): Promise> { - const policyRoomID = await resolveRoomReferenceSafe(draupnir.client, policyRoomReference); - if (isError(policyRoomID)) { - return policyRoomID; - } - return await draupnir.policyRoomManager.getPolicyRoomEditor(policyRoomID.ok); +export async function findPolicyRoomEditorFromRoomReference( + draupnir: Draupnir, + policyRoomReference: MatrixRoomReference +): Promise> { + const policyRoomID = await resolveRoomReferenceSafe( + draupnir.client, + policyRoomReference + ); + if (isError(policyRoomID)) { + return policyRoomID; + } + return await draupnir.policyRoomManager.getPolicyRoomEditor(policyRoomID.ok); } async function ban( - this: DraupnirContext, - _keywords: ParsedKeywords, - entity: UserID|MatrixRoomReference|string, - policyRoomDesignator: MatrixRoomReference|string, - ...reasonParts: string[] + this: DraupnirContext, + _keywords: ParsedKeywords, + entity: UserID | MatrixRoomReference | string, + policyRoomDesignator: MatrixRoomReference | string, + ...reasonParts: string[] ): Promise> { - const policyRoomReference = typeof policyRoomDesignator === 'string' - ? await findPolicyRoomIDFromShortcode(this.draupnir, policyRoomDesignator) - : Ok(policyRoomDesignator); - if (isError(policyRoomReference)) { - return policyRoomReference; - } - const policyListEditorResult = await findPolicyRoomEditorFromRoomReference( - this.draupnir, - policyRoomReference.ok + const policyRoomReference = + typeof policyRoomDesignator === "string" + ? await findPolicyRoomIDFromShortcode(this.draupnir, policyRoomDesignator) + : Ok(policyRoomDesignator); + if (isError(policyRoomReference)) { + return policyRoomReference; + } + const policyListEditorResult = await findPolicyRoomEditorFromRoomReference( + this.draupnir, + policyRoomReference.ok + ); + if (isError(policyListEditorResult)) { + return policyListEditorResult; + } + const policyListEditor = policyListEditorResult.ok; + const reason = reasonParts.join(" "); + if (entity instanceof UserID) { + return await policyListEditor.banEntity( + PolicyRuleType.User, + entity.toString(), + reason ); - if (isError(policyListEditorResult)) { - return policyListEditorResult; - } - const policyListEditor = policyListEditorResult.ok; - const reason = reasonParts.join(' '); - if (entity instanceof UserID) { - return await policyListEditor.banEntity(PolicyRuleType.User, entity.toString(), reason); - } else if (typeof entity === 'string') { - return await policyListEditor.banEntity(PolicyRuleType.Server,entity, reason); - } else { - const resolvedRoomReference = await resolveRoomReferenceSafe( - this.draupnir.client, - entity - ); - if (isError(resolvedRoomReference)) { - return resolvedRoomReference; - } - return await policyListEditor.banEntity(PolicyRuleType.Server, resolvedRoomReference.ok.toRoomIDOrAlias(), reason); + } else if (typeof entity === "string") { + return await policyListEditor.banEntity( + PolicyRuleType.Server, + entity, + reason + ); + } else { + const resolvedRoomReference = await resolveRoomReferenceSafe( + this.draupnir.client, + entity + ); + if (isError(resolvedRoomReference)) { + return resolvedRoomReference; } + return await policyListEditor.banEntity( + PolicyRuleType.Server, + resolvedRoomReference.ok.toRoomIDOrAlias(), + reason + ); + } } defineInterfaceCommand({ - designator: ["ban"], - table: "draupnir", - parameters: parameters([ - { - name: "entity", - acceptor: union( - findPresentationType("UserID"), - findPresentationType("MatrixRoomReference"), - findPresentationType("string") - ) - }, - { - name: "list", - acceptor: union( - findPresentationType("MatrixRoomReference"), - findPresentationType("string") - ), - prompt: async function (this: DraupnirContext, _parameter: ParameterDescription): Promise { - return { - suggestions: this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( - this.draupnir.clientUserID, - PolicyRuleType.User - ) - }; - } + designator: ["ban"], + table: "draupnir", + parameters: parameters( + [ + { + name: "entity", + acceptor: union( + findPresentationType("UserID"), + findPresentationType("MatrixRoomReference"), + findPresentationType("string") + ), + }, + { + name: "list", + acceptor: union( + findPresentationType("MatrixRoomReference"), + findPresentationType("string") + ), + prompt: async function ( + this: DraupnirContext, + _parameter: ParameterDescription + ): Promise { + return { + suggestions: + this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( + this.draupnir.clientUserID, + PolicyRuleType.User + ), + }; }, + }, ], new RestDescription( - "reason", - findPresentationType("string"), - async function(_parameter) { - return { - suggestions: this.draupnir.config.commands.ban.defaultReasons - } - }), - ), - command: ban, - summary: "Bans an entity from the policy list." -}) + "reason", + findPresentationType("string"), + async function (_parameter) { + return { + suggestions: this.draupnir.config.commands.ban.defaultReasons, + }; + } + ) + ), + command: ban, + summary: "Bans an entity from the policy list.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "ban"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "ban"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 9c9bbb87..3f088d47 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -27,19 +27,37 @@ limitations under the License. import { LogService, RichReply } from "matrix-bot-sdk"; import { readCommand } from "./interface-manager/CommandReader"; -import { CommandTable, defineCommandTable, findCommandTable, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findMatrixInterfaceAdaptor, MatrixContext } from "./interface-manager/MatrixInterfaceAdaptor"; +import { + CommandTable, + defineCommandTable, + findCommandTable, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findMatrixInterfaceAdaptor, + MatrixContext, +} from "./interface-manager/MatrixInterfaceAdaptor"; import { ArgumentStream } from "./interface-manager/ParameterParsing"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import "./interface-manager/MatrixPresentations"; -import { ActionException, ActionExceptionKind, ActionResult, ResultError, RoomMessage, StringRoomID } from "matrix-protection-suite"; +import { + ActionException, + ActionExceptionKind, + ActionResult, + ResultError, + RoomMessage, + StringRoomID, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; export interface DraupnirContext extends MatrixContext { - draupnir: Draupnir, + draupnir: Draupnir; } -export type DraupnirBaseExecutor = (this: DraupnirContext, ...args: Args) => Promise>; +export type DraupnirBaseExecutor = ( + this: DraupnirContext, + ...args: Args +) => Promise>; // Plesae keep these in alphabetical order. defineCommandTable("synapse admin"); @@ -60,7 +78,7 @@ import "./RedactCommand"; import "./ResolveAlias"; import "./Rooms"; import "./Rules"; -import "./SetDisplayNameCommand" +import "./SetDisplayNameCommand"; import "./SetPowerLevelCommand"; import "./StatusCommand"; import "./Unban"; @@ -69,64 +87,80 @@ import "./WatchUnwatchCommand"; export const COMMAND_PREFIX = "!draupnir"; export async function handleCommand( - roomID: StringRoomID, - event: RoomMessage, - normalisedCommand: string, - draupnir: Draupnir, - commandTable: CommandTable + roomID: StringRoomID, + event: RoomMessage, + normalisedCommand: string, + draupnir: Draupnir, + commandTable: CommandTable ): Promise { + try { + const readItemsIncludingPrefix = readCommand(normalisedCommand); + const readItems = readItemsIncludingPrefix.slice(1); + const stream = new ArgumentStream(readItems); + const command = + commandTable.findAMatchingCommand(stream) ?? + findTableCommand("draupnir", "help"); + const adaptor = findMatrixInterfaceAdaptor(command); + const draupnirContext: DraupnirContext = { + ...draupnir.commandContext, + event, + }; try { - const readItemsIncludingPrefix = readCommand(normalisedCommand) - const readItems = readItemsIncludingPrefix.slice(1); - const stream = new ArgumentStream(readItems); - const command = commandTable.findAMatchingCommand(stream) - ?? findTableCommand("draupnir", "help"); - const adaptor = findMatrixInterfaceAdaptor(command); - const draupnirContext: DraupnirContext = { - ...draupnir.commandContext, - event - }; - try { - await adaptor.invoke(draupnirContext, draupnirContext, ...stream.rest()); - } catch (e) { - const commandError = new ActionException(ActionExceptionKind.Unknown, e, 'Unknown Unexpected Error'); - await tickCrossRenderer.call(draupnirContext, draupnir.client, roomID, event, ResultError(commandError)); - } + await adaptor.invoke(draupnirContext, draupnirContext, ...stream.rest()); } catch (e) { - LogService.error("CommandHandler", e); - const text = "There was an error processing your command - see console/log for details"; - const reply = RichReply.createFor(roomID, event, text, text); - reply["msgtype"] = "m.notice"; - await draupnir.client.sendMessage(roomID, reply); + const commandError = new ActionException( + ActionExceptionKind.Unknown, + e, + "Unknown Unexpected Error" + ); + await tickCrossRenderer.call( + draupnirContext, + draupnir.client, + roomID, + event, + ResultError(commandError) + ); } + } catch (e) { + LogService.error("CommandHandler", e); + const text = + "There was an error processing your command - see console/log for details"; + const reply = RichReply.createFor(roomID, event, text, text); + reply["msgtype"] = "m.notice"; + await draupnir.client.sendMessage(roomID, reply); + } } export function extractCommandFromMessageBody( - body: string, - { prefix, - localpart, - userId, - additionalPrefixes, - allowNoPrefix - }: { - prefix: string, - localpart: string, - userId: string, - additionalPrefixes: string[], - allowNoPrefix: boolean - }): string | undefined { - const plainPrefixes = [prefix, localpart, userId, ...additionalPrefixes]; - const allPossiblePrefixes = [ - ...plainPrefixes.map(p => `!${p}`), - ...plainPrefixes.map(p => `${p}:`), - ...plainPrefixes, - ...allowNoPrefix ? ['!'] : [], - ]; - const usedPrefixInMessage = allPossiblePrefixes.find(p => body.toLowerCase().startsWith(p.toLowerCase())); - if (usedPrefixInMessage === undefined) { - return; - } - // normalise the event body to make the prefix uniform (in case the bot has spaces in its display name) - const restOfBody = body.substring(usedPrefixInMessage.length); - return prefix + (restOfBody.startsWith(' ') ? restOfBody : ` ${restOfBody}`); + body: string, + { + prefix, + localpart, + userId, + additionalPrefixes, + allowNoPrefix, + }: { + prefix: string; + localpart: string; + userId: string; + additionalPrefixes: string[]; + allowNoPrefix: boolean; + } +): string | undefined { + const plainPrefixes = [prefix, localpart, userId, ...additionalPrefixes]; + const allPossiblePrefixes = [ + ...plainPrefixes.map((p) => `!${p}`), + ...plainPrefixes.map((p) => `${p}:`), + ...plainPrefixes, + ...(allowNoPrefix ? ["!"] : []), + ]; + const usedPrefixInMessage = allPossiblePrefixes.find((p) => + body.toLowerCase().startsWith(p.toLowerCase()) + ); + if (usedPrefixInMessage === undefined) { + return; + } + // normalise the event body to make the prefix uniform (in case the bot has spaces in its display name) + const restOfBody = body.substring(usedPrefixInMessage.length); + return prefix + (restOfBody.startsWith(" ") ? restOfBody : ` ${restOfBody}`); } diff --git a/src/commands/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 38823dd4..d4acd034 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -25,78 +25,118 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionResult, MatrixRoomID, Ok, PolicyRuleType, PropagationType, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + MatrixRoomID, + Ok, + PolicyRuleType, + PropagationType, + isError, +} from "matrix-protection-suite"; import { DraupnirContext } from "./CommandHandler"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { ParsedKeywords, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + ParsedKeywords, + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { listInfo } from "./StatusCommand"; import { Draupnir } from "../Draupnir"; export async function createList( - this: DraupnirContext, - _keywords: ParsedKeywords, - shortcode: string, - aliasName: string, + this: DraupnirContext, + _keywords: ParsedKeywords, + shortcode: string, + aliasName: string ): Promise> { - const newList = await this.draupnir.policyRoomManager.createPolicyRoom( - shortcode, - // avoids inviting ourself and setting 50 as our own powerlevel - [this.event.sender].filter((sender) => sender !== this.draupnir.clientUserID), - { - room_alias_name: aliasName - } - ); - if (isError(newList)) { - return newList; - } - const watchResult = await this.draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, newList.ok, {}); - if (isError(watchResult)) { - return watchResult; - } - const protectResult = await this.draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(newList.ok); - if (isError(protectResult)) { - return protectResult; + const newList = await this.draupnir.policyRoomManager.createPolicyRoom( + shortcode, + // avoids inviting ourself and setting 50 as our own powerlevel + [this.event.sender].filter( + (sender) => sender !== this.draupnir.clientUserID + ), + { + room_alias_name: aliasName, } + ); + if (isError(newList)) { return newList; + } + const watchResult = + await this.draupnir.protectedRoomsSet.issuerManager.watchList( + PropagationType.Direct, + newList.ok, + {} + ); + if (isError(watchResult)) { + return watchResult; + } + const protectResult = + await this.draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( + newList.ok + ); + if (isError(protectResult)) { + return protectResult; + } + return newList; } defineInterfaceCommand({ - designator: ["list", "create"], - table: "draupnir", - parameters: parameters([ - { - name: "shortcode", - acceptor: findPresentationType("string"), - }, - { - name: "alias name", - acceptor: findPresentationType("string"), - }, - ]), - command: createList, - summary: "Create a new Policy Room which can be used to ban users, rooms and servers from your protected rooms" -}) + designator: ["list", "create"], + table: "draupnir", + parameters: parameters([ + { + name: "shortcode", + acceptor: findPresentationType("string"), + }, + { + name: "alias name", + acceptor: findPresentationType("string"), + }, + ]), + command: createList, + summary: + "Create a new Policy Room which can be used to ban users, rooms and servers from your protected rooms", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "list", "create"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "list", "create"), + renderer: tickCrossRenderer, +}); -export async function findPolicyRoomIDFromShortcode(draupnir: Draupnir, shortcode: string): Promise> { - const info = await listInfo(draupnir); - const matchingRevisions = info.filter(list => list.revision.shortcode === shortcode); - if (matchingRevisions.length === 0 || matchingRevisions[0] === undefined) { - return ActionError.Result(`Could not find a policy room from the shortcode: ${shortcode}`); - } else if (matchingRevisions.length === 1) { - return Ok(matchingRevisions[0].revision.room); +export async function findPolicyRoomIDFromShortcode( + draupnir: Draupnir, + shortcode: string +): Promise> { + const info = await listInfo(draupnir); + const matchingRevisions = info.filter( + (list) => list.revision.shortcode === shortcode + ); + if (matchingRevisions.length === 0 || matchingRevisions[0] === undefined) { + return ActionError.Result( + `Could not find a policy room from the shortcode: ${shortcode}` + ); + } else if (matchingRevisions.length === 1) { + return Ok(matchingRevisions[0].revision.room); + } else { + const remainingRevisions = matchingRevisions.filter((revision) => + revision.revision.isAbleToEdit(draupnir.clientUserID, PolicyRuleType.User) + ); + if ( + remainingRevisions.length !== 1 || + remainingRevisions[0] === undefined + ) { + return ActionError.Result( + `The shortcode ${shortcode} is ambiguous and is currently used by ${remainingRevisions.length} lists.` + ); } else { - const remainingRevisions = matchingRevisions.filter(revision => revision.revision.isAbleToEdit(draupnir.clientUserID, PolicyRuleType.User)); - if (remainingRevisions.length !== 1 || remainingRevisions[0] === undefined) { - return ActionError.Result(`The shortcode ${shortcode} is ambiguous and is currently used by ${remainingRevisions.length} lists.`) - } else { - return Ok(remainingRevisions[0].revision.room) - } + return Ok(remainingRevisions[0].revision.room); } + } } diff --git a/src/commands/DeactivateCommand.ts b/src/commands/DeactivateCommand.ts index b1d05dfd..337fa704 100644 --- a/src/commands/DeactivateCommand.ts +++ b/src/commands/DeactivateCommand.ts @@ -25,37 +25,59 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { ActionError, ActionResult, Ok, UserID, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + Ok, + UserID, + isError, +} from "matrix-protection-suite"; defineInterfaceCommand({ - table: "synapse admin", - designator: ["deactivate"], - summary: "Deactivates the user on the homeserver, preventing use of the account.", - parameters: parameters([ - { - name: 'user', - acceptor: findPresentationType("UserID"), - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, targetUser: UserID): Promise> { - const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); - if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { - return ActionError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); - } - if (this.draupnir.synapseAdminClient === undefined) { - throw new TypeError("Shouldn't be happening at this point"); - } - await this.draupnir.synapseAdminClient.deactivateUser(targetUser.toString()); - return Ok(undefined); + table: "synapse admin", + designator: ["deactivate"], + summary: + "Deactivates the user on the homeserver, preventing use of the account.", + parameters: parameters([ + { + name: "user", + acceptor: findPresentationType("UserID"), }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + targetUser: UserID + ): Promise> { + const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); + if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { + return ActionError.Result( + "I am not a Synapse administrator, or the endpoint to deactivate a user is blocked" + ); + } + if (this.draupnir.synapseAdminClient === undefined) { + throw new TypeError("Shouldn't be happening at this point"); + } + await this.draupnir.synapseAdminClient.deactivateUser( + targetUser.toString() + ); + return Ok(undefined); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "deactivate"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("synapse admin", "deactivate"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/Help.tsx b/src/commands/Help.tsx index f266e7a6..08d5f5d5 100644 --- a/src/commands/Help.tsx +++ b/src/commands/Help.tsx @@ -26,57 +26,77 @@ limitations under the License. */ import { DocumentNode } from "./interface-manager/DeadDocument"; -import { CommandTable, defineInterfaceCommand, findCommandTable, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + CommandTable, + defineInterfaceCommand, + findCommandTable, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { renderCommandSummary } from "./interface-manager/MatrixHelpRenderer"; import { DeadDocumentJSX } from "./interface-manager/JSXFactory"; -import { findPresentationType, parameters, RestDescription } from "./interface-manager/ParameterParsing"; +import { + findPresentationType, + parameters, + RestDescription, +} from "./interface-manager/ParameterParsing"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; import { ActionResult, Ok, isError } from "matrix-protection-suite"; import { DOCUMENTATION_URL } from "../config"; function renderTableHelp(table: CommandTable): DocumentNode { - // FIXME: is it possible to force case of table names? - return - Documentation: {DOCUMENTATION_URL}
-
- {table.name.toString()} commands: - {table.getExportedCommands().map(renderCommandSummary)} - {table.getImportedTables().map(renderTableHelp)} -
+ // FIXME: is it possible to force case of table names? + return ( + + Documentation: {DOCUMENTATION_URL} +
+
+ + {table.name.toString()} commands: + + {table.getExportedCommands().map(renderCommandSummary)} + {table.getImportedTables().map(renderTableHelp)} +
+ ); } function renderMjolnirHelp(mjolnirTable: CommandTable): DocumentNode { - return - {renderTableHelp(mjolnirTable)} - + return {renderTableHelp(mjolnirTable)}; } defineInterfaceCommand({ - parameters: parameters([], new RestDescription('command parts', findPresentationType("any"))), - table: "draupnir", - command: async function() { - return Ok(findCommandTable("draupnir")) - }, - designator: ["help"], - summary: "Display this message" -}) + parameters: parameters( + [], + new RestDescription("command parts", findPresentationType("any")) + ), + table: "draupnir", + command: async function () { + return Ok(findCommandTable("draupnir")); + }, + designator: ["help"], + summary: "Display this message", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "help"), - renderer: async function(client, commandRoomId, event, result: ActionResult) { - if (isError(result)) { - throw new TypeError("This command isn't supposed to fail"); - } - await renderMatrixAndSend( - renderMjolnirHelp(result.ok), - commandRoomId, - event, - client - ); + interfaceCommand: findTableCommand("draupnir", "help"), + renderer: async function ( + client, + commandRoomId, + event, + result: ActionResult + ) { + if (isError(result)) { + throw new TypeError("This command isn't supposed to fail"); } -}) + await renderMatrixAndSend( + renderMjolnirHelp(result.ok), + commandRoomId, + event, + client + ); + }, +}); // how to catagorise commands in help? // one way is to have subcommand tables to group them by and then iterate over the subcommand tables diff --git a/src/commands/HijackRoomCommand.ts b/src/commands/HijackRoomCommand.ts index bf3d396b..040b4a09 100644 --- a/src/commands/HijackRoomCommand.ts +++ b/src/commands/HijackRoomCommand.ts @@ -25,50 +25,78 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, +} from "./interface-manager/ParameterParsing"; import { DraupnirBaseExecutor, DraupnirContext } from "./CommandHandler"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { ActionError, ActionResult, MatrixRoomReference, Ok, UserID, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + MatrixRoomReference, + Ok, + UserID, + isError, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; async function hijackRoomCommand( - this: DraupnirContext, _keywords: ParsedKeywords, room: MatrixRoomReference, user: UserID + this: DraupnirContext, + _keywords: ParsedKeywords, + room: MatrixRoomReference, + user: UserID ): Promise> { - const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); - if (!this.draupnir.config.admin?.enableMakeRoomAdminCommand || isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { - return ActionError.Result("Either the command is disabled or Mjolnir is not running as homeserver administrator.") - } - if (this.draupnir.synapseAdminClient === undefined) { - throw new TypeError('Should be impossible at this point'); - } - const resolvedRoom = await resolveRoomReferenceSafe(this.client, room); - if (isError(resolvedRoom)) { - return resolvedRoom; - } - await this.draupnir.synapseAdminClient.makeUserRoomAdmin(resolvedRoom.ok.toRoomIDOrAlias(), user.toString()); - return Ok(undefined); + const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); + if ( + !this.draupnir.config.admin?.enableMakeRoomAdminCommand || + isAdmin === undefined || + isError(isAdmin) || + !isAdmin.ok + ) { + return ActionError.Result( + "Either the command is disabled or Mjolnir is not running as homeserver administrator." + ); + } + if (this.draupnir.synapseAdminClient === undefined) { + throw new TypeError("Should be impossible at this point"); + } + const resolvedRoom = await resolveRoomReferenceSafe(this.client, room); + if (isError(resolvedRoom)) { + return resolvedRoom; + } + await this.draupnir.synapseAdminClient.makeUserRoomAdmin( + resolvedRoom.ok.toRoomIDOrAlias(), + user.toString() + ); + return Ok(undefined); } defineInterfaceCommand({ - designator: ["hijack", "room"], - table: "synapse admin", - parameters: parameters([ - { - name: "room", - acceptor: findPresentationType("MatrixRoomReference") - }, - { - name: "user", - acceptor: findPresentationType("UserID") - } - ]), - command: hijackRoomCommand, - summary: "Make the specified user the admin of a room via the synapse admin API" -}) + designator: ["hijack", "room"], + table: "synapse admin", + parameters: parameters([ + { + name: "room", + acceptor: findPresentationType("MatrixRoomReference"), + }, + { + name: "user", + acceptor: findPresentationType("UserID"), + }, + ]), + command: hijackRoomCommand, + summary: + "Make the specified user the admin of a room via the synapse admin API", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "hijack", "room"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("synapse admin", "hijack", "room"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/ImportCommand.ts b/src/commands/ImportCommand.ts index 4be938a5..2cdd839b 100644 --- a/src/commands/ImportCommand.ts +++ b/src/commands/ImportCommand.ts @@ -26,81 +26,125 @@ limitations under the License. */ import { DraupnirBaseExecutor, DraupnirContext } from "./CommandHandler"; -import { ActionResult, MatrixRoomReference, MultipleErrors, PolicyRuleType, RoomActionError, RoomUpdateError, isError } from "matrix-protection-suite"; +import { + ActionResult, + MatrixRoomReference, + MultipleErrors, + PolicyRuleType, + RoomActionError, + RoomUpdateError, + isError, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, +} from "./interface-manager/ParameterParsing"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; export async function importCommand( - this: DraupnirContext, - _keywords: ParsedKeywords, - importFromRoomReference: MatrixRoomReference, - policyRoomReference: MatrixRoomReference + this: DraupnirContext, + _keywords: ParsedKeywords, + importFromRoomReference: MatrixRoomReference, + policyRoomReference: MatrixRoomReference ): Promise> { - const importFromRoom = await resolveRoomReferenceSafe(this.client, importFromRoomReference); - if (isError(importFromRoom)) { - return importFromRoom; - } - const policyRoom = await resolveRoomReferenceSafe(this.client, policyRoomReference); - if (isError(policyRoom)) { - return policyRoom; - } - const policyRoomEditor = await this.draupnir.policyRoomManager.getPolicyRoomEditor( - policyRoom.ok - ); - if (isError(policyRoomEditor)) { - return policyRoomEditor; - } - const state = await this.client.getRoomState(importFromRoom.ok.toRoomIDOrAlias()); - const errors: RoomUpdateError[] = []; - for (const stateEvent of state) { - const content = stateEvent['content'] || {}; - if (!content || Object.keys(content).length === 0) continue; + const importFromRoom = await resolveRoomReferenceSafe( + this.client, + importFromRoomReference + ); + if (isError(importFromRoom)) { + return importFromRoom; + } + const policyRoom = await resolveRoomReferenceSafe( + this.client, + policyRoomReference + ); + if (isError(policyRoom)) { + return policyRoom; + } + const policyRoomEditor = + await this.draupnir.policyRoomManager.getPolicyRoomEditor(policyRoom.ok); + if (isError(policyRoomEditor)) { + return policyRoomEditor; + } + const state = await this.client.getRoomState( + importFromRoom.ok.toRoomIDOrAlias() + ); + const errors: RoomUpdateError[] = []; + for (const stateEvent of state) { + const content = stateEvent["content"] || {}; + if (!content || Object.keys(content).length === 0) continue; - if (stateEvent['type'] === 'm.room.member' && stateEvent['state_key'] !== '') { - // Member event - check for ban - if (content['membership'] === 'ban') { - const reason = content['reason'] || ''; - const result = await policyRoomEditor.ok.banEntity(PolicyRuleType.User, stateEvent['state_key'], reason); - if (isError(result)) { - errors.push(RoomActionError.fromActionError(policyRoom.ok, result.error)); - } - } - } else if (stateEvent['type'] === 'm.room.server_acl' && stateEvent['state_key'] === '') { - // ACL event - ban denied servers - if (!content['deny']) continue; - for (const server of content['deny']) { - const reason = ""; - const result = await policyRoomEditor.ok.banEntity(PolicyRuleType.Server, server, reason); - if (isError(result)) { - errors.push(RoomActionError.fromActionError(policyRoom.ok, result.error)); - } - } + if ( + stateEvent["type"] === "m.room.member" && + stateEvent["state_key"] !== "" + ) { + // Member event - check for ban + if (content["membership"] === "ban") { + const reason = content["reason"] || ""; + const result = await policyRoomEditor.ok.banEntity( + PolicyRuleType.User, + stateEvent["state_key"], + reason + ); + if (isError(result)) { + errors.push( + RoomActionError.fromActionError(policyRoom.ok, result.error) + ); } + } + } else if ( + stateEvent["type"] === "m.room.server_acl" && + stateEvent["state_key"] === "" + ) { + // ACL event - ban denied servers + if (!content["deny"]) continue; + for (const server of content["deny"]) { + const reason = ""; + const result = await policyRoomEditor.ok.banEntity( + PolicyRuleType.Server, + server, + reason + ); + if (isError(result)) { + errors.push( + RoomActionError.fromActionError(policyRoom.ok, result.error) + ); + } + } } - return MultipleErrors.Result(`There were multiple errors when importing bans from the room ${importFromRoomReference.toPermalink()} to ${policyRoomReference.toPermalink()}`, { errors }); + } + return MultipleErrors.Result( + `There were multiple errors when importing bans from the room ${importFromRoomReference.toPermalink()} to ${policyRoomReference.toPermalink()}`, + { errors } + ); } defineInterfaceCommand({ - designator: ["import"], - table: "draupnir", - parameters: parameters([ - { - name: "import from room", - acceptor: findPresentationType("MatrixRoomReference") - }, - { - name: "policy room", - acceptor: findPresentationType("MatrixRoomReference") - } - ]), - command: importCommand, - summary: "Import user and server bans from a Matrix room and add them to a policy room." -}) + designator: ["import"], + table: "draupnir", + parameters: parameters([ + { + name: "import from room", + acceptor: findPresentationType("MatrixRoomReference"), + }, + { + name: "policy room", + acceptor: findPresentationType("MatrixRoomReference"), + }, + ]), + command: importCommand, + summary: + "Import user and server bans from a Matrix room and add them to a policy room.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "import"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "import"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/KickCommand.tsx b/src/commands/KickCommand.tsx index f6b955f3..ac6a3126 100644 --- a/src/commands/KickCommand.tsx +++ b/src/commands/KickCommand.tsx @@ -27,127 +27,190 @@ limitations under the License. import { MatrixGlob } from "matrix-bot-sdk"; import { DraupnirContext } from "./CommandHandler"; -import { ActionError, ActionResult, MatrixRoomReference, Ok, StringRoomID, StringUserID, UserID, isError } from "matrix-protection-suite"; -import { KeywordsDescription, ParsedKeywords, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; +import { + ActionError, + ActionResult, + MatrixRoomReference, + Ok, + StringRoomID, + StringUserID, + UserID, + isError, +} from "matrix-protection-suite"; +import { + KeywordsDescription, + ParsedKeywords, + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DocumentNode } from "./interface-manager/DeadDocument"; import { DeadDocumentJSX } from "./interface-manager/JSXFactory"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; type UsersToKick = Map; -function addUserToKick(map: UsersToKick, roomID: StringRoomID, userID: StringUserID): UsersToKick { - const userEntry = map.get(userID) ?? ((entry) => (map.set(userID, entry), entry))([]); - userEntry.push(roomID); - return map; +function addUserToKick( + map: UsersToKick, + roomID: StringRoomID, + userID: StringUserID +): UsersToKick { + const userEntry = + map.get(userID) ?? ((entry) => (map.set(userID, entry), entry))([]); + userEntry.push(roomID); + return map; } function renderUsersToKick(usersToKick: UsersToKick): DocumentNode { - return -
+ return ( + +
+ + Kicking {usersToKick.size} unique users from protected rooms. + + {[...usersToKick.entries()].map(([userID, rooms]) => ( +
- Kicking {usersToKick.size} unique users from protected rooms. + Kicking {userID} from {rooms.length} rooms. - {[...usersToKick.entries()].map(([userID, rooms]) => -
- Kicking {userID} from {rooms.length} rooms. -
    - {rooms.map(room =>
  • {room}
  • )} -
-
- )} -
+
    + {rooms.map((room) => ( +
  • {room}
  • + ))} +
+
+ ))} +
+ ); } export async function kickCommand( - this: DraupnirContext, - keywords: ParsedKeywords, - user: UserID, - ...reasonParts: string[] + this: DraupnirContext, + keywords: ParsedKeywords, + user: UserID, + ...reasonParts: string[] ): Promise> { - const restrictToRoomReference = keywords.getKeyword("room", undefined); - const isDryRun = this.draupnir.config.noop || (keywords.getKeyword("dry-run", "false") === "true"); - const allowGlob = keywords.getKeyword("glob", "false"); - const isGlob = user.toString().includes('*') || user.toString().includes('?'); - if (isGlob && !allowGlob) { - return ActionError.Result("Wildcard bans require an additional argument `--glob` to confirm"); - } - const restrictToRoom = restrictToRoomReference ? await resolveRoomReferenceSafe(this.client, restrictToRoomReference) : undefined; - if (restrictToRoom !== undefined && isError(restrictToRoom)) { - return restrictToRoom; - } - const restrictToRoomRevision = restrictToRoom === undefined ? undefined : this.draupnir.protectedRoomsSet.setMembership.getRevision(restrictToRoom.ok.toRoomIDOrAlias()); - const roomsToKickWithin = restrictToRoomRevision !== undefined ? [restrictToRoomRevision] : this.draupnir.protectedRoomsSet.setMembership.allRooms; - const reason = reasonParts.join(' '); - const kickRule = new MatrixGlob(user.toString()); - const usersToKick: UsersToKick = new Map(); - for (const revision of roomsToKickWithin) { - for (const member of revision.members()) { - if (kickRule.test(member.userID)) { - addUserToKick(usersToKick, revision.room.toRoomIDOrAlias(), member.userID); - } - if (!isDryRun) { - void this.draupnir.taskQueue.push(async () => { - return this.client.kickUser(member.userID, revision.room.toRoomIDOrAlias(), reason); - }); - } - } + const restrictToRoomReference = keywords.getKeyword( + "room", + undefined + ); + const isDryRun = + this.draupnir.config.noop || + keywords.getKeyword("dry-run", "false") === "true"; + const allowGlob = keywords.getKeyword("glob", "false"); + const isGlob = user.toString().includes("*") || user.toString().includes("?"); + if (isGlob && !allowGlob) { + return ActionError.Result( + "Wildcard bans require an additional argument `--glob` to confirm" + ); + } + const restrictToRoom = restrictToRoomReference + ? await resolveRoomReferenceSafe(this.client, restrictToRoomReference) + : undefined; + if (restrictToRoom !== undefined && isError(restrictToRoom)) { + return restrictToRoom; + } + const restrictToRoomRevision = + restrictToRoom === undefined + ? undefined + : this.draupnir.protectedRoomsSet.setMembership.getRevision( + restrictToRoom.ok.toRoomIDOrAlias() + ); + const roomsToKickWithin = + restrictToRoomRevision !== undefined + ? [restrictToRoomRevision] + : this.draupnir.protectedRoomsSet.setMembership.allRooms; + const reason = reasonParts.join(" "); + const kickRule = new MatrixGlob(user.toString()); + const usersToKick: UsersToKick = new Map(); + for (const revision of roomsToKickWithin) { + for (const member of revision.members()) { + if (kickRule.test(member.userID)) { + addUserToKick( + usersToKick, + revision.room.toRoomIDOrAlias(), + member.userID + ); + } + if (!isDryRun) { + void this.draupnir.taskQueue.push(async () => { + return this.client.kickUser( + member.userID, + revision.room.toRoomIDOrAlias(), + reason + ); + }); + } } - return Ok(usersToKick); + } + return Ok(usersToKick); } - defineInterfaceCommand({ - designator: ["kick"], - table: "draupnir", - parameters: parameters([ - { - name: "user", - acceptor: findPresentationType("string") - } + designator: ["kick"], + table: "draupnir", + parameters: parameters( + [ + { + name: "user", + acceptor: findPresentationType("string"), + }, ], undefined, new KeywordsDescription({ - "dry-run": { - name: "dry-run", - isFlag: true, - acceptor: findPresentationType("boolean"), - description: 'Runs the kick command without actually removing any users.' - }, - glob: { - name: 'glob', - isFlag: true, - acceptor: findPresentationType("boolean"), - description: 'Allows globs to be used to kick several users from rooms.' - }, - room: { - name: 'room', - isFlag: false, - acceptor: findPresentationType("MatrixRoomReference"), - description: 'Allows the command to be scoped to just one protected room.' - } + "dry-run": { + name: "dry-run", + isFlag: true, + acceptor: findPresentationType("boolean"), + description: + "Runs the kick command without actually removing any users.", + }, + glob: { + name: "glob", + isFlag: true, + acceptor: findPresentationType("boolean"), + description: + "Allows globs to be used to kick several users from rooms.", + }, + room: { + name: "room", + isFlag: false, + acceptor: findPresentationType("MatrixRoomReference"), + description: + "Allows the command to be scoped to just one protected room.", + }, }) - ), - command: kickCommand, - summary: "Kicks a user or all of those matching a glob in a particular room or all protected rooms. `--glob` must be provided to use globs. Can be scoped to a specific room with `--room`. Can be dry run with `--dry-run`." -}) + ), + command: kickCommand, + summary: + "Kicks a user or all of those matching a glob in a particular room or all protected rooms. `--glob` must be provided to use globs. Can be scoped to a specific room with `--room`. Can be dry run with `--dry-run`.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "kick"), - renderer: async function (this, client, commandRoomdID, event, result: ActionResult) { - tickCrossRenderer.call(this, client, commandRoomdID, event, result); - if (isError(result)) { - return; - } - await renderMatrixAndSend( - {renderUsersToKick(result.ok)}, - commandRoomdID, - event, - client - ); + interfaceCommand: findTableCommand("draupnir", "kick"), + renderer: async function ( + this, + client, + commandRoomdID, + event, + result: ActionResult + ) { + tickCrossRenderer.call(this, client, commandRoomdID, event, result); + if (isError(result)) { + return; } -}) + await renderMatrixAndSend( + {renderUsersToKick(result.ok)}, + commandRoomdID, + event, + client + ); + }, +}); diff --git a/src/commands/PermissionCheckCommand.ts b/src/commands/PermissionCheckCommand.ts index ef6cb808..df66557f 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -26,33 +26,47 @@ limitations under the License. */ import { ActionError, ActionResult } from "matrix-protection-suite"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { ParsedKeywords, parameters } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + ParsedKeywords, + parameters, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; defineInterfaceCommand({ - designator: ["verify"], - table: "draupnir", - parameters: parameters([]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords): Promise> { - const enabledProtection = this.draupnir.protectedRoomsSet.protections.allProtections; - const eventPermissions = new Set(); - const permissions = new Set(); - for (const proteciton of enabledProtection) { - proteciton.requiredEventPermissions.forEach(permission => eventPermissions.add(permission)); - proteciton.requiredPermissions.forEach(permission => permissions.add(permission)); - } - // FIXME do we need something like setMembership but for room state? - // Not sure if it will work because sometimes you need room state of watched lists too. - // Should be considered with the appservice to effect visibility of rooms. - return ActionError.Result(`Unimplemented`); - }, - summary: "Verify the permissions that draupnir has." -}) + designator: ["verify"], + table: "draupnir", + parameters: parameters([]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords + ): Promise> { + const enabledProtection = + this.draupnir.protectedRoomsSet.protections.allProtections; + const eventPermissions = new Set(); + const permissions = new Set(); + for (const proteciton of enabledProtection) { + proteciton.requiredEventPermissions.forEach((permission) => + eventPermissions.add(permission) + ); + proteciton.requiredPermissions.forEach((permission) => + permissions.add(permission) + ); + } + // FIXME do we need something like setMembership but for room state? + // Not sure if it will work because sometimes you need room state of watched lists too. + // Should be considered with the appservice to effect visibility of rooms. + return ActionError.Result(`Unimplemented`); + }, + summary: "Verify the permissions that draupnir has.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "verify"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "verify"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/ProtectionsCommands.tsx b/src/commands/ProtectionsCommands.tsx index cb6a2c40..61caaab5 100644 --- a/src/commands/ProtectionsCommands.tsx +++ b/src/commands/ProtectionsCommands.tsx @@ -25,9 +25,31 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { KeywordsDescription, ParsedKeywords, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; -import { ActionError, ActionResult, Ok, Protection, ProtectionDescription, ProtectionSetting, ProtectionSettings, RoomEvent, StringRoomID, UnknownSettings, findProtection, getAllProtections, isError } from "matrix-protection-suite"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + KeywordsDescription, + ParsedKeywords, + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; +import { + ActionError, + ActionResult, + Ok, + Protection, + ProtectionDescription, + ProtectionSetting, + ProtectionSettings, + RoomEvent, + StringRoomID, + UnknownSettings, + findProtection, + getAllProtections, + isError, +} from "matrix-protection-suite"; import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; @@ -38,351 +60,489 @@ import { DocumentNode } from "./interface-manager/DeadDocument"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; defineInterfaceCommand({ - designator: ["protections", "enable"], - table: "draupnir", - parameters: parameters([ - { - name: 'protection name', - acceptor: findPresentationType('string'), - } + designator: ["protections", "enable"], + table: "draupnir", + parameters: parameters( + [ + { + name: "protection name", + acceptor: findPresentationType("string"), + }, ], undefined, new KeywordsDescription({ - limit: { - name: "consequence-provider", - isFlag: false, - acceptor: findPresentationType("string"), - description: 'The name of a consequence provider to use for this protection.' - }, - })), - command: async function (this: DraupnirContext, keywords: ParsedKeywords, protectionName: string): Promise> { - const protectionDescription = findProtection(protectionName); - if (protectionDescription === undefined) { - return ActionError.Result(`Couldn't find a protection named ${protectionName}`); - } - const capabilityProviderSet = await this.draupnir.protectedRoomsSet.protections.getCapabilityProviderSet(protectionDescription); - if (isError(capabilityProviderSet)) { - return capabilityProviderSet.elaborate(`Couldn't load the capability provider set for the protection ${protectionName}`); - } - return await this.draupnir.protectedRoomsSet.protections.addProtection( - protectionDescription, - capabilityProviderSet.ok, - this.draupnir.protectedRoomsSet, - this.draupnir - ) - }, - summary: "Enable a named protection." -}) + limit: { + name: "consequence-provider", + isFlag: false, + acceptor: findPresentationType("string"), + description: + "The name of a consequence provider to use for this protection.", + }, + }) + ), + command: async function ( + this: DraupnirContext, + keywords: ParsedKeywords, + protectionName: string + ): Promise> { + const protectionDescription = findProtection(protectionName); + if (protectionDescription === undefined) { + return ActionError.Result( + `Couldn't find a protection named ${protectionName}` + ); + } + const capabilityProviderSet = + await this.draupnir.protectedRoomsSet.protections.getCapabilityProviderSet( + protectionDescription + ); + if (isError(capabilityProviderSet)) { + return capabilityProviderSet.elaborate( + `Couldn't load the capability provider set for the protection ${protectionName}` + ); + } + return await this.draupnir.protectedRoomsSet.protections.addProtection( + protectionDescription, + capabilityProviderSet.ok, + this.draupnir.protectedRoomsSet, + this.draupnir + ); + }, + summary: "Enable a named protection.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "protections", "enable"), - renderer: tickCrossRenderer + interfaceCommand: findTableCommand("draupnir", "protections", "enable"), + renderer: tickCrossRenderer, }); defineInterfaceCommand({ - designator: ["protections", "disable"], - table: "draupnir", - parameters: parameters([ - { - name: 'protection name', - acceptor: findPresentationType('string') - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, protectionName: string): Promise> { - const protectionDescription = findProtection(protectionName); - if (protectionDescription === undefined) { - return ActionError.Result(`Couldn't find a protection named ${protectionName}`); - } - if (!this.draupnir.protectedRoomsSet.protections.isEnabledProtection(protectionDescription)) { - return ActionError.Result(`The protection named ${protectionDescription.name} is currently disabled`); - } - return await this.draupnir.protectedRoomsSet.protections.removeProtection( - protectionDescription - ); + designator: ["protections", "disable"], + table: "draupnir", + parameters: parameters([ + { + name: "protection name", + acceptor: findPresentationType("string"), }, - summary: "Disable a protection." -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + protectionName: string + ): Promise> { + const protectionDescription = findProtection(protectionName); + if (protectionDescription === undefined) { + return ActionError.Result( + `Couldn't find a protection named ${protectionName}` + ); + } + if ( + !this.draupnir.protectedRoomsSet.protections.isEnabledProtection( + protectionDescription + ) + ) { + return ActionError.Result( + `The protection named ${protectionDescription.name} is currently disabled` + ); + } + return await this.draupnir.protectedRoomsSet.protections.removeProtection( + protectionDescription + ); + }, + summary: "Disable a protection.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "protections", "disable"), - renderer: tickCrossRenderer + interfaceCommand: findTableCommand("draupnir", "protections", "disable"), + renderer: tickCrossRenderer, }); -const CommonProtectionSettingParameters = [{ - name: 'protection name', - acceptor: findPresentationType('string'), - description: 'The name of the protection to be modified.' -}, -{ - name: 'setting name', - acceptor: findPresentationType('string'), - description: "The name of the setting within the protection config to modify." -}]; +const CommonProtectionSettingParameters = [ + { + name: "protection name", + acceptor: findPresentationType("string"), + description: "The name of the protection to be modified.", + }, + { + name: "setting name", + acceptor: findPresentationType("string"), + description: + "The name of the setting within the protection config to modify.", + }, +]; -interface SettingChangeSummary = UnknownSettings> { - readonly oldValue: unknown, - readonly newValue: unknown, - readonly description: ProtectionSetting, +interface SettingChangeSummary< + Key extends string = string, + TSettings extends UnknownSettings = UnknownSettings, +> { + readonly oldValue: unknown; + readonly newValue: unknown; + readonly description: ProtectionSetting; } defineInterfaceCommand({ - designator: ["protections", "config", "set"], - table: "draupnir", - parameters: parameters([...CommonProtectionSettingParameters, { - name: 'new value', - acceptor: findPresentationType('any'), - description: 'The new value to give the protection setting' - }]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, protectionName: string, settingName: string, value: unknown): Promise> { - const detailsResult = await findSettingDetailsForCommand(this.draupnir, protectionName, settingName); - if (isError(detailsResult)) { - return detailsResult; - } - const details = detailsResult.ok; - const newSettings = details.protectionSettings.setValue(details.previousSettings, settingName, value); - if (isError(newSettings)) { - return newSettings; - } - return await changeSettingsForCommands( - this.draupnir, - details, - settingName, - newSettings.ok - ) + designator: ["protections", "config", "set"], + table: "draupnir", + parameters: parameters([ + ...CommonProtectionSettingParameters, + { + name: "new value", + acceptor: findPresentationType("any"), + description: "The new value to give the protection setting", }, - summary: "Set a new value for the protection setting, if the setting is a collection\ - then this will write over the entire collection." -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + protectionName: string, + settingName: string, + value: unknown + ): Promise> { + const detailsResult = await findSettingDetailsForCommand( + this.draupnir, + protectionName, + settingName + ); + if (isError(detailsResult)) { + return detailsResult; + } + const details = detailsResult.ok; + const newSettings = details.protectionSettings.setValue( + details.previousSettings, + settingName, + value + ); + if (isError(newSettings)) { + return newSettings; + } + return await changeSettingsForCommands( + this.draupnir, + details, + settingName, + newSettings.ok + ); + }, + summary: + "Set a new value for the protection setting, if the setting is a collection\ + then this will write over the entire collection.", +}); defineInterfaceCommand({ - designator: ["protections", "config", "add"], - table: "draupnir", - parameters: parameters([ - { - name: 'item', - acceptor: findPresentationType('any'), - description: "An item to add to the collection setting." - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, protectionName: string, settingName: string, value: unknown): Promise> { - const detailsResult = await findSettingDetailsForCommand(this.draupnir, protectionName, settingName); - if (isError(detailsResult)) { - return detailsResult; - } - const details = detailsResult.ok; - const settingDescription = details.settingDescription; - if (!settingDescription.isCollectionSetting()) { - return ActionError.Result( - `${protectionName}'s setting ${settingName} is not a collection protection setting, and cannot be used with the add or remove commands.` - ) - } - const newSettings = settingDescription.addItem(details.previousSettings, value); - if (isError(newSettings)) { - return newSettings; - } - return await changeSettingsForCommands( - this.draupnir, - details, - settingName, - newSettings.ok - ); + designator: ["protections", "config", "add"], + table: "draupnir", + parameters: parameters([ + { + name: "item", + acceptor: findPresentationType("any"), + description: "An item to add to the collection setting.", }, - summary: "Add an item to a collection protection setting." -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + protectionName: string, + settingName: string, + value: unknown + ): Promise> { + const detailsResult = await findSettingDetailsForCommand( + this.draupnir, + protectionName, + settingName + ); + if (isError(detailsResult)) { + return detailsResult; + } + const details = detailsResult.ok; + const settingDescription = details.settingDescription; + if (!settingDescription.isCollectionSetting()) { + return ActionError.Result( + `${protectionName}'s setting ${settingName} is not a collection protection setting, and cannot be used with the add or remove commands.` + ); + } + const newSettings = settingDescription.addItem( + details.previousSettings, + value + ); + if (isError(newSettings)) { + return newSettings; + } + return await changeSettingsForCommands( + this.draupnir, + details, + settingName, + newSettings.ok + ); + }, + summary: "Add an item to a collection protection setting.", +}); defineInterfaceCommand({ - designator: ["protections", "config", "remove"], - table: "draupnir", - parameters: parameters([ - { - name: 'item', - acceptor: findPresentationType('any'), - description: "An item to remove from a collection setting." - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, protectionName: string, settingName: string, value: unknown): Promise> { - const detailsResult = await findSettingDetailsForCommand(this.draupnir, protectionName, settingName); - if (isError(detailsResult)) { - return detailsResult; - } - const details = detailsResult.ok; - const settingDescription = details.settingDescription; - if (!settingDescription.isCollectionSetting()) { - return ActionError.Result( - `${protectionName}'s setting ${settingName} is not a collection protection setting, and cannot be used with the add or remove commands.` - ) - } - const newSettings = settingDescription.removeItem(details.previousSettings, value); - if (isError(newSettings)) { - return newSettings; - } - return await changeSettingsForCommands( - this.draupnir, - details, - settingName, - newSettings.ok - ); + designator: ["protections", "config", "remove"], + table: "draupnir", + parameters: parameters([ + { + name: "item", + acceptor: findPresentationType("any"), + description: "An item to remove from a collection setting.", }, - summary: "Remove an item from a collection protection setting." -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + protectionName: string, + settingName: string, + value: unknown + ): Promise> { + const detailsResult = await findSettingDetailsForCommand( + this.draupnir, + protectionName, + settingName + ); + if (isError(detailsResult)) { + return detailsResult; + } + const details = detailsResult.ok; + const settingDescription = details.settingDescription; + if (!settingDescription.isCollectionSetting()) { + return ActionError.Result( + `${protectionName}'s setting ${settingName} is not a collection protection setting, and cannot be used with the add or remove commands.` + ); + } + const newSettings = settingDescription.removeItem( + details.previousSettings, + value + ); + if (isError(newSettings)) { + return newSettings; + } + return await changeSettingsForCommands( + this.draupnir, + details, + settingName, + newSettings.ok + ); + }, + summary: "Remove an item from a collection protection setting.", +}); -function renderSettingChangeSummary(summary: SettingChangeSummary): DocumentNode { - const oldJSON = summary.description.toJSON({ [summary.description.key]: summary.oldValue }); - const newJSON = summary.description.toJSON({ [summary.description.key]: summary.newValue }); - return - Setting {summary.description.key} changed from {JSON.stringify(oldJSON)} to {JSON.stringify(newJSON)} +function renderSettingChangeSummary( + summary: SettingChangeSummary +): DocumentNode { + const oldJSON = summary.description.toJSON({ + [summary.description.key]: summary.oldValue, + }); + const newJSON = summary.description.toJSON({ + [summary.description.key]: summary.newValue, + }); + return ( + + Setting {summary.description.key} changed from{" "} + {JSON.stringify(oldJSON)} to{" "} + {JSON.stringify(newJSON)} + ); } - -async function settingChangeSummaryRenderer(this: unknown, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult) { - await tickCrossRenderer.call(this, client, commandRoomID, event, result); - if (isError(result)) { - return; - } else { - await renderMatrixAndSend( - {renderSettingChangeSummary(result.ok)}, - commandRoomID, - event, - client - ) - } +async function settingChangeSummaryRenderer( + this: unknown, + client: MatrixSendClient, + commandRoomID: StringRoomID, + event: RoomEvent, + result: ActionResult +) { + await tickCrossRenderer.call(this, client, commandRoomID, event, result); + if (isError(result)) { + return; + } else { + await renderMatrixAndSend( + {renderSettingChangeSummary(result.ok)}, + commandRoomID, + event, + client + ); + } } for (const designator of ["add", "set", "remove"]) { - defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "protections", "config", designator), - renderer: settingChangeSummaryRenderer, - }) + defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand( + "draupnir", + "protections", + "config", + designator + ), + renderer: settingChangeSummaryRenderer, + }); } - -function findProtectionDescriptionForCommand(protectionName: string): ActionResult { - const protectionDescription = findProtection(protectionName); - if (protectionDescription === undefined) { - return ActionError.Result( - `Couldn't find a protection named ${protectionName}` - ) - } - return Ok(protectionDescription); +function findProtectionDescriptionForCommand( + protectionName: string +): ActionResult { + const protectionDescription = findProtection(protectionName); + if (protectionDescription === undefined) { + return ActionError.Result( + `Couldn't find a protection named ${protectionName}` + ); + } + return Ok(protectionDescription); } -function findSettingDescriptionForCommand(settings: ProtectionSettings, settingName: string): ActionResult>> { - const setting = settings.getDescription(settingName); - if (setting === undefined) { - return ActionError.Result(`Unable to find a protection setting named ${settingName}`); - } - return Ok(setting); +function findSettingDescriptionForCommand( + settings: ProtectionSettings, + settingName: string +): ActionResult>> { + const setting = settings.getDescription(settingName); + if (setting === undefined) { + return ActionError.Result( + `Unable to find a protection setting named ${settingName}` + ); + } + return Ok(setting); } -interface SettingDetails = UnknownSettings> { - readonly protectionDescription: ProtectionDescription, - readonly protectionSettings: ProtectionSettings, - readonly settingDescription: ProtectionSetting, - readonly previousSettings: TSettings +interface SettingDetails< + TSettings extends UnknownSettings = UnknownSettings, +> { + readonly protectionDescription: ProtectionDescription; + readonly protectionSettings: ProtectionSettings; + readonly settingDescription: ProtectionSetting; + readonly previousSettings: TSettings; } -async function findSettingDetailsForCommand(draupnir: Draupnir, protectionName: string, settingName: string): Promise> { - const protectionDescription = findProtectionDescriptionForCommand(protectionName); - if (isError(protectionDescription)) { - return protectionDescription; - } - const settingsDescription = protectionDescription.ok.protectionSettings; - const settingDescription = findSettingDescriptionForCommand(settingsDescription, settingName); - if (isError(settingDescription)) { - return settingDescription; - } - const previousSettings = await draupnir.protectedRoomsSet.protections.getProtectionSettings( - protectionDescription.ok, +async function findSettingDetailsForCommand( + draupnir: Draupnir, + protectionName: string, + settingName: string +): Promise> { + const protectionDescription = + findProtectionDescriptionForCommand(protectionName); + if (isError(protectionDescription)) { + return protectionDescription; + } + const settingsDescription = protectionDescription.ok.protectionSettings; + const settingDescription = findSettingDescriptionForCommand( + settingsDescription, + settingName + ); + if (isError(settingDescription)) { + return settingDescription; + } + const previousSettings = + await draupnir.protectedRoomsSet.protections.getProtectionSettings( + protectionDescription.ok ); - if (isError(previousSettings)) { - return previousSettings; - } - return Ok({ - protectionDescription: protectionDescription.ok, - protectionSettings: settingsDescription, - settingDescription: settingDescription.ok, - previousSettings: previousSettings.ok - }) + if (isError(previousSettings)) { + return previousSettings; + } + return Ok({ + protectionDescription: protectionDescription.ok, + protectionSettings: settingsDescription, + settingDescription: settingDescription.ok, + previousSettings: previousSettings.ok, + }); } -async function changeSettingsForCommands = UnknownSettings>(draupnir: Draupnir, details: SettingDetails, settingName: string, newSettings: TSettings): Promise> { - const changeResult = await draupnir.protectedRoomsSet.protections.changeProtectionSettings( - details.protectionDescription, - draupnir.protectedRoomsSet, - draupnir, - newSettings +async function changeSettingsForCommands< + TSettings extends UnknownSettings = UnknownSettings, +>( + draupnir: Draupnir, + details: SettingDetails, + settingName: string, + newSettings: TSettings +): Promise> { + const changeResult = + await draupnir.protectedRoomsSet.protections.changeProtectionSettings( + details.protectionDescription, + draupnir.protectedRoomsSet, + draupnir, + newSettings ); - if (isError(changeResult)) { - return changeResult; - } - return Ok({ - description: details.settingDescription, - oldValue: details.previousSettings[settingName], - newValue: newSettings[settingName] - }); + if (isError(changeResult)) { + return changeResult; + } + return Ok({ + description: details.settingDescription, + oldValue: details.previousSettings[settingName], + newValue: newSettings[settingName], + }); } interface ProtectionsSummary { - readonly description: ProtectionDescription, - readonly isEnabled: boolean, - readonly protection?: Protection + readonly description: ProtectionDescription; + readonly isEnabled: boolean; + readonly protection?: Protection; } defineInterfaceCommand({ - designator: ["protections"], - table: "draupnir", - parameters: parameters([]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, _protectionName: string): Promise> { - const enabledProtections = this.draupnir.protectedRoomsSet.protections.allProtections; - const summaries: ProtectionsSummary[] = []; - for (const protectionDescription of getAllProtections()) { - const enabledProtection = enabledProtections.find(p => p.description.name === protectionDescription.name); - if (enabledProtection !== undefined) { - summaries.push({ - description: protectionDescription, - protection: enabledProtection, - isEnabled: true, - }) - } else { - summaries.push({ - description: protectionDescription, - isEnabled: false - }) - } - } - return Ok(summaries); - }, - summary: "List all available protections." -}) + designator: ["protections"], + table: "draupnir", + parameters: parameters([]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + _protectionName: string + ): Promise> { + const enabledProtections = + this.draupnir.protectedRoomsSet.protections.allProtections; + const summaries: ProtectionsSummary[] = []; + for (const protectionDescription of getAllProtections()) { + const enabledProtection = enabledProtections.find( + (p) => p.description.name === protectionDescription.name + ); + if (enabledProtection !== undefined) { + summaries.push({ + description: protectionDescription, + protection: enabledProtection, + isEnabled: true, + }); + } else { + summaries.push({ + description: protectionDescription, + isEnabled: false, + }); + } + } + return Ok(summaries); + }, + summary: "List all available protections.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "protections"), - renderer: async function(client, commandRoomID, event, result: ActionResult) { - await tickCrossRenderer.call(this, client, commandRoomID, event, result); - if (isError(result)) { - return; - } else { - await renderMatrixAndSend( - {renderProtectionsSummary(result.ok)}, - commandRoomID, - event, - client - ); - } + interfaceCommand: findTableCommand("draupnir", "protections"), + renderer: async function ( + client, + commandRoomID, + event, + result: ActionResult + ) { + await tickCrossRenderer.call(this, client, commandRoomID, event, result); + if (isError(result)) { + return; + } else { + await renderMatrixAndSend( + {renderProtectionsSummary(result.ok)}, + commandRoomID, + event, + client + ); } -}) + }, +}); -function renderProtectionsSummary(protectionsSummary: ProtectionsSummary[]): DocumentNode { - return - Available protections: -
    - {protectionsSummary.map(summary => - (
  • - {summary.isEnabled ? '🟢 (enabled)' : '🔴 (disabled)'} - {summary.description.name} - {summary.description.description} -
  • ) - )} -
+function renderProtectionsSummary( + protectionsSummary: ProtectionsSummary[] +): DocumentNode { + return ( + + Available protections: +
    + {protectionsSummary.map((summary) => ( +
  • + {summary.isEnabled ? "🟢 (enabled)" : "🔴 (disabled)"} + {summary.description.name} -{" "} + {summary.description.description} +
  • + ))} +
+ ); } diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index 54c91471..9f33c4c4 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -25,98 +25,140 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, MatrixEventReference, MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomReference, Ok, UserID, isError } from "matrix-protection-suite"; +import { + ActionResult, + MatrixEventReference, + MatrixEventViaAlias, + MatrixEventViaRoomID, + MatrixRoomReference, + Ok, + UserID, + isError, +} from "matrix-protection-suite"; import { redactUserMessagesIn } from "../utils"; -import { KeywordsDescription, ParsedKeywords, RestDescription, findPresentationType, parameters, union } from "./interface-manager/ParameterParsing"; +import { + KeywordsDescription, + ParsedKeywords, + RestDescription, + findPresentationType, + parameters, + union, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { Draupnir } from "../Draupnir"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; export async function redactEvent( - draupnir: Draupnir, - reference: MatrixEventReference, - reason: string + draupnir: Draupnir, + reference: MatrixEventReference, + reason: string ): Promise> { - const resolvedRoom = await resolveRoomReferenceSafe(draupnir.client, reference.reference); - if (isError(resolvedRoom)) { - return resolvedRoom; - } - await draupnir.client.redactEvent(resolvedRoom.ok.toRoomIDOrAlias(), reference.eventID, reason); - return Ok(undefined); + const resolvedRoom = await resolveRoomReferenceSafe( + draupnir.client, + reference.reference + ); + if (isError(resolvedRoom)) { + return resolvedRoom; + } + await draupnir.client.redactEvent( + resolvedRoom.ok.toRoomIDOrAlias(), + reference.eventID, + reason + ); + return Ok(undefined); } export async function redactCommand( - this: DraupnirContext, - keywords: ParsedKeywords, - reference: UserID | MatrixEventReference, - ...reasonParts: string[] + this: DraupnirContext, + keywords: ParsedKeywords, + reference: UserID | MatrixEventReference, + ...reasonParts: string[] ): Promise> { - const reason = reasonParts.join(' '); - if (reference instanceof MatrixEventViaAlias || reference instanceof MatrixEventViaRoomID) { - return await redactEvent(this.draupnir, reference, reason); - } - const rawLimit = keywords.getKeyword('limit', undefined); - const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10); - const restrictToRoomReference = keywords.getKeyword("room", undefined); - const restrictToRoom = restrictToRoomReference ? await resolveRoomReferenceSafe(this.client, restrictToRoomReference) : undefined; - if (restrictToRoom !== undefined && isError(restrictToRoom)) { - return restrictToRoom; - } - const roomsToRedactWithin = restrictToRoom === undefined ? this.draupnir.protectedRoomsSet.allProtectedRooms : [restrictToRoom.ok]; - await redactUserMessagesIn( - this.client, - this.draupnir.managementRoomOutput, - reference.toString(), - roomsToRedactWithin.map((room) => room.toRoomIDOrAlias()), - limit, - this.draupnir.config.noop - ); - return Ok(undefined); + const reason = reasonParts.join(" "); + if ( + reference instanceof MatrixEventViaAlias || + reference instanceof MatrixEventViaRoomID + ) { + return await redactEvent(this.draupnir, reference, reason); + } + const rawLimit = keywords.getKeyword("limit", undefined); + const limit = + rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10); + const restrictToRoomReference = keywords.getKeyword( + "room", + undefined + ); + const restrictToRoom = restrictToRoomReference + ? await resolveRoomReferenceSafe(this.client, restrictToRoomReference) + : undefined; + if (restrictToRoom !== undefined && isError(restrictToRoom)) { + return restrictToRoom; + } + const roomsToRedactWithin = + restrictToRoom === undefined + ? this.draupnir.protectedRoomsSet.allProtectedRooms + : [restrictToRoom.ok]; + await redactUserMessagesIn( + this.client, + this.draupnir.managementRoomOutput, + reference.toString(), + roomsToRedactWithin.map((room) => room.toRoomIDOrAlias()), + limit, + this.draupnir.config.noop + ); + return Ok(undefined); } defineInterfaceCommand({ - designator: ["redact"], - table: "draupnir", - parameters: parameters([ - { - name: "entity", - acceptor: union( - findPresentationType("UserID"), - findPresentationType("MatrixEventReference") - ), - }], + designator: ["redact"], + table: "draupnir", + parameters: parameters( + [ + { + name: "entity", + acceptor: union( + findPresentationType("UserID"), + findPresentationType("MatrixEventReference") + ), + }, + ], new RestDescription( - "reason", - findPresentationType("string"), - async function(_parameter) { - return { - suggestions: this.draupnir.config.commands.ban.defaultReasons - } - } + "reason", + findPresentationType("string"), + async function (_parameter) { + return { + suggestions: this.draupnir.config.commands.ban.defaultReasons, + }; + } ), new KeywordsDescription({ - limit: { - name: "limit", - isFlag: false, - acceptor: findPresentationType("string"), - description: 'Limit the number of messages to be redacted per room.' - }, - room: { - name: 'room', - isFlag: false, - acceptor: findPresentationType("MatrixRoomReference"), - description: 'Allows the command to be scoped to just one protected room.' - } - }), - ), - command: redactCommand, - summary: "Redacts either a users's recent messagaes within protected rooms or a specific message shared with the bot." + limit: { + name: "limit", + isFlag: false, + acceptor: findPresentationType("string"), + description: "Limit the number of messages to be redacted per room.", + }, + room: { + name: "room", + isFlag: false, + acceptor: findPresentationType("MatrixRoomReference"), + description: + "Allows the command to be scoped to just one protected room.", + }, + }) + ), + command: redactCommand, + summary: + "Redacts either a users's recent messagaes within protected rooms or a specific message shared with the bot.", }); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "redact"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "redact"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/ResolveAlias.tsx b/src/commands/ResolveAlias.tsx index 0a680c76..13008601 100644 --- a/src/commands/ResolveAlias.tsx +++ b/src/commands/ResolveAlias.tsx @@ -25,47 +25,72 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, MatrixRoomAlias, MatrixRoomID, isError } from "matrix-protection-suite"; +import { + ActionResult, + MatrixRoomAlias, + MatrixRoomID, + isError, +} from "matrix-protection-suite"; import { DraupnirContext } from "./CommandHandler"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { ParsedKeywords, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + ParsedKeywords, + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { renderRoomPill, tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; +import { + renderRoomPill, + tickCrossRenderer, +} from "./interface-manager/MatrixHelpRenderer"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; import { DeadDocumentJSX } from "./interface-manager/JSXFactory"; async function resolveAliasCommand( - this: DraupnirContext, - _keywords: ParsedKeywords, - alias: MatrixRoomAlias + this: DraupnirContext, + _keywords: ParsedKeywords, + alias: MatrixRoomAlias ): Promise> { - return await resolveRoomReferenceSafe(this.client, alias); + return await resolveRoomReferenceSafe(this.client, alias); } defineInterfaceCommand({ - table: "draupnir", - designator: ["resolve"], - parameters: parameters([{ - name: "alias", - acceptor: findPresentationType("MatrixRoomAlias") - }]), - command: resolveAliasCommand, - summary: "Resolve a room alias." -}) + table: "draupnir", + designator: ["resolve"], + parameters: parameters([ + { + name: "alias", + acceptor: findPresentationType("MatrixRoomAlias"), + }, + ]), + command: resolveAliasCommand, + summary: "Resolve a room alias.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "resolve"), - renderer: async function(this, client, commandRoomID, event, result: ActionResult) { - if (isError(result)) { - await tickCrossRenderer.call(this, client, commandRoomID, event, result); - return; - } - await renderMatrixAndSend( - {result.ok.toRoomIDOrAlias()} - {renderRoomPill(result.ok)}, - commandRoomID, - event, - client - ) + interfaceCommand: findTableCommand("draupnir", "resolve"), + renderer: async function ( + this, + client, + commandRoomID, + event, + result: ActionResult + ) { + if (isError(result)) { + await tickCrossRenderer.call(this, client, commandRoomID, event, result); + return; } -}) + await renderMatrixAndSend( + + {result.ok.toRoomIDOrAlias()} - {renderRoomPill(result.ok)} + , + commandRoomID, + event, + client + ); + }, +}); diff --git a/src/commands/Rooms.tsx b/src/commands/Rooms.tsx index c42f1d09..03f9e1ba 100644 --- a/src/commands/Rooms.tsx +++ b/src/commands/Rooms.tsx @@ -25,114 +25,163 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { DocumentNode } from "./interface-manager/DeadDocument"; import { DeadDocumentJSX } from "./interface-manager/JSXFactory"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; -import { ActionException, ActionExceptionKind, ActionResult, MatrixRoomID, MatrixRoomReference, Ok, isError } from "matrix-protection-suite"; +import { + ActionException, + ActionExceptionKind, + ActionResult, + MatrixRoomID, + MatrixRoomReference, + Ok, + isError, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; defineInterfaceCommand({ - table: "draupnir", - designator: ["rooms"], - summary: "List all of the protected rooms.", - parameters: parameters([]), - command: async function (this: DraupnirContext, _keywrods): Promise> { - return Ok(this.draupnir.protectedRoomsSet.allProtectedRooms); - } -}) + table: "draupnir", + designator: ["rooms"], + summary: "List all of the protected rooms.", + parameters: parameters([]), + command: async function ( + this: DraupnirContext, + _keywrods + ): Promise> { + return Ok(this.draupnir.protectedRoomsSet.allProtectedRooms); + }, +}); function renderProtectedRooms(rooms: MatrixRoomID[]): DocumentNode { - return -
- Protected Rooms ({rooms.length}): - -
+ return ( + +
+ + Protected Rooms ({rooms.length}): + + +
+ ); } defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "rooms"), - renderer: async function (client, commandRoomId, event, result: ActionResult) { - tickCrossRenderer.call(this, client, commandRoomId, event, result); - if (isError(result)) { - return; // tickCrossRenderer will handle it. - } - await renderMatrixAndSend( - renderProtectedRooms(result.ok), - commandRoomId, event, client - ); + interfaceCommand: findTableCommand("draupnir", "rooms"), + renderer: async function ( + client, + commandRoomId, + event, + result: ActionResult + ) { + tickCrossRenderer.call(this, client, commandRoomId, event, result); + if (isError(result)) { + return; // tickCrossRenderer will handle it. } -}) + await renderMatrixAndSend( + renderProtectedRooms(result.ok), + commandRoomId, + event, + client + ); + }, +}); defineInterfaceCommand({ - table: "draupnir", - designator: ["rooms", "add"], - summary: "Protect the room using the watched policy lists, banning users and synchronizing server ACL.", - parameters: parameters([ - { - name: 'room', - acceptor: findPresentationType("MatrixRoomReference"), - description: 'The room to protect.' - } - ]), - command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { - const joiner = this.clientPlatform.toRoomJoiner(); - const room = await joiner.joinRoom(roomRef); - if (isError(room)) { - return room.elaborate( - `The homeserver that Draupnir is hosted on cannot join this room using the room reference provided.\ - Try an alias or the "share room" button in your client to obtain a valid reference to the room.`, - ); - } - return await this.draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(room.ok); + table: "draupnir", + designator: ["rooms", "add"], + summary: + "Protect the room using the watched policy lists, banning users and synchronizing server ACL.", + parameters: parameters([ + { + name: "room", + acceptor: findPresentationType("MatrixRoomReference"), + description: "The room to protect.", }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords, + roomRef: MatrixRoomReference + ): Promise> { + const joiner = this.clientPlatform.toRoomJoiner(); + const room = await joiner.joinRoom(roomRef); + if (isError(room)) { + return room.elaborate( + `The homeserver that Draupnir is hosted on cannot join this room using the room reference provided.\ + Try an alias or the "share room" button in your client to obtain a valid reference to the room.` + ); + } + return await this.draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( + room.ok + ); + }, +}); defineInterfaceCommand({ - table: "draupnir", - designator: ["rooms", "remove"], - summary: "Stop protecting the room and leave.", - parameters: parameters([ - { - name: 'room', - acceptor: findPresentationType("MatrixRoomReference"), - description: 'The room to stop protecting.' - } - ]), - command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { - const room = await resolveRoomReferenceSafe(this.client, roomRef); - if (isError(room)) { - return room.elaborate( - `The homeserver that Draupnir is hosted on cannot join this room using the room reference provided.\ - Try an alias or the "share room" button in your client to obtain a valid reference to the room.`, - ); - }; - const removeResult = await this.draupnir.protectedRoomsSet.protectedRoomsManager.removeRoom(room.ok); - if (isError(removeResult)) { - return removeResult; - } - try { - await this.client.leaveRoom(room.ok.toRoomIDOrAlias()); - } catch (exception) { - return ActionException.Result( - `Failed to leave ${roomRef.toPermalink()} - the room is no longer being protected, but the bot could not leave.`, - { exceptionKind: ActionExceptionKind.Unknown, exception } - ); - } - return Ok(undefined); + table: "draupnir", + designator: ["rooms", "remove"], + summary: "Stop protecting the room and leave.", + parameters: parameters([ + { + name: "room", + acceptor: findPresentationType("MatrixRoomReference"), + description: "The room to stop protecting.", }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords, + roomRef: MatrixRoomReference + ): Promise> { + const room = await resolveRoomReferenceSafe(this.client, roomRef); + if (isError(room)) { + return room.elaborate( + `The homeserver that Draupnir is hosted on cannot join this room using the room reference provided.\ + Try an alias or the "share room" button in your client to obtain a valid reference to the room.` + ); + } + const removeResult = + await this.draupnir.protectedRoomsSet.protectedRoomsManager.removeRoom( + room.ok + ); + if (isError(removeResult)) { + return removeResult; + } + try { + await this.client.leaveRoom(room.ok.toRoomIDOrAlias()); + } catch (exception) { + return ActionException.Result( + `Failed to leave ${roomRef.toPermalink()} - the room is no longer being protected, but the bot could not leave.`, + { exceptionKind: ActionExceptionKind.Unknown, exception } + ); + } + return Ok(undefined); + }, +}); -for (const designator of [["rooms", "add"], ["rooms", "remove"]]) { - defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", ...designator), - renderer: tickCrossRenderer, - }) +for (const designator of [ + ["rooms", "add"], + ["rooms", "remove"], +]) { + defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("draupnir", ...designator), + renderer: tickCrossRenderer, + }); } diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index 08805aa7..36f345fe 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -28,118 +28,170 @@ limitations under the License. import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirContext } from "./CommandHandler"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { DeadDocumentJSX } from "./interface-manager/JSXFactory"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; -import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { findPresentationType, parameters, union } from "./interface-manager/ParameterParsing"; -import { ActionResult, MatrixRoomID, MatrixRoomReference, Ok, PolicyRoomWatchProfile, PolicyRule, StringRoomID, isError, UserID, RoomEvent } from "matrix-protection-suite"; +import { + defineMatrixInterfaceAdaptor, + MatrixContext, + MatrixInterfaceAdaptor, +} from "./interface-manager/MatrixInterfaceAdaptor"; +import { + findPresentationType, + parameters, + union, +} from "./interface-manager/ParameterParsing"; +import { + ActionResult, + MatrixRoomID, + MatrixRoomReference, + Ok, + PolicyRoomWatchProfile, + PolicyRule, + StringRoomID, + isError, + UserID, + RoomEvent, +} from "matrix-protection-suite"; import { listInfo } from "./StatusCommand"; async function renderListMatches( - this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult + this: MatrixInterfaceAdaptor, + client: MatrixSendClient, + commandRoomID: StringRoomID, + event: RoomEvent, + result: ActionResult ) { - if (isError(result)) { - return await tickCrossRenderer.call(this, client, commandRoomID, event, result); - } - const lists = result.ok; - if (lists.length === 0) { - return await renderMatrixAndSend( - No policy lists configured, - commandRoomID, event, client - ) - } + if (isError(result)) { + return await tickCrossRenderer.call( + this, + client, + commandRoomID, + event, + result + ); + } + const lists = result.ok; + if (lists.length === 0) { return await renderMatrixAndSend( - - Rules currently in use:
- {lists.map(list => renderListRules(list))} -
, - commandRoomID, event, client - ) + No policy lists configured, + commandRoomID, + event, + client + ); + } + return await renderMatrixAndSend( + + Rules currently in use: +
+ {lists.map((list) => renderListRules(list))} +
, + commandRoomID, + event, + client + ); } export function renderListRules(list: ListMatches) { - const renderRuleSummary = (rule: PolicyRule) => { - return
  • - {rule.kind} ({rule.recommendation}): {rule.entity} ({rule.reason}) -
  • - }; - return - {list.roomID} propagation: {list.profile.propagation}
    -
      - {list.matches.length === 0 - ?
    • No rules
    • - : list.matches.map(rule => renderRuleSummary(rule))} -
    + const renderRuleSummary = (rule: PolicyRule) => { + return ( +
  • + {rule.kind} ({rule.recommendation}):{" "} + {rule.entity} ({rule.reason}) +
  • + ); + }; + return ( + + {list.roomID} propagation:{" "} + {list.profile.propagation} +
    +
      + {list.matches.length === 0 ? ( +
    • + No rules +
    • + ) : ( + list.matches.map((rule) => renderRuleSummary(rule)) + )} +
    + ); } export interface ListMatches { - room: MatrixRoomID, - roomID: StringRoomID, - profile: PolicyRoomWatchProfile, - matches: PolicyRule[] + room: MatrixRoomID; + roomID: StringRoomID; + profile: PolicyRoomWatchProfile; + matches: PolicyRule[]; } defineInterfaceCommand({ - designator: ["rules"], - table: "draupnir", - parameters: parameters([]), - command: async function (this: DraupnirContext): Promise> { - const infoResult = await listInfo(this.draupnir); - return Ok( - infoResult.map( - policyRoom => ({ - room: policyRoom.revision.room, - roomID: policyRoom.revision.room.toRoomIDOrAlias(), - profile: policyRoom.watchedListProfile, - matches: policyRoom.revision.allRules() - }) - ) - ); - }, - summary: "Lists the rules currently in use by Mjolnir." -}) + designator: ["rules"], + table: "draupnir", + parameters: parameters([]), + command: async function ( + this: DraupnirContext + ): Promise> { + const infoResult = await listInfo(this.draupnir); + return Ok( + infoResult.map((policyRoom) => ({ + room: policyRoom.revision.room, + roomID: policyRoom.revision.room.toRoomIDOrAlias(), + profile: policyRoom.watchedListProfile, + matches: policyRoom.revision.allRules(), + })) + ); + }, + summary: "Lists the rules currently in use by Mjolnir.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "rules"), - renderer: renderListMatches -}) + interfaceCommand: findTableCommand("draupnir", "rules"), + renderer: renderListMatches, +}); defineInterfaceCommand({ - designator: ["rules", "matching"], - table: "draupnir", - parameters: parameters([ - { - name: "entity", - acceptor: union( - findPresentationType("UserID"), - findPresentationType("MatrixRoomReference"), - findPresentationType("string"), - ) - } - ]), - command: async function ( - this: DraupnirContext, _keywords, entity: string|UserID|MatrixRoomReference - ): Promise> { - const policyRooms = await listInfo(this.draupnir); - return Ok( - policyRooms - .map(policyRoom => { - return { - room: policyRoom.revision.room, - roomID: policyRoom.revision.room.toRoomIDOrAlias(), - matches: policyRoom.revision.allRulesMatchingEntity(entity.toString()), - profile: policyRoom.watchedListProfile - } - }) - ); + designator: ["rules", "matching"], + table: "draupnir", + parameters: parameters([ + { + name: "entity", + acceptor: union( + findPresentationType("UserID"), + findPresentationType("MatrixRoomReference"), + findPresentationType("string") + ), }, - summary: "Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user" -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords, + entity: string | UserID | MatrixRoomReference + ): Promise> { + const policyRooms = await listInfo(this.draupnir); + return Ok( + policyRooms.map((policyRoom) => { + return { + room: policyRoom.revision.room, + roomID: policyRoom.revision.room.toRoomIDOrAlias(), + matches: policyRoom.revision.allRulesMatchingEntity( + entity.toString() + ), + profile: policyRoom.watchedListProfile, + }; + }) + ); + }, + summary: + "Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user", +}); // I'm pretty sure that both commands could merge and use the same rendeer. defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "rules", "matching"), - renderer: renderListMatches -}) + interfaceCommand: findTableCommand("draupnir", "rules", "matching"), + renderer: renderListMatches, +}); diff --git a/src/commands/SetDisplayNameCommand.ts b/src/commands/SetDisplayNameCommand.ts index 2183a86d..0da3b08d 100644 --- a/src/commands/SetDisplayNameCommand.ts +++ b/src/commands/SetDisplayNameCommand.ts @@ -1,39 +1,53 @@ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { ParsedKeywords, RestDescription, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + ParsedKeywords, + RestDescription, + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { ActionError, ActionResult, Ok } from "matrix-protection-suite"; - defineInterfaceCommand({ - table: "draupnir", - designator: ["displayname"], - summary: "Sets the displayname of the draupnir instance to the specified value in all rooms.", - parameters: parameters( - [], - new RestDescription( - "displayname", - findPresentationType("string"), - ), - ), - command: execSetDisplayNameCommand -}) + table: "draupnir", + designator: ["displayname"], + summary: + "Sets the displayname of the draupnir instance to the specified value in all rooms.", + parameters: parameters( + [], + new RestDescription( + "displayname", + findPresentationType("string") + ) + ), + command: execSetDisplayNameCommand, +}); // !draupnir displayname -export async function execSetDisplayNameCommand(this: DraupnirContext, _keywords: ParsedKeywords, ...displaynameParts: string[]): Promise> { - const displayname = displaynameParts.join(' '); - try { - await this.client.setDisplayName(displayname); - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - return ActionError.Result(`Failed to set displayname to ${displayname}: ${message}`) - } +export async function execSetDisplayNameCommand( + this: DraupnirContext, + _keywords: ParsedKeywords, + ...displaynameParts: string[] +): Promise> { + const displayname = displaynameParts.join(" "); + try { + await this.client.setDisplayName(displayname); + } catch (e) { + const message = e.message || (e.body ? e.body.error : ""); + return ActionError.Result( + `Failed to set displayname to ${displayname}: ${message}` + ); + } - return Ok(undefined); + return Ok(undefined); } defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "displayname"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "displayname"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/SetPowerLevelCommand.ts b/src/commands/SetPowerLevelCommand.ts index 37512bc4..5efaa29a 100644 --- a/src/commands/SetPowerLevelCommand.ts +++ b/src/commands/SetPowerLevelCommand.ts @@ -25,50 +25,72 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, MatrixRoomID, MatrixRoomReference, Ok, UserID, isError } from "matrix-protection-suite"; +import { + ActionResult, + MatrixRoomID, + MatrixRoomReference, + Ok, + UserID, + isError, +} from "matrix-protection-suite"; import { DraupnirContext } from "./CommandHandler"; -import { ParsedKeywords, RestDescription, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; +import { + ParsedKeywords, + RestDescription, + findPresentationType, + parameters, +} from "./interface-manager/ParameterParsing"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { defineInterfaceCommand } from "./interface-manager/InterfaceCommand"; async function setPowerLevelCommand( - this: DraupnirContext, - _keywords: ParsedKeywords, - user: UserID, - powerLevel: string, - ...givenRooms: MatrixRoomReference[] + this: DraupnirContext, + _keywords: ParsedKeywords, + user: UserID, + powerLevel: string, + ...givenRooms: MatrixRoomReference[] ): Promise> { - const parsedLevel = Number.parseInt(powerLevel, 10); - const resolvedGivenRooms: MatrixRoomID[] = []; - for (const room of givenRooms) { - const resolvedResult = await resolveRoomReferenceSafe(this.client, room); - if (isError(resolvedResult)) { - return resolvedResult; - } else { - resolvedGivenRooms.push(resolvedResult.ok); - } + const parsedLevel = Number.parseInt(powerLevel, 10); + const resolvedGivenRooms: MatrixRoomID[] = []; + for (const room of givenRooms) { + const resolvedResult = await resolveRoomReferenceSafe(this.client, room); + if (isError(resolvedResult)) { + return resolvedResult; + } else { + resolvedGivenRooms.push(resolvedResult.ok); } - const rooms = givenRooms.length === 0 ? this.draupnir.protectedRoomsSet.allProtectedRooms : resolvedGivenRooms; - for (const room of rooms) { - await this.draupnir.client.setUserPowerLevel(user.toString(), room.toRoomIDOrAlias(), parsedLevel); - } - return Ok(undefined); + } + const rooms = + givenRooms.length === 0 + ? this.draupnir.protectedRoomsSet.allProtectedRooms + : resolvedGivenRooms; + for (const room of rooms) { + await this.draupnir.client.setUserPowerLevel( + user.toString(), + room.toRoomIDOrAlias(), + parsedLevel + ); + } + return Ok(undefined); } defineInterfaceCommand({ - table: "draupnir", - designator: ["powerlevel"], - parameters: parameters([ - { - name: "user", - acceptor: findPresentationType("UserID") - }, - { - name: "power level", - acceptor: findPresentationType("string") - } + table: "draupnir", + designator: ["powerlevel"], + parameters: parameters( + [ + { + name: "user", + acceptor: findPresentationType("UserID"), + }, + { + name: "power level", + acceptor: findPresentationType("string"), + }, ], - new RestDescription("rooms", findPresentationType("MatrixRoomReference"))), - command: setPowerLevelCommand, - summary: "Set the power level of a user across the protected rooms set, or within the provided rooms" -}) + new RestDescription("rooms", findPresentationType("MatrixRoomReference")) + ), + command: setPowerLevelCommand, + summary: + "Set the power level of a user across the protected rooms set, or within the provided rooms", +}); diff --git a/src/commands/ShutdownRoomCommand.ts b/src/commands/ShutdownRoomCommand.ts index 2e67905d..28dff46f 100644 --- a/src/commands/ShutdownRoomCommand.ts +++ b/src/commands/ShutdownRoomCommand.ts @@ -25,52 +25,78 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, ParsedKeywords, RestDescription } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, + RestDescription, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; -import { ActionError, ActionResult, MatrixRoomReference, Ok, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + MatrixRoomReference, + Ok, + isError, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; defineInterfaceCommand({ - table: "synapse admin", - designator: ["shutdown", "room"], - summary: "Prevents access to the the room on this server and sends a message to all users that they have violated the terms of service.", - parameters: parameters([ - { - name: 'room', - acceptor: findPresentationType("MatrixRoomReference"), - }, + table: "synapse admin", + designator: ["shutdown", "room"], + summary: + "Prevents access to the the room on this server and sends a message to all users that they have violated the terms of service.", + parameters: parameters( + [ + { + name: "room", + acceptor: findPresentationType("MatrixRoomReference"), + }, ], - new RestDescription("reason", findPresentationType("string"))), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, targetRoom: MatrixRoomReference, ...reasonParts: string[]): Promise> { - const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); - if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { - return ActionError.Result('I am not a Synapse administrator, or the endpoint to shutdown a room is blocked'); - } - if (this.draupnir.synapseAdminClient === undefined) { - throw new TypeError(`Should be impossible at this point.`); - } - const resolvedRoom = await resolveRoomReferenceSafe(this.client, targetRoom); - if (isError(resolvedRoom)) { - return resolvedRoom; - } - const reason = reasonParts.join(" "); - await this.draupnir.synapseAdminClient.deleteRoom( - resolvedRoom.ok.toRoomIDOrAlias(), - { - message: reason, - new_room_user_id: this.draupnir.clientUserID, - block: true, - } - - ); - return Ok(undefined); - }, -}) + new RestDescription("reason", findPresentationType("string")) + ), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + targetRoom: MatrixRoomReference, + ...reasonParts: string[] + ): Promise> { + const isAdmin = await this.draupnir.synapseAdminClient?.isSynapseAdmin(); + if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { + return ActionError.Result( + "I am not a Synapse administrator, or the endpoint to shutdown a room is blocked" + ); + } + if (this.draupnir.synapseAdminClient === undefined) { + throw new TypeError(`Should be impossible at this point.`); + } + const resolvedRoom = await resolveRoomReferenceSafe( + this.client, + targetRoom + ); + if (isError(resolvedRoom)) { + return resolvedRoom; + } + const reason = reasonParts.join(" "); + await this.draupnir.synapseAdminClient.deleteRoom( + resolvedRoom.ok.toRoomIDOrAlias(), + { + message: reason, + new_room_user_id: this.draupnir.clientUserID, + block: true, + } + ); + return Ok(undefined); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "shutdown", "room"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("synapse admin", "shutdown", "room"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index 172171dc..39b9b17a 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -26,116 +26,182 @@ limitations under the License. */ import { DOCUMENTATION_URL, PACKAGE_JSON, SOFTWARE_VERSION } from "../config"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { parameters } from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { DeadDocumentJSX } from "./interface-manager/JSXFactory"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; -import { ActionResult, Ok, PolicyRoomRevision, PolicyRoomWatchProfile, PolicyRuleType, isError } from "matrix-protection-suite"; +import { + ActionResult, + Ok, + PolicyRoomRevision, + PolicyRoomWatchProfile, + PolicyRuleType, + isError, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { DocumentNode } from "./interface-manager/DeadDocument"; defineInterfaceCommand({ - designator: ["status"], - table: "draupnir", - parameters: parameters([]), - command: async function (this: DraupnirContext): Promise> { - return Ok(await draupnirStatusInfo(this.draupnir)) - }, - summary: "Show the status of the bot." -}) + designator: ["status"], + table: "draupnir", + parameters: parameters([]), + command: async function ( + this: DraupnirContext + ): Promise> { + return Ok(await draupnirStatusInfo(this.draupnir)); + }, + summary: "Show the status of the bot.", +}); export interface ListInfo { - watchedListProfile: PolicyRoomWatchProfile, - revision: PolicyRoomRevision + watchedListProfile: PolicyRoomWatchProfile; + revision: PolicyRoomRevision; } export interface StatusInfo { - numberOfProtectedRooms: number, - subscribedLists: ListInfo[], - subscribedAndProtectedLists: ListInfo[], - version: string, - repository: string, - documentationURL: string, + numberOfProtectedRooms: number; + subscribedLists: ListInfo[]; + subscribedAndProtectedLists: ListInfo[]; + version: string; + repository: string; + documentationURL: string; } export async function listInfo(draupnir: Draupnir): Promise { - const watchedListProfiles = draupnir.protectedRoomsSet.issuerManager.allWatchedLists; - const issuerResults = await Promise.all(watchedListProfiles.map((profile) => - draupnir.policyRoomManager.getPolicyRoomRevisionIssuer(profile.room) - )); - return issuerResults.map((result) => { - if (isError(result)) { - throw result.error; - } - const revision = result.ok.currentRevision; - const associatedProfile = watchedListProfiles.find((profile) => profile.room.toRoomIDOrAlias() === revision.room.toRoomIDOrAlias()) - if (associatedProfile === undefined) { - throw new TypeError(`Shouldn't be possible to have got a result for a list profile we don't have`) - } - return { - watchedListProfile: associatedProfile, - revision: revision - } - }) + const watchedListProfiles = + draupnir.protectedRoomsSet.issuerManager.allWatchedLists; + const issuerResults = await Promise.all( + watchedListProfiles.map((profile) => + draupnir.policyRoomManager.getPolicyRoomRevisionIssuer(profile.room) + ) + ); + return issuerResults.map((result) => { + if (isError(result)) { + throw result.error; + } + const revision = result.ok.currentRevision; + const associatedProfile = watchedListProfiles.find( + (profile) => + profile.room.toRoomIDOrAlias() === revision.room.toRoomIDOrAlias() + ); + if (associatedProfile === undefined) { + throw new TypeError( + `Shouldn't be possible to have got a result for a list profile we don't have` + ); + } + return { + watchedListProfile: associatedProfile, + revision: revision, + }; + }); } // FIXME: need a shoutout to dependencies in here and NOTICE info. -export async function draupnirStatusInfo(draupnir: Draupnir): Promise { - const watchedListInfo = await listInfo(draupnir); - const protectedWatchedLists = watchedListInfo.filter((info) => draupnir.protectedRoomsSet.isProtectedRoom(info.revision.room.toRoomIDOrAlias())); - const unprotectedListProfiles = watchedListInfo.filter((info) => !draupnir.protectedRoomsSet.isProtectedRoom(info.revision.room.toRoomIDOrAlias())); - return { - numberOfProtectedRooms: draupnir.protectedRoomsSet.allProtectedRooms.length, - subscribedLists: unprotectedListProfiles, - subscribedAndProtectedLists: protectedWatchedLists, - documentationURL: DOCUMENTATION_URL, - version: SOFTWARE_VERSION, - repository: PACKAGE_JSON['repository'] ?? 'Unknown' - } +export async function draupnirStatusInfo( + draupnir: Draupnir +): Promise { + const watchedListInfo = await listInfo(draupnir); + const protectedWatchedLists = watchedListInfo.filter((info) => + draupnir.protectedRoomsSet.isProtectedRoom( + info.revision.room.toRoomIDOrAlias() + ) + ); + const unprotectedListProfiles = watchedListInfo.filter( + (info) => + !draupnir.protectedRoomsSet.isProtectedRoom( + info.revision.room.toRoomIDOrAlias() + ) + ); + return { + numberOfProtectedRooms: draupnir.protectedRoomsSet.allProtectedRooms.length, + subscribedLists: unprotectedListProfiles, + subscribedAndProtectedLists: protectedWatchedLists, + documentationURL: DOCUMENTATION_URL, + version: SOFTWARE_VERSION, + repository: PACKAGE_JSON["repository"] ?? "Unknown", + }; } export function renderStatusInfo(info: StatusInfo): DocumentNode { - const renderPolicyLists = (header: string, lists: ListInfo[]) => { - const renderedLists = lists.map(list => { - return
  • - {list.revision.room.toRoomIDOrAlias()} - ({list.revision.shortcode ?? ''}) propagation: {list.watchedListProfile.propagation} - (rules: {list.revision.allRulesOfType(PolicyRuleType.Server).length} servers, {list.revision.allRulesOfType(PolicyRuleType.User).length} users, {list.revision.allRulesOfType(PolicyRuleType.Room).length} rooms) + const renderPolicyLists = (header: string, lists: ListInfo[]) => { + const renderedLists = lists.map((list) => { + return ( +
  • + + {list.revision.room.toRoomIDOrAlias()} + {" "} + ({list.revision.shortcode ?? ""}) propagation:{" "} + {list.watchedListProfile.propagation} (rules:{" "} + {list.revision.allRulesOfType(PolicyRuleType.Server).length} servers,{" "} + {list.revision.allRulesOfType(PolicyRuleType.User).length} users,{" "} + {list.revision.allRulesOfType(PolicyRuleType.Room).length} rooms) +
  • + ); + }); + return ( + + {header} +
    +
      + {renderedLists.length === 0 ? ( +
    • + None
    • - }); - return - {header}
      -
        - {renderedLists.length === 0 ?
      • None
      • : renderedLists} -
      -
      - }; - return - Protected Rooms: {info.numberOfProtectedRooms}
      - {renderPolicyLists('Subscribed policy rooms', info.subscribedLists)} - {renderPolicyLists('Subscribed and protected policy rooms', info.subscribedAndProtectedLists)} - Version: {info.version}
      - Repository: {info.repository}
      - Documentation: {info.documentationURL}
      + ) : ( + renderedLists + )} +
    +
    + ); + }; + return ( + + Protected Rooms: + {info.numberOfProtectedRooms} +
    + {renderPolicyLists("Subscribed policy rooms", info.subscribedLists)} + {renderPolicyLists( + "Subscribed and protected policy rooms", + info.subscribedAndProtectedLists + )} + Version: + {info.version} +
    + Repository: + {info.repository} +
    + Documentation: {" "} + {info.documentationURL} +
    + ); } defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "status"), - renderer: async function (this, client, commandRoomID, event, result: ActionResult): Promise { - if (isError(result)) { - await tickCrossRenderer.call(this, client, commandRoomID, event, result); - return; - } - const info = result.ok; - await renderMatrixAndSend( - renderStatusInfo(info), - commandRoomID, - event, - client - ); + interfaceCommand: findTableCommand("draupnir", "status"), + renderer: async function ( + this, + client, + commandRoomID, + event, + result: ActionResult + ): Promise { + if (isError(result)) { + await tickCrossRenderer.call(this, client, commandRoomID, event, result); + return; } + const info = result.ok; + await renderMatrixAndSend( + renderStatusInfo(info), + commandRoomID, + event, + client + ); + }, }); diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index 0c341f43..c454190b 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -26,130 +26,188 @@ limitations under the License. */ import { DraupnirContext } from "./CommandHandler"; -import { findPresentationType, KeywordsDescription, parameters, ParsedKeywords, union } from "./interface-manager/ParameterParsing"; +import { + findPresentationType, + KeywordsDescription, + parameters, + ParsedKeywords, + union, +} from "./interface-manager/ParameterParsing"; import { MatrixGlob, LogLevel } from "matrix-bot-sdk"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { Draupnir } from "../Draupnir"; -import { ActionResult, isError, isStringUserID, MatrixRoomReference, Ok, PolicyRuleType, UserID } from "matrix-protection-suite"; +import { + ActionResult, + isError, + isStringUserID, + MatrixRoomReference, + Ok, + PolicyRuleType, + UserID, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { findPolicyRoomIDFromShortcode } from "./CreateBanListCommand"; async function unbanUserFromRooms(draupnir: Draupnir, rule: MatrixGlob) { - await draupnir.managementRoomOutput.logMessage(LogLevel.INFO, "Unban", `Unbanning users that match glob: ${rule.regex}`); - for (const revision of draupnir.protectedRoomsSet.setMembership.allRooms) { - for (const member of revision.members()) { - if (member.membership !== 'ban') { - continue; - } - if (rule.test(member.userID)) { - await draupnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "Unban", `Unbanning ${member.userID} in ${revision.room.toRoomIDOrAlias()}`, revision.room.toRoomIDOrAlias()); - if (!draupnir.config.noop) { - await draupnir.client.unbanUser(member.userID, revision.room.toRoomIDOrAlias()); - } else { - await draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "Unban", `Attempted to unban ${member.userID} in ${revision.room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, revision.room.toRoomIDOrAlias()); - } - } + await draupnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "Unban", + `Unbanning users that match glob: ${rule.regex}` + ); + for (const revision of draupnir.protectedRoomsSet.setMembership.allRooms) { + for (const member of revision.members()) { + if (member.membership !== "ban") { + continue; + } + if (rule.test(member.userID)) { + await draupnir.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "Unban", + `Unbanning ${member.userID} in ${revision.room.toRoomIDOrAlias()}`, + revision.room.toRoomIDOrAlias() + ); + if (!draupnir.config.noop) { + await draupnir.client.unbanUser( + member.userID, + revision.room.toRoomIDOrAlias() + ); + } else { + await draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "Unban", + `Attempted to unban ${member.userID} in ${revision.room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, + revision.room.toRoomIDOrAlias() + ); } + } } + } } async function unban( - this: DraupnirContext, - keywords: ParsedKeywords, - entity: UserID|MatrixRoomReference|string, - policyRoomDesignator: MatrixRoomReference|string, + this: DraupnirContext, + keywords: ParsedKeywords, + entity: UserID | MatrixRoomReference | string, + policyRoomDesignator: MatrixRoomReference | string ): Promise> { - const policyRoomReference = typeof policyRoomDesignator === 'string' - ? await findPolicyRoomIDFromShortcode(this.draupnir, policyRoomDesignator) - : Ok(policyRoomDesignator); - if (isError(policyRoomReference)) { - return policyRoomReference; - } - const policyRoom = await resolveRoomReferenceSafe(this.client, policyRoomReference.ok); - if (isError(policyRoom)) { - return policyRoom; - } - const policyRoomEditor = await this.draupnir.policyRoomManager.getPolicyRoomEditor( - policyRoom.ok - ); - if (isError(policyRoomEditor)) { - return policyRoomEditor; + const policyRoomReference = + typeof policyRoomDesignator === "string" + ? await findPolicyRoomIDFromShortcode(this.draupnir, policyRoomDesignator) + : Ok(policyRoomDesignator); + if (isError(policyRoomReference)) { + return policyRoomReference; + } + const policyRoom = await resolveRoomReferenceSafe( + this.client, + policyRoomReference.ok + ); + if (isError(policyRoom)) { + return policyRoom; + } + const policyRoomEditor = + await this.draupnir.policyRoomManager.getPolicyRoomEditor(policyRoom.ok); + if (isError(policyRoomEditor)) { + return policyRoomEditor; + } + const policyRoomUnban = + entity instanceof UserID + ? await policyRoomEditor.ok.unbanEntity( + PolicyRuleType.User, + entity.toString() + ) + : typeof entity === "string" + ? await policyRoomEditor.ok.unbanEntity(PolicyRuleType.Server, entity) + : await (async () => { + const bannedRoom = await resolveRoomReferenceSafe( + this.client, + entity + ); + if (isError(bannedRoom)) { + return bannedRoom; + } + return await policyRoomEditor.ok.unbanEntity( + PolicyRuleType.Room, + bannedRoom.ok.toRoomIDOrAlias() + ); + })(); + if (isError(policyRoomUnban)) { + return policyRoomUnban; + } + if (typeof entity === "string" || entity instanceof UserID) { + const rawEnttiy = typeof entity === "string" ? entity : entity.toString(); + const isGlob = (string: string) => + string.includes("*") ? true : string.includes("?"); + const rule = new MatrixGlob(entity.toString()); + if (isStringUserID(rawEnttiy)) { + this.draupnir.unlistedUserRedactionQueue.removeUser(rawEnttiy); } - const policyRoomUnban = entity instanceof UserID - ? await policyRoomEditor.ok.unbanEntity(PolicyRuleType.User, entity.toString()) - : typeof entity === 'string' - ? await policyRoomEditor.ok.unbanEntity(PolicyRuleType.Server, entity) - : await (async () => { - const bannedRoom = await resolveRoomReferenceSafe(this.client, entity); - if (isError(bannedRoom)) { - return bannedRoom; - } - return await policyRoomEditor.ok.unbanEntity(PolicyRuleType.Room, bannedRoom.ok.toRoomIDOrAlias()); - })(); - if (isError(policyRoomUnban)) { - return policyRoomUnban; - } - if (typeof entity === 'string' || entity instanceof UserID) { - const rawEnttiy = typeof entity === 'string' ? entity : entity.toString(); - const isGlob = (string: string) => string.includes('*') ? true : string.includes('?'); - const rule = new MatrixGlob(entity.toString()) - if (isStringUserID(rawEnttiy)) { - this.draupnir.unlistedUserRedactionQueue.removeUser(rawEnttiy); - } - if (!isGlob(rawEnttiy) || keywords.getKeyword("true", "false") === "true") { - await unbanUserFromRooms(this.draupnir, rule); - } else { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "Unban", "Running unban without `unban true` will not override existing room level bans"); - } + if ( + !isGlob(rawEnttiy) || + keywords.getKeyword("true", "false") === "true" + ) { + await unbanUserFromRooms(this.draupnir, rule); + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "Unban", + "Running unban without `unban true` will not override existing room level bans" + ); } + } - return Ok(undefined); + return Ok(undefined); } defineInterfaceCommand({ - designator: ["unban"], - table: "draupnir", - parameters: parameters([ - { - name: "entity", - acceptor: union( - findPresentationType("UserID"), - findPresentationType("MatrixRoomReference"), - findPresentationType("string") - ) - }, - { - name: "list", - acceptor: union( - findPresentationType("MatrixRoomReference"), - findPresentationType("string") - ), - prompt: async function (this: DraupnirContext) { - return { - suggestions: this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( - this.draupnir.clientUserID, - PolicyRuleType.User - ) - }; - } + designator: ["unban"], + table: "draupnir", + parameters: parameters( + [ + { + name: "entity", + acceptor: union( + findPresentationType("UserID"), + findPresentationType("MatrixRoomReference"), + findPresentationType("string") + ), + }, + { + name: "list", + acceptor: union( + findPresentationType("MatrixRoomReference"), + findPresentationType("string") + ), + prompt: async function (this: DraupnirContext) { + return { + suggestions: + this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( + this.draupnir.clientUserID, + PolicyRuleType.User + ), + }; }, + }, ], undefined, new KeywordsDescription({ - true: { - name: "true", - isFlag: true, - acceptor: findPresentationType("boolean"), - } + true: { + name: "true", + isFlag: true, + acceptor: findPresentationType("boolean"), + }, }) - ), - command: unban, - summary: "Removes an entity from a policy list. If the entity is a glob, then the flag --true must be provided to unban users matching the glob from all protected rooms." -}) + ), + command: unban, + summary: + "Removes an entity from a policy list. If the entity is a glob, then the flag --true must be provided to unban users matching the glob from all protected rooms.", +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "unban"), - renderer: tickCrossRenderer -}) + interfaceCommand: findTableCommand("draupnir", "unban"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/WatchUnwatchCommand.ts b/src/commands/WatchUnwatchCommand.ts index fdec298d..f8f87d4b 100644 --- a/src/commands/WatchUnwatchCommand.ts +++ b/src/commands/WatchUnwatchCommand.ts @@ -25,58 +25,93 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; +import { + defineInterfaceCommand, + findTableCommand, +} from "./interface-manager/InterfaceCommand"; +import { + findPresentationType, + parameters, + ParsedKeywords, +} from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { ActionResult, MatrixRoomReference, PropagationType, isError } from "matrix-protection-suite"; +import { + ActionResult, + MatrixRoomReference, + PropagationType, + isError, +} from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; defineInterfaceCommand({ - table: "draupnir", - designator: ["watch"], - summary: "Watches a list and applies the list's assocated policies to draupnir's protected rooms.", - parameters: parameters([ - { - name: 'list', - acceptor: findPresentationType("MatrixRoomReference"), - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, policyRoomReference: MatrixRoomReference): Promise> { - const policyRoom = await resolveRoomReferenceSafe(this.client, policyRoomReference); - if (isError(policyRoom)) { - return policyRoom; - } - return await this.draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); + table: "draupnir", + designator: ["watch"], + summary: + "Watches a list and applies the list's assocated policies to draupnir's protected rooms.", + parameters: parameters([ + { + name: "list", + acceptor: findPresentationType("MatrixRoomReference"), }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + policyRoomReference: MatrixRoomReference + ): Promise> { + const policyRoom = await resolveRoomReferenceSafe( + this.client, + policyRoomReference + ); + if (isError(policyRoom)) { + return policyRoom; + } + return await this.draupnir.protectedRoomsSet.issuerManager.watchList( + PropagationType.Direct, + policyRoom.ok, + {} + ); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "watch"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("draupnir", "watch"), + renderer: tickCrossRenderer, +}); defineInterfaceCommand({ - table: "draupnir", - designator: ["unwatch"], - summary: "Unwatches a list and stops applying the list's assocated policies to draupnir's protected rooms.", - parameters: parameters([ - { - name: 'list', - acceptor: findPresentationType("MatrixRoomReference"), - } - ]), - command: async function (this: DraupnirContext, _keywords: ParsedKeywords, policyRoomReference: MatrixRoomReference): Promise> { - const policyRoom = await resolveRoomReferenceSafe(this.client, policyRoomReference); - if (isError(policyRoom)) { - return policyRoom; - } - return await this.draupnir.protectedRoomsSet.issuerManager.unwatchList(PropagationType.Direct, policyRoom.ok); + table: "draupnir", + designator: ["unwatch"], + summary: + "Unwatches a list and stops applying the list's assocated policies to draupnir's protected rooms.", + parameters: parameters([ + { + name: "list", + acceptor: findPresentationType("MatrixRoomReference"), }, -}) + ]), + command: async function ( + this: DraupnirContext, + _keywords: ParsedKeywords, + policyRoomReference: MatrixRoomReference + ): Promise> { + const policyRoom = await resolveRoomReferenceSafe( + this.client, + policyRoomReference + ); + if (isError(policyRoom)) { + return policyRoom; + } + return await this.draupnir.protectedRoomsSet.issuerManager.unwatchList( + PropagationType.Direct, + policyRoom.ok + ); + }, +}); defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("draupnir", "unwatch"), - renderer: tickCrossRenderer, -}) + interfaceCommand: findTableCommand("draupnir", "unwatch"), + renderer: tickCrossRenderer, +}); diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index 0b56e9c8..e94be53e 100644 --- a/src/commands/interface-manager/CommandReader.ts +++ b/src/commands/interface-manager/CommandReader.ts @@ -7,93 +7,103 @@ // https://github.com/Gnuxie/super-cool-stream // -import { MatrixEventReference, MatrixRoomReference, Permalinks, UserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; +import { + MatrixEventReference, + MatrixRoomReference, + Permalinks, + UserID, + isError, + isStringRoomAlias, + isStringRoomID, +} from "matrix-protection-suite"; export interface SuperCoolStream { - readonly source: Sequence; - peekItem(eof?: EOF): Item | EOF; - readItem(eof?: EOF): Item | EOF; - getPosition(): number; - setPosition(n: number): void; - clone(): SuperCoolStream; - savingPositionIf(description: { - predicate: (t: Result) => boolean; - body: (stream: SuperCoolStream) => Result; - }): Result; + readonly source: Sequence; + peekItem(eof?: EOF): Item | EOF; + readItem(eof?: EOF): Item | EOF; + getPosition(): number; + setPosition(n: number): void; + clone(): SuperCoolStream; + savingPositionIf(description: { + predicate: (t: Result) => boolean; + body: (stream: SuperCoolStream) => Result; + }): Result; } interface Indexable { - at(position: number): Item | undefined; + at(position: number): Item | undefined; } -export class StandardSuperCoolStream> implements SuperCoolStream { - protected position: number; - /** - * Makes the super cool string stream. - * @param source A string to act as the source of the stream. - * @param start Where in the string we should start reading. - */ - constructor( - public readonly source: Sequence, - start = 0 - ) { - this.position = start; - } - - public peekItem(eof?: EOF): Item | EOF { - return this.source.at(this.position) ?? (eof as EOF); - } - - public readItem(eof?: EOF) { - return this.source.at(this.position++) ?? (eof as EOF); - } - - public getPosition(): number { - return this.position; - } - - public setPosition(n: number) { - this.position = n; - } - - public clone(): SuperCoolStream { - return new StandardSuperCoolStream(this.source, this.position); - } - - savingPositionIf(description: { - predicate: (t: Result) => boolean; - body: (stream: SuperCoolStream) => Result; - }): Result { - const previousPosition = this.position; - const bodyResult = description.body(this); - if (description.predicate(bodyResult)) { - this.position = previousPosition; - } - return bodyResult; +export class StandardSuperCoolStream> + implements SuperCoolStream +{ + protected position: number; + /** + * Makes the super cool string stream. + * @param source A string to act as the source of the stream. + * @param start Where in the string we should start reading. + */ + constructor( + public readonly source: Sequence, + start = 0 + ) { + this.position = start; + } + + public peekItem(eof?: EOF): Item | EOF { + return this.source.at(this.position) ?? (eof as EOF); + } + + public readItem(eof?: EOF) { + return this.source.at(this.position++) ?? (eof as EOF); + } + + public getPosition(): number { + return this.position; + } + + public setPosition(n: number) { + this.position = n; + } + + public clone(): SuperCoolStream { + return new StandardSuperCoolStream(this.source, this.position); + } + + savingPositionIf(description: { + predicate: (t: Result) => boolean; + body: (stream: SuperCoolStream) => Result; + }): Result { + const previousPosition = this.position; + const bodyResult = description.body(this); + if (description.predicate(bodyResult)) { + this.position = previousPosition; } + return bodyResult; + } } /** * Helper for peeking and reading character by character. */ export class StringStream extends StandardSuperCoolStream< - string, - Indexable + string, + Indexable > { - public peekChar(eof?: EOF) { - return this.peekItem(eof); - } + public peekChar(eof?: EOF) { + return this.peekItem(eof); + } - public readChar(eof?: EOF) { - return this.readItem(eof); - } + public readChar(eof?: EOF) { + return this.readItem(eof); + } - public clone(): StringStream { - return new StringStream(this.source, this.position); - } + public clone(): StringStream { + return new StringStream(this.source, this.position); + } } /** Whitespace we want to nom. */ -const WHITESPACE = [' ', '\r', '\f', '\v', '\n', '\t']; +const WHITESPACE = [" ", "\r", "\f", "\v", "\n", "\t"]; /** * Transforms a command from a string to a list of `ReadItem`s. @@ -123,21 +133,21 @@ const WHITESPACE = [' ', '\r', '\f', '\v', '\n', '\t']; * @returns ReadItems that have been read from this command. */ export function readCommand(string: string): ReadItem[] { - return readCommandFromStream(new StringStream(string)) + return readCommandFromStream(new StringStream(string)); } function readCommandFromStream(stream: StringStream): ReadItem[] { - const words: ReadItem[] = []; + const words: ReadItem[] = []; + eatWhitespace(stream); + while (stream.peekChar() !== undefined) { + words.push(readItem(stream)); eatWhitespace(stream); - while (stream.peekChar() !== undefined) { - words.push(readItem(stream)); - eatWhitespace(stream); - } - return words.map(applyPostReadTransformersToReadItem); + } + return words.map(applyPostReadTransformersToReadItem); } function eatWhitespace(stream: StringStream): void { - readUntil(/\S/, stream, []); + readUntil(/\S/, stream, []); } /** @@ -146,25 +156,27 @@ function eatWhitespace(stream: StringStream): void { * @returns A single ReadItem. */ function readItem(stream: StringStream): ReadItem { - if (stream.peekChar() === undefined) { - throw new TypeError('EOF'); - } - if (WHITESPACE.includes(stream.peekChar())) { - throw new TypeError('whitespace should have been stripped'); - } - const dispatchCharacter = stream.peekChar(); - if (dispatchCharacter === undefined) { - throw new TypeError(`There should be a dispatch character and if there isn't then the code is wrong`); - } - const macro = WORD_DISPATCH_CHARACTERS.get(dispatchCharacter); - if (macro) { - return macro(stream); - } else { - // Then read a normal word. - const word: string[] = [stream.readChar()]; - readUntil(/\s/, stream, word); - return word.join(''); - } + if (stream.peekChar() === undefined) { + throw new TypeError("EOF"); + } + if (WHITESPACE.includes(stream.peekChar())) { + throw new TypeError("whitespace should have been stripped"); + } + const dispatchCharacter = stream.peekChar(); + if (dispatchCharacter === undefined) { + throw new TypeError( + `There should be a dispatch character and if there isn't then the code is wrong` + ); + } + const macro = WORD_DISPATCH_CHARACTERS.get(dispatchCharacter); + if (macro) { + return macro(stream); + } else { + // Then read a normal word. + const word: string[] = [stream.readChar()]; + readUntil(/\s/, stream, word); + return word.join(""); + } } /** @@ -174,7 +186,12 @@ function readItem(stream: StringStream): ReadItem { type ReadMacro = (stream: StringStream) => ReadItem; const WORD_DISPATCH_CHARACTERS = new Map(); -export type ReadItem = string | MatrixRoomReference | UserID | Keyword | MatrixEventReference; +export type ReadItem = + | string + | MatrixRoomReference + | UserID + | Keyword + | MatrixEventReference; /** * Defines a read macro to produce a read item. @@ -183,17 +200,21 @@ export type ReadItem = string | MatrixRoomReference | UserID | Keyword | MatrixE * @param macro A function that reads a stream and produces a `ReadItem` */ function defineReadItem(dispatchCharacter: string, macro: ReadMacro) { - if (WORD_DISPATCH_CHARACTERS.has(dispatchCharacter)) { - throw new TypeError(`Read macro already defined for this dispatch character: ${dispatchCharacter}`); - } - WORD_DISPATCH_CHARACTERS.set(dispatchCharacter, macro); + if (WORD_DISPATCH_CHARACTERS.has(dispatchCharacter)) { + throw new TypeError( + `Read macro already defined for this dispatch character: ${dispatchCharacter}` + ); + } + WORD_DISPATCH_CHARACTERS.set(dispatchCharacter, macro); } -type PostReadStringReplaceTransformer = (item: string) => ReadItem|string; -type TransformerEntry = { regex: RegExp, transformer: PostReadStringReplaceTransformer }; +type PostReadStringReplaceTransformer = (item: string) => ReadItem | string; +type TransformerEntry = { + regex: RegExp; + transformer: PostReadStringReplaceTransformer; +}; const POST_READ_TRANSFORMERS = new Map(); - /** * Define a function that will be applied to ReadItem's that are strings that * also match the regex. @@ -202,22 +223,27 @@ const POST_READ_TRANSFORMERS = new Map(); * * This is mainly used to transform URLs into a MatrixRoomReference. */ -function definePostReadReplace(regex: RegExp, transformer: PostReadStringReplaceTransformer) { - if (POST_READ_TRANSFORMERS.has(regex.source)) { - throw new TypeError(`A transformer has already been defined for the regexp ${regex.source}`); - } - POST_READ_TRANSFORMERS.set(regex.source, { regex, transformer }) +function definePostReadReplace( + regex: RegExp, + transformer: PostReadStringReplaceTransformer +) { + if (POST_READ_TRANSFORMERS.has(regex.source)) { + throw new TypeError( + `A transformer has already been defined for the regexp ${regex.source}` + ); + } + POST_READ_TRANSFORMERS.set(regex.source, { regex, transformer }); } function applyPostReadTransformersToReadItem(item: ReadItem): ReadItem { - if (typeof item === 'string') { - for (const [, { regex, transformer }] of POST_READ_TRANSFORMERS) { - if (regex.test(item)) { - return transformer(item); - } - } + if (typeof item === "string") { + for (const [, { regex, transformer }] of POST_READ_TRANSFORMERS) { + if (regex.test(item)) { + return transformer(item); + } } - return item; + } + return item; } /** @@ -228,10 +254,10 @@ function applyPostReadTransformersToReadItem(item: ReadItem): ReadItem { * @returns `output`. */ function readUntil(regex: RegExp, stream: StringStream, output: string[]) { - while (stream.peekChar() !== undefined && !regex.test(stream.peekChar())) { - output.push(stream.readChar()); - } - return output; + while (stream.peekChar() !== undefined && !regex.test(stream.peekChar())) { + output.push(stream.readChar()); + } + return output; } /** @@ -240,51 +266,57 @@ function readUntil(regex: RegExp, stream: StringStream, output: string[]) { * @param stream The stream to consume the room reference from. * @returns A MatrixRoomReference or string if what has been read does not represent a room. */ -function readRoomIDOrAlias(stream: StringStream): MatrixRoomReference|string { - const word: string[] = [stream.readChar()]; - readUntil(/[:\s]/, stream, word); - if (stream.peekChar() === undefined || WHITESPACE.includes(stream.peekChar())) { - return word.join(''); - } - readUntil(/\s/, stream, word); - const wholeWord = word.join(''); - if (!isStringRoomID(wholeWord) && !isStringRoomAlias(wholeWord)) { - return wholeWord; - } - return MatrixRoomReference.fromRoomIDOrAlias(wholeWord); +function readRoomIDOrAlias(stream: StringStream): MatrixRoomReference | string { + const word: string[] = [stream.readChar()]; + readUntil(/[:\s]/, stream, word); + if ( + stream.peekChar() === undefined || + WHITESPACE.includes(stream.peekChar()) + ) { + return word.join(""); + } + readUntil(/\s/, stream, word); + const wholeWord = word.join(""); + if (!isStringRoomID(wholeWord) && !isStringRoomAlias(wholeWord)) { + return wholeWord; + } + return MatrixRoomReference.fromRoomIDOrAlias(wholeWord); } /** * Read the word as an alias if it is an alias, otherwise it will just return a string token. */ -defineReadItem('#', readRoomIDOrAlias); -defineReadItem('!', readRoomIDOrAlias); +defineReadItem("#", readRoomIDOrAlias); +defineReadItem("!", readRoomIDOrAlias); /** * Read the word as a UserID, otherwise return a string if what has been read doesn not represent a user. */ -defineReadItem('@', (stream: StringStream): UserID|string => { - const word: string[] = [stream.readChar()]; - readUntil(/[:\s]/, stream, word); - if (stream.peekChar() === undefined || WHITESPACE.includes(stream.peekChar())) { - return word.join(''); - } - readUntil(/\s/, stream, word); - return new UserID(word.join('')); -}) +defineReadItem("@", (stream: StringStream): UserID | string => { + const word: string[] = [stream.readChar()]; + readUntil(/[:\s]/, stream, word); + if ( + stream.peekChar() === undefined || + WHITESPACE.includes(stream.peekChar()) + ) { + return word.join(""); + } + readUntil(/\s/, stream, word); + return new UserID(word.join("")); +}); /** * Used for keyword arguments (also known as "options", but this isn't specific enough as it could mean an optional argument). * For example `--force`. */ export class Keyword { - /** - * Creates a Keyword - * @param designator The designator exluding hyphens. - */ - constructor(public readonly designator: string) { - // nothing to do. - } + /** + * Creates a Keyword + * @param designator The designator exluding hyphens. + */ + constructor(public readonly designator: string) { + // nothing to do. + } } /** @@ -294,40 +326,40 @@ export class Keyword { * @returns A `Keyword` */ function readKeyword(stream: StringStream): Keyword { - readUntil(/[^-:]/, stream, []); - if (stream.peekChar() === undefined) { - return new Keyword(''); - } - const word: string[] = [stream.readChar()] - readUntil(/[\s]/, stream, word) - return new Keyword(word.join('')); + readUntil(/[^-:]/, stream, []); + if (stream.peekChar() === undefined) { + return new Keyword(""); + } + const word: string[] = [stream.readChar()]; + readUntil(/[\s]/, stream, word); + return new Keyword(word.join("")); } -defineReadItem('-', readKeyword); -defineReadItem(':', readKeyword); - -definePostReadReplace(/^https:\/\/matrix\.to/, input => { - const parseResult = Permalinks.parseUrl(input); - if (isError(parseResult)) { - // it's an invalid URI. - return input; +defineReadItem("-", readKeyword); +defineReadItem(":", readKeyword); + +definePostReadReplace(/^https:\/\/matrix\.to/, (input) => { + const parseResult = Permalinks.parseUrl(input); + if (isError(parseResult)) { + // it's an invalid URI. + return input; + } + const url = parseResult.ok; + if (url.eventID !== undefined) { + const eventResult = MatrixEventReference.fromPermalink(input); + if (isError(eventResult)) { + return input; + } else { + return eventResult.ok; } - const url = parseResult.ok; - if (url.eventID !== undefined) { - const eventResult = MatrixEventReference.fromPermalink(input); - if (isError(eventResult)) { - return input; - } else { - return eventResult.ok; - } - } else if (url.userID !== undefined) { - return new UserID(url.userID); + } else if (url.userID !== undefined) { + return new UserID(url.userID); + } else { + const roomResult = MatrixRoomReference.fromPermalink(input); + if (isError(roomResult)) { + return input; } else { - const roomResult = MatrixRoomReference.fromPermalink(input); - if (isError(roomResult)) { - return input; - } else { - return roomResult.ok; - } + return roomResult.ok; } -}) + } +}); diff --git a/src/commands/interface-manager/DeadDocument.ts b/src/commands/interface-manager/DeadDocument.ts index b6ef1618..c61c42b8 100644 --- a/src/commands/interface-manager/DeadDocument.ts +++ b/src/commands/interface-manager/DeadDocument.ts @@ -27,23 +27,23 @@ import { StandardSuperCoolStream } from "./CommandReader"; */ export interface AbstractNode { - readonly parent: DocumentNode|null; - readonly leafNode: boolean; - readonly tag: NodeTag; + readonly parent: DocumentNode | null; + readonly leafNode: boolean; + readonly tag: NodeTag; } export interface DocumentNode extends AbstractNode { - readonly leafNode: false; - attributeMap: Map, - addChild(node: Node): Node - getChildren(): (DocumentNode|LeafNode)[] - getFirstChild(): DocumentNode|LeafNode|undefined; + readonly leafNode: false; + attributeMap: Map; + addChild(node: Node): Node; + getChildren(): (DocumentNode | LeafNode)[]; + getFirstChild(): DocumentNode | LeafNode | undefined; } export interface LeafNode extends AbstractNode { - readonly parent: DocumentNode, - readonly data: string, - readonly leafNode: true, + readonly parent: DocumentNode; + readonly data: string; + readonly leafNode: true; } // TODO: https://www.typescriptlang.org/docs/handbook/jsx.html#intrinsic-elements @@ -52,26 +52,26 @@ export interface LeafNode extends AbstractNode { // These are NOT necessarily HTML tags. export enum NodeTag { - TextNode = 'text', - InlineCode = 'code', - PreformattedText = 'pre', - Root = 'root', - Strong = 'strong', - Emphasis = 'em', - Paragraph = 'p', - HeadingOne = 'h1', - UnorderedList = 'ul', - OrderedList = 'ol', - ListItem = 'li', - LineBreak = 'br', - BoldFace = 'b', - ItalicFace = 'i', - Anchor = 'a', - Fragment = 'fragment', - Details = 'details', - Summary = 'summary', - Font = 'font', - Span = 'span', + TextNode = "text", + InlineCode = "code", + PreformattedText = "pre", + Root = "root", + Strong = "strong", + Emphasis = "em", + Paragraph = "p", + HeadingOne = "h1", + UnorderedList = "ul", + OrderedList = "ol", + ListItem = "li", + LineBreak = "br", + BoldFace = "b", + ItalicFace = "i", + Anchor = "a", + Fragment = "fragment", + Details = "details", + Summary = "summary", + Font = "font", + Span = "span", } /** @@ -80,71 +80,91 @@ export enum NodeTag { * where we can use ad-hoc mixins. */ interface DeadDocumentNode extends DocumentNode { - children: (DocumentNode|LeafNode)[]; - attributeMap: Map, + children: (DocumentNode | LeafNode)[]; + attributeMap: Map; } -export function addChild(this: DeadDocumentNode, node: Node): Node { - if (this.children.includes(node)) { - return node; - } - this.children.push(node); +export function addChild( + this: DeadDocumentNode, + node: Node +): Node { + if (this.children.includes(node)) { return node; + } + this.children.push(node); + return node; } -export function getChildren(this: DeadDocumentNode): (DocumentNode|LeafNode)[] { - return [...this.children]; +export function getChildren( + this: DeadDocumentNode +): (DocumentNode | LeafNode)[] { + return [...this.children]; } -export function getFirstChild(this: DeadDocumentNode): DocumentNode|LeafNode|undefined { - return this.children.at(0); +export function getFirstChild( + this: DeadDocumentNode +): DocumentNode | LeafNode | undefined { + return this.children.at(0); } export function makeDocumentNode(tag: NodeTag, parent = null): DocumentNode { - const node: DeadDocumentNode = { - tag, - leafNode: false, - parent, - children: [], - attributeMap: new Map(), - addChild, - getChildren, - getFirstChild, - }; - return node; + const node: DeadDocumentNode = { + tag, + leafNode: false, + parent, + children: [], + attributeMap: new Map(), + addChild, + getChildren, + getFirstChild, + }; + return node; } - -export function makeLeafNode(tag: LeafInterface['tag'], parent: DocumentNode, data: string): LeafInterface { - const leaf = { tag, parent, data, leafNode: true } as LeafInterface; - parent.addChild(leaf); - return leaf; +export function makeLeafNode( + tag: LeafInterface["tag"], + parent: DocumentNode, + data: string +): LeafInterface { + const leaf = { tag, parent, data, leafNode: true } as LeafInterface; + parent.addChild(leaf); + return leaf; } export interface TextNode extends LeafNode { - readonly tag: NodeTag.TextNode; + readonly tag: NodeTag.TextNode; } // lol no mixins so addChild can't be protected on DocumentNode // it's a yoke. export function addText(this: DocumentNode, data: string): TextNode { - return this.addChild(makeLeafNode(NodeTag.TextNode, this, data)) + return this.addChild(makeLeafNode(NodeTag.TextNode, this, data)); } export interface InlineCodeNode extends LeafNode { - readonly tag: NodeTag.InlineCode; + readonly tag: NodeTag.InlineCode; } -export function addInlineCode(this: DocumentNode, data: string): InlineCodeNode { - return this.addChild(makeLeafNode(NodeTag.InlineCode, this, data)); +export function addInlineCode( + this: DocumentNode, + data: string +): InlineCodeNode { + return this.addChild( + makeLeafNode(NodeTag.InlineCode, this, data) + ); } export interface PreformattedTextNode extends LeafNode { - readonly tag: NodeTag.PreformattedText; + readonly tag: NodeTag.PreformattedText; } -export function addPreformattedText(this: DocumentNode, data: string): PreformattedTextNode { - return this.addChild(makeLeafNode(NodeTag.PreformattedText, this, data)); +export function addPreformattedText( + this: DocumentNode, + data: string +): PreformattedTextNode { + return this.addChild( + makeLeafNode(NodeTag.PreformattedText, this, data) + ); } /** @@ -152,14 +172,14 @@ export function addPreformattedText(this: DocumentNode, data: string): Preformat */ export enum FringeType { - Pre = 'pre', - Leaf = 'leaf', - Post = 'post', + Pre = "pre", + Leaf = "leaf", + Post = "post", } type AnnotatedFringeNode = { - type: FringeType, - node: DocumentNode|LeafNode + type: FringeType; + node: DocumentNode | LeafNode; }; type Flat = AnnotatedFringeNode[]; @@ -169,104 +189,158 @@ type Flat = AnnotatedFringeNode[]; // So long as no one uses `Flat` directly and there's some inversion of control // then we'll be fine. function fringeInternalNode(node: DocumentNode, flat: Flat): Flat { - if (node.getChildren().length === 0) { - return flat; - } else { - return node.getChildren().reduce((previous: Flat, child: DocumentNode|LeafNode) => { - return fringe(child, previous); - }, flat); - } + if (node.getChildren().length === 0) { + return flat; + } else { + return node + .getChildren() + .reduce((previous: Flat, child: DocumentNode | LeafNode) => { + return fringe(child, previous); + }, flat); + } } -function fringe(node: DocumentNode|LeafNode, flat: Flat = []): Flat { - if (node.leafNode) { - flat.push({ type: FringeType.Leaf, node }); - return flat; - } else { - flat.push({ type: FringeType.Pre, node }); - flat = fringeInternalNode(node, flat); - flat.push({ type: FringeType.Post, node }); - return flat; - } +function fringe(node: DocumentNode | LeafNode, flat: Flat = []): Flat { + if (node.leafNode) { + flat.push({ type: FringeType.Leaf, node }); + return flat; + } else { + flat.push({ type: FringeType.Pre, node }); + flat = fringeInternalNode(node, flat); + flat.push({ type: FringeType.Post, node }); + return flat; + } } -export type FringeLeafRenderFunction = (tag: NodeTag, node: LeafNode, context: Context) => void -export type FringeInnerRenderFunction = (type: FringeType, node: DocumentNode, context: Context, environment: TagDynamicEnvironment) => void; +export type FringeLeafRenderFunction = ( + tag: NodeTag, + node: LeafNode, + context: Context +) => void; +export type FringeInnerRenderFunction = ( + type: FringeType, + node: DocumentNode, + context: Context, + environment: TagDynamicEnvironment +) => void; export interface FringeRenderer { - getLeafRenderer(tag: NodeTag): FringeLeafRenderFunction - getPreRenderer(tag: NodeTag): FringeInnerRenderFunction; - getPostRenderer(tag: NodeTag): FringeInnerRenderFunction; + getLeafRenderer(tag: NodeTag): FringeLeafRenderFunction; + getPreRenderer(tag: NodeTag): FringeInnerRenderFunction; + getPostRenderer(tag: NodeTag): FringeInnerRenderFunction; } export class SimpleFringeRenderer implements FringeRenderer { - private readonly preRenderers = new Map>(); - private readonly leafRenderers = new Map>(); - private readonly postRenderers = new Map>(); - - private getRenderer(table: Map, type: FringeType, tag: NodeTag): T { - const entry = table.get(tag); - if (entry) { - return entry; - } - throw new TypeError(`Couldn't find a ${type} renderer for ${tag}`); - } - - public getPreRenderer(tag: NodeTag): FringeInnerRenderFunction { - return this.getRenderer(this.preRenderers, FringeType.Pre, tag); - } - - public getLeafRenderer(tag: NodeTag): FringeLeafRenderFunction { - return this.getRenderer(this.leafRenderers, FringeType.Leaf, tag); - } - - public getPostRenderer(tag: NodeTag): FringeInnerRenderFunction { - return this.getRenderer(this.postRenderers, FringeType.Post, tag); - } - - public internRenderer|FringeLeafRenderFunction>(type: FringeType, tag: NodeTag, table: Map, renderer: T): void { - if (table.has(tag)) { - throw new TypeError(`There is already a renderer registered for ${type} ${tag}`); - } - table.set(tag, renderer); + private readonly preRenderers = new Map< + NodeTag, + FringeInnerRenderFunction + >(); + private readonly leafRenderers = new Map< + NodeTag, + FringeLeafRenderFunction + >(); + private readonly postRenderers = new Map< + NodeTag, + FringeInnerRenderFunction + >(); + + private getRenderer( + table: Map, + type: FringeType, + tag: NodeTag + ): T { + const entry = table.get(tag); + if (entry) { + return entry; } - - public registerRenderer|FringeLeafRenderFunction>(type: FringeType, tag: NodeTag, renderer: T): this { - // The casting in here is evil. Not sure how to fix it. - switch (type) { - case FringeType.Pre: - this.internRenderer(type, tag, this.preRenderers as Map, renderer); - break; - case FringeType.Leaf: - this.internRenderer(type, tag, this.leafRenderers as Map, renderer); - break; - case FringeType.Post: - this.internRenderer(type, tag, this.postRenderers as Map, renderer); - break; - } - return this; + throw new TypeError(`Couldn't find a ${type} renderer for ${tag}`); + } + + public getPreRenderer(tag: NodeTag): FringeInnerRenderFunction { + return this.getRenderer(this.preRenderers, FringeType.Pre, tag); + } + + public getLeafRenderer(tag: NodeTag): FringeLeafRenderFunction { + return this.getRenderer(this.leafRenderers, FringeType.Leaf, tag); + } + + public getPostRenderer(tag: NodeTag): FringeInnerRenderFunction { + return this.getRenderer(this.postRenderers, FringeType.Post, tag); + } + + public internRenderer< + T extends + | FringeInnerRenderFunction + | FringeLeafRenderFunction, + >(type: FringeType, tag: NodeTag, table: Map, renderer: T): void { + if (table.has(tag)) { + throw new TypeError( + `There is already a renderer registered for ${type} ${tag}` + ); } - - public registerInnerNode(tag: NodeTag, pre: FringeInnerRenderFunction, post: FringeInnerRenderFunction): this { - this.internRenderer(FringeType.Pre, tag, this.preRenderers, pre); - this.internRenderer(FringeType.Post, tag, this.postRenderers, post); - return this; + table.set(tag, renderer); + } + + public registerRenderer< + T extends + | FringeInnerRenderFunction + | FringeLeafRenderFunction, + >(type: FringeType, tag: NodeTag, renderer: T): this { + // The casting in here is evil. Not sure how to fix it. + switch (type) { + case FringeType.Pre: + this.internRenderer( + type, + tag, + this.preRenderers as Map, + renderer + ); + break; + case FringeType.Leaf: + this.internRenderer( + type, + tag, + this.leafRenderers as Map, + renderer + ); + break; + case FringeType.Post: + this.internRenderer( + type, + tag, + this.postRenderers as Map, + renderer + ); + break; } + return this; + } + + public registerInnerNode( + tag: NodeTag, + pre: FringeInnerRenderFunction, + post: FringeInnerRenderFunction + ): this { + this.internRenderer(FringeType.Pre, tag, this.preRenderers, pre); + this.internRenderer(FringeType.Post, tag, this.postRenderers, post); + return this; + } } const COMMITTABLE_NODES = new Set([ - NodeTag.HeadingOne, - NodeTag.ListItem, - NodeTag.Paragraph, - NodeTag.PreformattedText, - NodeTag.UnorderedList, - NodeTag.Root, + NodeTag.HeadingOne, + NodeTag.ListItem, + NodeTag.Paragraph, + NodeTag.PreformattedText, + NodeTag.UnorderedList, + NodeTag.Root, ]); -class FringeStream extends StandardSuperCoolStream { - -} +class FringeStream extends StandardSuperCoolStream {} -export type CommitHook = (node: DocumentNode, context: Context) => void; +export type CommitHook = ( + node: DocumentNode, + context: Context +) => void; /** * The FringeWalker allows for the implementation of an incremental @@ -276,79 +350,92 @@ export type CommitHook = (node: DocumentNode, context: Context) => void * @param Context is a static context that should be provided to each render function. */ export class FringeWalker { - private readonly stream: FringeStream; - private readonly dynamicEnvironment = new TagDynamicEnvironment(); - constructor( - public readonly root: DocumentNode, - private readonly context: Context, - private readonly renderer: FringeRenderer, - private readonly commitHook: CommitHook + private readonly stream: FringeStream; + private readonly dynamicEnvironment = new TagDynamicEnvironment(); + constructor( + public readonly root: DocumentNode, + private readonly context: Context, + private readonly renderer: FringeRenderer, + private readonly commitHook: CommitHook + ) { + this.stream = new FringeStream(fringe(root)); + } + + public increment(): DocumentNode | undefined { + const renderInnerNode = (node: AnnotatedFringeNode) => { + if (node.node.leafNode) { + throw new TypeError( + "Leaf nodes should not be in the Pre/Post position" + ); + } + const renderer = + node.type === FringeType.Pre + ? this.renderer.getPreRenderer(node.node.tag) + : this.renderer.getPostRenderer(node.node.tag); + renderer(node.type, node.node, this.context, this.dynamicEnvironment); + return node.node; + }; + const postNode = (node: AnnotatedFringeNode): DocumentNode => { + if (node.node.leafNode) { + throw new TypeError( + "Leaf nodes should not be in the Pre/Post position" + ); + } + renderInnerNode(node); + this.dynamicEnvironment.pop(node.node); + return node.node; + }; + const isAnnotatedNodeCommittable = (node: AnnotatedFringeNode): boolean => { + return ( + COMMITTABLE_NODES.has(node.node.tag) && node.type === FringeType.Post + ); + }; + while ( + this.stream.peekItem() && + !isAnnotatedNodeCommittable(this.stream.peekItem()) ) { - this.stream = new FringeStream(fringe(root)); + const annotatedNode = this.stream.readItem(); + if (annotatedNode === undefined) { + throw new TypeError(`Stream code is wrong`); + } + switch (annotatedNode.type) { + case FringeType.Pre: + renderInnerNode(annotatedNode); + break; + case FringeType.Post: + postNode(annotatedNode); + break; + case FringeType.Leaf: + if (!annotatedNode.node.leafNode) { + throw new TypeError( + "Leaf nodes should not be marked as an inner node" + ); + } + this.renderer.getLeafRenderer(annotatedNode.node.tag)( + annotatedNode.node.tag, + annotatedNode.node as unknown as LeafNode, + this.context + ); + break; + default: + throw new TypeError(`Uknown fringe type ${annotatedNode.type}`); + } } - - public increment(): DocumentNode|undefined { - const renderInnerNode = (node: AnnotatedFringeNode) => { - if (node.node.leafNode) { - throw new TypeError("Leaf nodes should not be in the Pre/Post position"); - } - const renderer = node.type === FringeType.Pre - ? this.renderer.getPreRenderer(node.node.tag) - : this.renderer.getPostRenderer(node.node.tag); - renderer(node.type, node.node, this.context, this.dynamicEnvironment); - return node.node; - } - const postNode = (node: AnnotatedFringeNode): DocumentNode => { - if (node.node.leafNode) { - throw new TypeError("Leaf nodes should not be in the Pre/Post position"); - } - renderInnerNode(node); - this.dynamicEnvironment.pop(node.node); - return node.node; - } - const isAnnotatedNodeCommittable = (node: AnnotatedFringeNode): boolean => { - return COMMITTABLE_NODES.has(node.node.tag) - && node.type === FringeType.Post; - } - while (this.stream.peekItem() && !isAnnotatedNodeCommittable(this.stream.peekItem())) { - const annotatedNode = this.stream.readItem(); - if (annotatedNode === undefined) { - throw new TypeError(`Stream code is wrong`); - } - switch (annotatedNode.type) { - case FringeType.Pre: - renderInnerNode(annotatedNode); - break; - case FringeType.Post: - postNode(annotatedNode); - break; - case FringeType.Leaf: - if (!annotatedNode.node.leafNode) { - throw new TypeError("Leaf nodes should not be marked as an inner node"); - } - this.renderer.getLeafRenderer(annotatedNode.node.tag)(annotatedNode.node.tag, annotatedNode.node as unknown as LeafNode, this.context); - break; - default: - throw new TypeError(`Uknown fringe type ${annotatedNode.type}`); - } - } - if (this.stream.peekItem() === undefined) { - return undefined; - } - const documentNode = postNode(this.stream.readItem()); - this.commitHook(documentNode, this.context); - return documentNode; + if (this.stream.peekItem() === undefined) { + return undefined; } + const documentNode = postNode(this.stream.readItem()); + this.commitHook(documentNode, this.context); + return documentNode; + } } export class TagDynamicEnvironmentEntry { - constructor( - public readonly node: DocumentNode, - public value: unknown, - public readonly previous: undefined|TagDynamicEnvironmentEntry, - ) { - - } + constructor( + public readonly node: DocumentNode, + public value: unknown, + public readonly previous: undefined | TagDynamicEnvironmentEntry + ) {} } /** @@ -370,38 +457,45 @@ export class TagDynamicEnvironmentEntry { * as the restoration of previous values can be handled automatically for us. */ export class TagDynamicEnvironment { - private readonly environments = new Map(); - - public read(variableName: string): T { - const variableEntry = this.environments.get(variableName); - if (variableEntry) { - return variableEntry.value as T; - } else { - throw new TypeError(`The variable ${variableName} is unbound.`); - } - } - - public write(variableName: string, value: T): T { - const variableEntry = this.environments.get(variableName); - if (variableEntry) { - return variableEntry.value = value; - } else { - throw new TypeError(`The variable ${variableName} is unbound.`) - } + private readonly environments = new Map< + string, + TagDynamicEnvironmentEntry | undefined + >(); + + public read(variableName: string): T { + const variableEntry = this.environments.get(variableName); + if (variableEntry) { + return variableEntry.value as T; + } else { + throw new TypeError(`The variable ${variableName} is unbound.`); } + } - public bind(variableName: string, node: DocumentNode, value: T): T { - const entry = this.environments.get(variableName); - const newEntry = new TagDynamicEnvironmentEntry(node, value, entry); - this.environments.set(variableName, newEntry); - return value + public write(variableName: string, value: T): T { + const variableEntry = this.environments.get(variableName); + if (variableEntry) { + return (variableEntry.value = value); + } else { + throw new TypeError(`The variable ${variableName} is unbound.`); } - - public pop(node: DocumentNode): void { - for (const [variableName, environment] of this.environments.entries()) { - if (Object.is(environment?.node, node)) { - this.environments.set(variableName, environment?.previous); - } - } + } + + public bind( + variableName: string, + node: DocumentNode, + value: T + ): T { + const entry = this.environments.get(variableName); + const newEntry = new TagDynamicEnvironmentEntry(node, value, entry); + this.environments.set(variableName, newEntry); + return value; + } + + public pop(node: DocumentNode): void { + for (const [variableName, environment] of this.environments.entries()) { + if (Object.is(environment?.node, node)) { + this.environments.set(variableName, environment?.previous); + } } + } } diff --git a/src/commands/interface-manager/DeadDocumentHtml.ts b/src/commands/interface-manager/DeadDocumentHtml.ts index 1f840c45..2ebeefe2 100644 --- a/src/commands/interface-manager/DeadDocumentHtml.ts +++ b/src/commands/interface-manager/DeadDocumentHtml.ts @@ -4,85 +4,143 @@ */ import { htmlEscape } from "../../utils"; -import { DocumentNode, FringeLeafRenderFunction, FringeType, LeafNode, NodeTag, SimpleFringeRenderer, TagDynamicEnvironment } from "./DeadDocument"; -import { blank, staticString, TransactionalOutputContext } from "./DeadDocumentMarkdown"; +import { + DocumentNode, + FringeLeafRenderFunction, + FringeType, + LeafNode, + NodeTag, + SimpleFringeRenderer, + TagDynamicEnvironment, +} from "./DeadDocument"; +import { + blank, + staticString, + TransactionalOutputContext, +} from "./DeadDocumentMarkdown"; -function writeAttributableNode(tagName: string, _fringe: FringeType, node: DocumentNode, context: TransactionalOutputContext, _environment: TagDynamicEnvironment) { - context.output.writeString(`<${tagName}`); - if (node.attributeMap.size > 0) { - for (const [key, value] of node.attributeMap.entries()) { - context.output.writeString(` ${htmlEscape(key)}="${htmlEscape(value)}"`); - } +function writeAttributableNode( + tagName: string, + _fringe: FringeType, + node: DocumentNode, + context: TransactionalOutputContext, + _environment: TagDynamicEnvironment +) { + context.output.writeString(`<${tagName}`); + if (node.attributeMap.size > 0) { + for (const [key, value] of node.attributeMap.entries()) { + context.output.writeString(` ${htmlEscape(key)}="${htmlEscape(value)}"`); } - context.output.writeString('>') + } + context.output.writeString(">"); } function attributableNode(tagName: string) { - return function(fringe: FringeType, node: DocumentNode, context: TransactionalOutputContext, environment: TagDynamicEnvironment) { - writeAttributableNode(tagName, fringe, node, context, environment); - } + return function ( + fringe: FringeType, + node: DocumentNode, + context: TransactionalOutputContext, + environment: TagDynamicEnvironment + ) { + writeAttributableNode(tagName, fringe, node, context, environment); + }; } +export const HTML_RENDERER = + new SimpleFringeRenderer(); -export const HTML_RENDERER = new SimpleFringeRenderer(); - -HTML_RENDERER.registerRenderer>( - FringeType.Leaf, - NodeTag.TextNode, - function (_tag: NodeTag, node: LeafNode, context: TransactionalOutputContext) { - context.output.writeString(htmlEscape(node.data)); - } -).registerInnerNode(NodeTag.Emphasis, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.HeadingOne, - staticString('

    '), - staticString('

    ') -).registerInnerNode(NodeTag.InlineCode, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.Paragraph, - staticString('

    '), - staticString('

    ') -).registerInnerNode(NodeTag.PreformattedText, - staticString('
    '),
    -    staticString('
    ') -).registerInnerNode(NodeTag.Strong, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.UnorderedList, - staticString('
      '), - staticString('
    ') -).registerInnerNode(NodeTag.OrderedList, - staticString('
      '), - staticString('
    ') -).registerInnerNode(NodeTag.ListItem, - staticString('
  • '), - staticString('
  • ') -).registerInnerNode(NodeTag.LineBreak, - blank, - staticString('
    '), -).registerInnerNode(NodeTag.BoldFace, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.ItalicFace, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.Anchor, - attributableNode('a'), - staticString('') -).registerInnerNode(NodeTag.Font, - attributableNode('font'), - staticString('') -).registerInnerNode(NodeTag.Root, - blank, - blank -).registerInnerNode(NodeTag.Details, - staticString('
    '), - staticString('
    ') -).registerInnerNode(NodeTag.Summary, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.Span, - attributableNode('span'), - staticString('')); +HTML_RENDERER.registerRenderer< + FringeLeafRenderFunction +>( + FringeType.Leaf, + NodeTag.TextNode, + function ( + _tag: NodeTag, + node: LeafNode, + context: TransactionalOutputContext + ) { + context.output.writeString(htmlEscape(node.data)); + } +) + .registerInnerNode( + NodeTag.Emphasis, + staticString(""), + staticString("") + ) + .registerInnerNode( + NodeTag.HeadingOne, + staticString("

    "), + staticString("

    ") + ) + .registerInnerNode( + NodeTag.InlineCode, + staticString(""), + staticString("") + ) + .registerInnerNode( + NodeTag.Paragraph, + staticString("

    "), + staticString("

    ") + ) + .registerInnerNode( + NodeTag.PreformattedText, + staticString("
    "),
    +    staticString("
    ") + ) + .registerInnerNode( + NodeTag.Strong, + staticString(""), + staticString("") + ) + .registerInnerNode( + NodeTag.UnorderedList, + staticString("
      "), + staticString("
    ") + ) + .registerInnerNode( + NodeTag.OrderedList, + staticString("
      "), + staticString("
    ") + ) + .registerInnerNode( + NodeTag.ListItem, + staticString("
  • "), + staticString("
  • ") + ) + .registerInnerNode(NodeTag.LineBreak, blank, staticString("
    ")) + .registerInnerNode( + NodeTag.BoldFace, + staticString(""), + staticString("") + ) + .registerInnerNode( + NodeTag.ItalicFace, + staticString(""), + staticString("") + ) + .registerInnerNode( + NodeTag.Anchor, + attributableNode("a"), + staticString("") + ) + .registerInnerNode( + NodeTag.Font, + attributableNode("font"), + staticString("") + ) + .registerInnerNode(NodeTag.Root, blank, blank) + .registerInnerNode( + NodeTag.Details, + staticString("
    "), + staticString("
    ") + ) + .registerInnerNode( + NodeTag.Summary, + staticString(""), + staticString("") + ) + .registerInnerNode( + NodeTag.Span, + attributableNode("span"), + staticString("") + ); diff --git a/src/commands/interface-manager/DeadDocumentMarkdown.ts b/src/commands/interface-manager/DeadDocumentMarkdown.ts index abb1ed75..6f00a995 100644 --- a/src/commands/interface-manager/DeadDocumentMarkdown.ts +++ b/src/commands/interface-manager/DeadDocumentMarkdown.ts @@ -3,13 +3,22 @@ * All rights reserved. */ -import { DocumentNode, FringeInnerRenderFunction, FringeLeafRenderFunction, FringeType, LeafNode, NodeTag, SimpleFringeRenderer, TagDynamicEnvironment } from "./DeadDocument"; +import { + DocumentNode, + FringeInnerRenderFunction, + FringeLeafRenderFunction, + FringeType, + LeafNode, + NodeTag, + SimpleFringeRenderer, + TagDynamicEnvironment, +} from "./DeadDocument"; import { PagedDuplexStream } from "./PagedDuplexStream"; export enum MarkdownVariables { - IndentationLevel = "indentation level", - ListType = "List Type", - ListItemCount = "List Item Count", + IndentationLevel = "indentation level", + ListType = "List Type", + ListItemCount = "List Item Count", } /** @@ -19,135 +28,169 @@ export enum MarkdownVariables { * other people can still use the fringe walker. */ export interface TransactionalOutputContext { - output: PagedDuplexStream + output: PagedDuplexStream; } -export function staticString(string: string): FringeInnerRenderFunction { - return function(_fringe: FringeType, _node: DocumentNode, context: TransactionalOutputContext) { - context.output.writeString(string) - } +export function staticString( + string: string +): FringeInnerRenderFunction { + return function ( + _fringe: FringeType, + _node: DocumentNode, + context: TransactionalOutputContext + ) { + context.output.writeString(string); + }; } -export function blank() { } -export function incrementDynamicEnvironment(_fringe: FringeType, node: DocumentNode, _context: TransactionalOutputContext, environment: TagDynamicEnvironment) { - const value = (() => { - try { - return environment.read(MarkdownVariables.IndentationLevel); - } catch (_e: unknown) { - return environment.bind(MarkdownVariables.IndentationLevel, node, 0); - } - })(); - if (value) { - if (!Number.isInteger(value)) { - throw new TypeError(`${MarkdownVariables.IndentationLevel} should not have a dynamic environment entry that isn't an integer`); - } - environment.bind(MarkdownVariables.IndentationLevel, node, value + 1); +export function blank() {} +export function incrementDynamicEnvironment( + _fringe: FringeType, + node: DocumentNode, + _context: TransactionalOutputContext, + environment: TagDynamicEnvironment +) { + const value = (() => { + try { + return environment.read( + MarkdownVariables.IndentationLevel + ); + } catch (_e: unknown) { + return environment.bind(MarkdownVariables.IndentationLevel, node, 0); } + })(); + if (value) { + if (!Number.isInteger(value)) { + throw new TypeError( + `${MarkdownVariables.IndentationLevel} should not have a dynamic environment entry that isn't an integer` + ); + } + environment.bind(MarkdownVariables.IndentationLevel, node, value + 1); + } } +export const MARKDOWN_RENDERER = + new SimpleFringeRenderer(); -export const MARKDOWN_RENDERER = new SimpleFringeRenderer(); - - -MARKDOWN_RENDERER.registerRenderer>( - FringeType.Leaf, - NodeTag.TextNode, - function (tag: NodeTag, node: LeafNode, context: TransactionalOutputContext) { - context.output.writeString(node.data); - } -).registerInnerNode(NodeTag.HeadingOne, +MARKDOWN_RENDERER.registerRenderer< + FringeLeafRenderFunction +>( + FringeType.Leaf, + NodeTag.TextNode, + function (tag: NodeTag, node: LeafNode, context: TransactionalOutputContext) { + context.output.writeString(node.data); + } +) + .registerInnerNode( + NodeTag.HeadingOne, function (_fringeType, _node, context: TransactionalOutputContext) { - context.output.writeString('# ') + context.output.writeString("# "); }, - staticString('\n\n'), -).registerInnerNode(NodeTag.Emphasis, - staticString('*'), - staticString('*') -).registerInnerNode(NodeTag.InlineCode, - staticString('`'), - staticString('`') -).registerInnerNode(NodeTag.Paragraph, - blank, - staticString('\n\n') -).registerInnerNode(NodeTag.PreformattedText, - staticString('```\n'), - staticString('```\n') -).registerInnerNode(NodeTag.Strong, - staticString('**'), - staticString('**') -).registerInnerNode(NodeTag.UnorderedList, - function(fringe: FringeType, node: DocumentNode, context: TransactionalOutputContext, environment: TagDynamicEnvironment) { - incrementDynamicEnvironment(fringe, node, context, environment); - environment.bind(MarkdownVariables.ListType, node, NodeTag.UnorderedList); - environment.bind(MarkdownVariables.ListItemCount, node, 0); + staticString("\n\n") + ) + .registerInnerNode(NodeTag.Emphasis, staticString("*"), staticString("*")) + .registerInnerNode(NodeTag.InlineCode, staticString("`"), staticString("`")) + .registerInnerNode(NodeTag.Paragraph, blank, staticString("\n\n")) + .registerInnerNode( + NodeTag.PreformattedText, + staticString("```\n"), + staticString("```\n") + ) + .registerInnerNode(NodeTag.Strong, staticString("**"), staticString("**")) + .registerInnerNode( + NodeTag.UnorderedList, + function ( + fringe: FringeType, + node: DocumentNode, + context: TransactionalOutputContext, + environment: TagDynamicEnvironment + ) { + incrementDynamicEnvironment(fringe, node, context, environment); + environment.bind(MarkdownVariables.ListType, node, NodeTag.UnorderedList); + environment.bind(MarkdownVariables.ListItemCount, node, 0); }, blank -).registerInnerNode(NodeTag.ListItem, - function(_fringe, node, context, environment) { - const indentationLevel: number = (() => { - const value = environment.read(MarkdownVariables.IndentationLevel); - if (!Number.isInteger(value)) { - throw new TypeError(`Cannot render the list ${node.tag} because someone clobbered the dynamic environment, should only have integers. Did you forget to enclose in
      or
        ?`) - } else { - return value; - } - })(); - const listItemCount = (() => { - const currentCount = environment.read(MarkdownVariables.ListItemCount); - if (!Number.isInteger(currentCount)) { - throw new TypeError(`Cannot render the list ${node.tag} because someone clobbered the dynamic environment.`); - } - environment.write(MarkdownVariables.ListItemCount, currentCount + 1); - return currentCount + 1; - })(); - context.output.writeString('\n'); - for (let i = 0; i < indentationLevel; i++) { - context.output.writeString(' '); - } - if (environment.read(MarkdownVariables.ListType) === NodeTag.OrderedList) { - context.output.writeString(` ${listItemCount}. `); + ) + .registerInnerNode( + NodeTag.ListItem, + function (_fringe, node, context, environment) { + const indentationLevel: number = (() => { + const value = environment.read( + MarkdownVariables.IndentationLevel + ); + if (!Number.isInteger(value)) { + throw new TypeError( + `Cannot render the list ${node.tag} because someone clobbered the dynamic environment, should only have integers. Did you forget to enclose in
          or
            ?` + ); } else { - context.output.writeString(' * '); + return value; } + })(); + const listItemCount = (() => { + const currentCount = environment.read( + MarkdownVariables.ListItemCount + ); + if (!Number.isInteger(currentCount)) { + throw new TypeError( + `Cannot render the list ${node.tag} because someone clobbered the dynamic environment.` + ); + } + environment.write(MarkdownVariables.ListItemCount, currentCount + 1); + return currentCount + 1; + })(); + context.output.writeString("\n"); + for (let i = 0; i < indentationLevel; i++) { + context.output.writeString(" "); + } + if ( + environment.read(MarkdownVariables.ListType) === NodeTag.OrderedList + ) { + context.output.writeString(` ${listItemCount}. `); + } else { + context.output.writeString(" * "); + } }, - staticString('\n') -).registerInnerNode(NodeTag.OrderedList, - function(fringe: FringeType, node: DocumentNode, context: TransactionalOutputContext, environment: TagDynamicEnvironment) { - incrementDynamicEnvironment(fringe, node, context, environment); - environment.bind(MarkdownVariables.ListType, node, NodeTag.OrderedList); - environment.bind(MarkdownVariables.ListItemCount, node, 0); + staticString("\n") + ) + .registerInnerNode( + NodeTag.OrderedList, + function ( + fringe: FringeType, + node: DocumentNode, + context: TransactionalOutputContext, + environment: TagDynamicEnvironment + ) { + incrementDynamicEnvironment(fringe, node, context, environment); + environment.bind(MarkdownVariables.ListType, node, NodeTag.OrderedList); + environment.bind(MarkdownVariables.ListItemCount, node, 0); }, blank -).registerInnerNode(NodeTag.LineBreak, - blank, - staticString('\n') -).registerInnerNode(NodeTag.BoldFace, - staticString('**'), - staticString('**') -).registerInnerNode(NodeTag.ItalicFace, - staticString('*'), - staticString('*') -).registerInnerNode(NodeTag.Anchor, - staticString('['), - function(_fringe, node, context, _environment) { - const href = node.attributeMap.get("href"); - if (href === undefined) { - throw new TypeError('Anchor without a href is probably a mistake? well we do not support other uses yet.') - } - context.output.writeString(`](${href})`); + ) + .registerInnerNode(NodeTag.LineBreak, blank, staticString("\n")) + .registerInnerNode(NodeTag.BoldFace, staticString("**"), staticString("**")) + .registerInnerNode(NodeTag.ItalicFace, staticString("*"), staticString("*")) + .registerInnerNode( + NodeTag.Anchor, + staticString("["), + function (_fringe, node, context, _environment) { + const href = node.attributeMap.get("href"); + if (href === undefined) { + throw new TypeError( + "Anchor without a href is probably a mistake? well we do not support other uses yet." + ); + } + context.output.writeString(`](${href})`); } -).registerInnerNode(NodeTag.Root, - blank, - blank -).registerInnerNode(NodeTag.Details, - staticString('
            '), - staticString('
            ') -).registerInnerNode(NodeTag.Summary, - staticString(''), - staticString('') -).registerInnerNode(NodeTag.Font, - blank, - blank -).registerInnerNode(NodeTag.Span, - blank, - blank -); + ) + .registerInnerNode(NodeTag.Root, blank, blank) + .registerInnerNode( + NodeTag.Details, + staticString("
            "), + staticString("
            ") + ) + .registerInnerNode( + NodeTag.Summary, + staticString(""), + staticString("") + ) + .registerInnerNode(NodeTag.Font, blank, blank) + .registerInnerNode(NodeTag.Span, blank, blank); diff --git a/src/commands/interface-manager/DeadDocumentMatrix.ts b/src/commands/interface-manager/DeadDocumentMatrix.ts index f7189348..f9bdea63 100644 --- a/src/commands/interface-manager/DeadDocumentMatrix.ts +++ b/src/commands/interface-manager/DeadDocumentMatrix.ts @@ -4,20 +4,31 @@ */ import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { AbstractNode, DocumentNode, FringeWalker, NodeTag } from "./DeadDocument"; +import { + AbstractNode, + DocumentNode, + FringeWalker, + NodeTag, +} from "./DeadDocument"; import { HTML_RENDERER } from "./DeadDocumentHtml"; import { MARKDOWN_RENDERER } from "./DeadDocumentMarkdown"; import { PagedDuplexStream } from "./PagedDuplexStream"; import { RoomEvent } from "matrix-protection-suite"; -function checkEqual(node1: AbstractNode|undefined, node2: AbstractNode|undefined): true { - if (!Object.is(node1, node2)) { - throw new TypeError('There is an implementation bug in one of the walker') - } - return true; +function checkEqual( + node1: AbstractNode | undefined, + node2: AbstractNode | undefined +): true { + if (!Object.is(node1, node2)) { + throw new TypeError("There is an implementation bug in one of the walker"); + } + return true; } -export type SendMatrixEventCB = (text: string, html: string) => Promise; +export type SendMatrixEventCB = ( + text: string, + html: string +) => Promise; /** * Render the `DocumentNode` to Matrix (in both HTML + Markdown) using the @@ -27,59 +38,79 @@ export type SendMatrixEventCB = (text: string, html: string) => Promise { - const commitHook = (commitNode: DocumentNode, context: { output: PagedDuplexStream }) => { - context.output.commit(commitNode); - }; - if (node.tag !== NodeTag.Root) { - // rendering has to start (and end) with a committable node. - throw new TypeError("Tried to render a node without a root, this will not be committable"); - } - const markdownOutput = new PagedDuplexStream(); - const markdownWalker = new FringeWalker( - node, - { output: markdownOutput }, - MARKDOWN_RENDERER, - commitHook, +export async function renderMatrix( + node: DocumentNode, + cb: SendMatrixEventCB +): Promise { + const commitHook = ( + commitNode: DocumentNode, + context: { output: PagedDuplexStream } + ) => { + context.output.commit(commitNode); + }; + if (node.tag !== NodeTag.Root) { + // rendering has to start (and end) with a committable node. + throw new TypeError( + "Tried to render a node without a root, this will not be committable" ); - const htmlOutput = new PagedDuplexStream(); - const htmlWalker = new FringeWalker( - node, - { output: htmlOutput }, - HTML_RENDERER, - commitHook, - ); - const eventIds: string[] = []; - const outputs = [htmlOutput, markdownOutput]; - let currentMarkdownNode = markdownWalker.increment(); - let currentHtmlNode = htmlWalker.increment(); - checkEqual(currentHtmlNode, currentMarkdownNode); - while (currentHtmlNode !== undefined) { - if (outputs.some(o => o.peekPage())) { - // Make sure that any outputs that have buffered input start a fresh page, - // so that the same committed nodes end up in the same message. - outputs.filter(o => !o.peekPage()).forEach(o => { o.ensureNewPage(); }); - // Send the new pages as an event. - const [nextMakrdownPage, nextHtmlPage] = [markdownOutput.readPage(), htmlOutput.readPage()]; - if (nextMakrdownPage === undefined || nextHtmlPage === undefined) { - throw new TypeError(`The code is wrong!!`); - } - eventIds.push(await cb(nextMakrdownPage, nextHtmlPage)); - } - // prepare next iteration - currentMarkdownNode = markdownWalker.increment(); - currentHtmlNode = htmlWalker.increment(); - checkEqual(currentHtmlNode, currentMarkdownNode); + } + const markdownOutput = new PagedDuplexStream(); + const markdownWalker = new FringeWalker( + node, + { output: markdownOutput }, + MARKDOWN_RENDERER, + commitHook + ); + const htmlOutput = new PagedDuplexStream(); + const htmlWalker = new FringeWalker( + node, + { output: htmlOutput }, + HTML_RENDERER, + commitHook + ); + const eventIds: string[] = []; + const outputs = [htmlOutput, markdownOutput]; + let currentMarkdownNode = markdownWalker.increment(); + let currentHtmlNode = htmlWalker.increment(); + checkEqual(currentHtmlNode, currentMarkdownNode); + while (currentHtmlNode !== undefined) { + if (outputs.some((o) => o.peekPage())) { + // Make sure that any outputs that have buffered input start a fresh page, + // so that the same committed nodes end up in the same message. + outputs + .filter((o) => !o.peekPage()) + .forEach((o) => { + o.ensureNewPage(); + }); + // Send the new pages as an event. + const [nextMakrdownPage, nextHtmlPage] = [ + markdownOutput.readPage(), + htmlOutput.readPage(), + ]; + if (nextMakrdownPage === undefined || nextHtmlPage === undefined) { + throw new TypeError(`The code is wrong!!`); + } + eventIds.push(await cb(nextMakrdownPage, nextHtmlPage)); } - outputs.forEach(o => { o.ensureNewPage(); }); - if (outputs.some(o => o.peekPage())) { - const [nextMakrdownPage, nextHtmlPage] = [markdownOutput.readPage(), htmlOutput.readPage()]; - if (nextMakrdownPage === undefined || nextHtmlPage === undefined) { - throw new TypeError(`The code is wrong!!`); - } - eventIds.push(await cb(nextMakrdownPage, nextHtmlPage)); + // prepare next iteration + currentMarkdownNode = markdownWalker.increment(); + currentHtmlNode = htmlWalker.increment(); + checkEqual(currentHtmlNode, currentMarkdownNode); + } + outputs.forEach((o) => { + o.ensureNewPage(); + }); + if (outputs.some((o) => o.peekPage())) { + const [nextMakrdownPage, nextHtmlPage] = [ + markdownOutput.readPage(), + htmlOutput.readPage(), + ]; + if (nextMakrdownPage === undefined || nextHtmlPage === undefined) { + throw new TypeError(`The code is wrong!!`); } - return eventIds; + eventIds.push(await cb(nextMakrdownPage, nextHtmlPage)); + } + return eventIds; } /** @@ -89,45 +120,56 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB): P * @param event An event to reply to, if any. * @param client A MatrixClient to send the events with. */ -export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: RoomEvent | undefined, client: MatrixSendClient, additionalContent = {}): Promise { - const baseContent = (text: string, html: string) => { - return { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - } - }; - const renderInitialReply = async (text: string, html: string) => { - return await client.sendMessage(roomId, { - ...baseContent(text, html), - ...additionalContent, - ...event === undefined - ? {} // if they don't supply a reply just send a top level event. - : { "m.relates_to": { - "m.in_reply_to": { - "event_id": event['event_id'] - } - } - } - }) +export async function renderMatrixAndSend( + node: DocumentNode, + roomId: string, + event: RoomEvent | undefined, + client: MatrixSendClient, + additionalContent = {} +): Promise { + const baseContent = (text: string, html: string) => { + return { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, }; - const renderThreadReply = async (eventId: string, text: string, html: string) => { - return await client.sendMessage(roomId, { - ...baseContent(text, html), + }; + const renderInitialReply = async (text: string, html: string) => { + return await client.sendMessage(roomId, { + ...baseContent(text, html), + ...additionalContent, + ...(event === undefined + ? {} // if they don't supply a reply just send a top level event. + : { "m.relates_to": { - "rel_type": "m.thread", - "event_id": eventId, - } - }) - }; - let initialReplyId: string | undefined = undefined; - return await renderMatrix(node, async (text: string, html: string) => { - if (initialReplyId === undefined) { - initialReplyId = await renderInitialReply(text, html); - return initialReplyId; - } else { - return await renderThreadReply(initialReplyId, text, html); - } + "m.in_reply_to": { + event_id: event["event_id"], + }, + }, + }), }); + }; + const renderThreadReply = async ( + eventId: string, + text: string, + html: string + ) => { + return await client.sendMessage(roomId, { + ...baseContent(text, html), + "m.relates_to": { + rel_type: "m.thread", + event_id: eventId, + }, + }); + }; + let initialReplyId: string | undefined = undefined; + return await renderMatrix(node, async (text: string, html: string) => { + if (initialReplyId === undefined) { + initialReplyId = await renderInitialReply(text, html); + return initialReplyId; + } else { + return await renderThreadReply(initialReplyId, text, html); + } + }); } diff --git a/src/commands/interface-manager/DeadDocumentPresentation.ts b/src/commands/interface-manager/DeadDocumentPresentation.ts index bd9d10a1..7846d2a6 100644 --- a/src/commands/interface-manager/DeadDocumentPresentation.ts +++ b/src/commands/interface-manager/DeadDocumentPresentation.ts @@ -4,43 +4,61 @@ */ import { MatrixRoomAlias, MatrixRoomID } from "matrix-protection-suite"; -import { DocumentNode } from "./DeadDocument" -import { PresentationType, findPresentationType, presentationTypeOf } from "./ParameterParsing"; +import { DocumentNode } from "./DeadDocument"; +import { + PresentationType, + findPresentationType, + presentationTypeOf, +} from "./ParameterParsing"; type PresentationRenderer = (presentation: unknown) => DocumentNode; -const PRESENTATION_RENDERERS = new Map(); +const PRESENTATION_RENDERERS = new Map< + PresentationType, + PresentationRenderer +>(); // don't forget that this is the equivalent of present and define-presentation-method. -export function definePresentationRenderer(presentationType: PresentationType, renderer: PresentationRenderer): void { - if (PRESENTATION_RENDERERS.has(presentationType)) { - throw new TypeError(`A DeadDocument renderer is already defined for the presentation type ${presentationType.name}`); - } else { - PRESENTATION_RENDERERS.set(presentationType, renderer); - } +export function definePresentationRenderer( + presentationType: PresentationType, + renderer: PresentationRenderer +): void { + if (PRESENTATION_RENDERERS.has(presentationType)) { + throw new TypeError( + `A DeadDocument renderer is already defined for the presentation type ${presentationType.name}` + ); + } else { + PRESENTATION_RENDERERS.set(presentationType, renderer); + } } -export function findPresentationRenderer(presentationType: PresentationType): PresentationRenderer { - const entry = PRESENTATION_RENDERERS.get(presentationType); - if (entry === undefined) { - throw new TypeError(`There is no presentation renderer defined for the presentation type ${presentationType.name}`); - } - return entry; +export function findPresentationRenderer( + presentationType: PresentationType +): PresentationRenderer { + const entry = PRESENTATION_RENDERERS.get(presentationType); + if (entry === undefined) { + throw new TypeError( + `There is no presentation renderer defined for the presentation type ${presentationType.name}` + ); + } + return entry; } export const DeadDocumentPresentationMirror = Object.freeze({ - present(object: unknown): DocumentNode { - if (object instanceof MatrixRoomID || object instanceof MatrixRoomAlias) { - return findPresentationRenderer(findPresentationType('MatrixRoomReference'))(object) - } else { - const presentationType = presentationTypeOf(object); - if (presentationType !== undefined) { - const renderer = findPresentationRenderer(presentationType); - return renderer(object); - } else { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new TypeError(`Unable to present: ${object}`); - } - } + present(object: unknown): DocumentNode { + if (object instanceof MatrixRoomID || object instanceof MatrixRoomAlias) { + return findPresentationRenderer( + findPresentationType("MatrixRoomReference") + )(object); + } else { + const presentationType = presentationTypeOf(object); + if (presentationType !== undefined) { + const renderer = findPresentationRenderer(presentationType); + return renderer(object); + } else { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new TypeError(`Unable to present: ${object}`); + } } -}) + }, +}); diff --git a/src/commands/interface-manager/InterfaceCommand.ts b/src/commands/interface-manager/InterfaceCommand.ts index 3fd8fbdc..07430f1c 100644 --- a/src/commands/interface-manager/InterfaceCommand.ts +++ b/src/commands/interface-manager/InterfaceCommand.ts @@ -30,7 +30,13 @@ limitations under the License. */ import { ActionResult, isError } from "matrix-protection-suite"; -import { ParameterParser, IArgumentStream, IArgumentListParser, ParsedKeywords, ArgumentStream } from "./ParameterParsing"; +import { + ParameterParser, + IArgumentStream, + IArgumentListParser, + ParsedKeywords, + ArgumentStream, +} from "./ParameterParsing"; /** * 💀 . o O ( at least I don't have to remember the types ) @@ -38,178 +44,220 @@ import { ParameterParser, IArgumentStream, IArgumentListParser, ParsedKeywords, * Probably am "doing something wrong", and no, trying to make this protocol isn't it. */ -export type BaseFunction = (keywords: ParsedKeywords, ...args: unknown[]) => Promise>; +export type BaseFunction = ( + keywords: ParsedKeywords, + ...args: unknown[] +) => Promise>; type CommandLookupEntry = { - next?: Map>, - current?: InterfaceCommand + next?: Map>; + current?: InterfaceCommand; }; export class CommandTable { - private readonly flattenedCommands = new Set(); - private readonly commands: CommandLookupEntry = { }; - /** Imported tables are tables that "add commands" to this table. They are not sub commands. */ - private readonly importedTables = new Set(); + private readonly flattenedCommands = new Set(); + private readonly commands: CommandLookupEntry = {}; + /** Imported tables are tables that "add commands" to this table. They are not sub commands. */ + private readonly importedTables = new Set(); - constructor(public readonly name: string|symbol) { + constructor(public readonly name: string | symbol) {} - } + /** + * Used to render the help command. + * @returns All of the commands in this table. + */ + public getAllCommands(): InterfaceCommand[] { + const importedCommands = [...this.importedTables].reduce( + (acc, t) => [...acc, ...t.getAllCommands()], + [] + ); + return [...this.getExportedCommands(), ...importedCommands]; + } - /** - * Used to render the help command. - * @returns All of the commands in this table. - */ - public getAllCommands(): InterfaceCommand[] { - const importedCommands = [...this.importedTables].reduce((acc, t) => [...acc, ...t.getAllCommands()], []); - return [...this.getExportedCommands(), ...importedCommands] - } + /** + * @returns Only the commands interned in this table, excludes imported commands. + */ + public getExportedCommands(): InterfaceCommand[] { + return [...this.flattenedCommands.values()]; + } - /** - * @returns Only the commands interned in this table, excludes imported commands. - */ - public getExportedCommands(): InterfaceCommand[] { - return [...this.flattenedCommands.values()]; - } + public getImportedTables(): CommandTable[] { + return [...this.importedTables]; + } - public getImportedTables(): CommandTable[] { - return [...this.importedTables]; - } + // We use the argument stream so that they can use stream.rest() to get the unconsumed arguments. + public findAnExportedMatchingCommand(stream: IArgumentStream) { + const tableHelper = ( + table: CommandLookupEntry, + argumentStream: IArgumentStream + ): undefined | InterfaceCommand => { + if ( + argumentStream.peekItem() === undefined || + typeof argumentStream.peekItem() !== "string" + ) { + // Then they might be using something like "!mjolnir status" + return table.current; + } + const entry = table.next?.get(argumentStream.readItem() as string); + if (!entry) { + // The reason there's no match is because this is the command arguments, rather than subcommand notation. + return table.current; + } else { + return tableHelper(entry, argumentStream); + } + }; + return tableHelper(this.commands, stream); + } - // We use the argument stream so that they can use stream.rest() to get the unconsumed arguments. - public findAnExportedMatchingCommand(stream: IArgumentStream) { - const tableHelper = (table: CommandLookupEntry, argumentStream: IArgumentStream): undefined|InterfaceCommand => { - if (argumentStream.peekItem() === undefined || typeof argumentStream.peekItem() !== 'string') { - // Then they might be using something like "!mjolnir status" - return table.current; - } - const entry = table.next?.get(argumentStream.readItem() as string); - if (!entry) { - // The reason there's no match is because this is the command arguments, rather than subcommand notation. - return table.current; - } else { - return tableHelper(entry, argumentStream); - } - }; - return tableHelper(this.commands, stream); + public findAMatchingCommand( + stream: IArgumentStream + ): InterfaceCommand | undefined { + const possibleExportedCommand = stream.savingPositionIf({ + body: (s: IArgumentStream) => this.findAnExportedMatchingCommand(s), + predicate: (command) => command === undefined, + }); + if (possibleExportedCommand) { + return possibleExportedCommand; } - - public findAMatchingCommand(stream: IArgumentStream): InterfaceCommand|undefined { - const possibleExportedCommand = stream.savingPositionIf({ - body: (s: IArgumentStream) => this.findAnExportedMatchingCommand(s), - predicate: command => command === undefined, + for (const table of this.importedTables.values()) { + const possibleCommand: InterfaceCommand | undefined = + stream.savingPositionIf({ + body: (s: IArgumentStream) => table.findAMatchingCommand(s), + predicate: (command) => command === undefined, }); - if (possibleExportedCommand) { - return possibleExportedCommand; - } - for (const table of this.importedTables.values()) { - const possibleCommand: InterfaceCommand|undefined = stream.savingPositionIf({ - body: (s: IArgumentStream) => table.findAMatchingCommand(s), - predicate: command => command === undefined, - }); - if (possibleCommand) { - return possibleCommand; - } - } - return undefined; + if (possibleCommand) { + return possibleCommand; + } } + return undefined; + } - public internCommand(command: InterfaceCommand) { - const internCommandHelper = (table: CommandLookupEntry, designator: string[]): void => { - const currentDesignator = designator.shift(); - if (currentDesignator === undefined) { - if (table.current) { - throw new TypeError(`There is already a command for ${JSON.stringify(designator)}`) - } - table.current = command; - this.flattenedCommands.add(command); - } else { - if (table.next === undefined) { - table.next = new Map(); - } - const nextLookupEntry = table.next.get(currentDesignator) - ?? ((lookup: CommandLookupEntry) => (table.next.set(currentDesignator, lookup), lookup))({}); - internCommandHelper(nextLookupEntry, designator); - } + public internCommand(command: InterfaceCommand) { + const internCommandHelper = ( + table: CommandLookupEntry, + designator: string[] + ): void => { + const currentDesignator = designator.shift(); + if (currentDesignator === undefined) { + if (table.current) { + throw new TypeError( + `There is already a command for ${JSON.stringify(designator)}` + ); } + table.current = command; + this.flattenedCommands.add(command); + } else { + if (table.next === undefined) { + table.next = new Map(); + } + const nextLookupEntry = + table.next.get(currentDesignator) ?? + ((lookup: CommandLookupEntry) => ( + table.next.set(currentDesignator, lookup), lookup + ))({}); + internCommandHelper(nextLookupEntry, designator); + } + }; - internCommandHelper(this.commands, [...command.designator]); - } + internCommandHelper(this.commands, [...command.designator]); + } - public importTable(table: CommandTable): void { - for (const command of table.getAllCommands()) { - if (this.findAMatchingCommand(new ArgumentStream(command.designator))) { - throw new TypeError(`Command ${JSON.stringify(command.designator)} is in conflict with this table and cannot be imported.`); - } - } - this.importedTables.add(table); + public importTable(table: CommandTable): void { + for (const command of table.getAllCommands()) { + if (this.findAMatchingCommand(new ArgumentStream(command.designator))) { + throw new TypeError( + `Command ${JSON.stringify(command.designator)} is in conflict with this table and cannot be imported.` + ); + } } + this.importedTables.add(table); + } } -const COMMAND_TABLE_TABLE = new Map(); -export function defineCommandTable(name: string|symbol): CommandTable { - if (COMMAND_TABLE_TABLE.has(name)) { - throw new TypeError(`A table called ${name.toString()} already exists`); - } - const table = new CommandTable(name); - COMMAND_TABLE_TABLE.set(name, table); - return table; +const COMMAND_TABLE_TABLE = new Map(); +export function defineCommandTable(name: string | symbol): CommandTable { + if (COMMAND_TABLE_TABLE.has(name)) { + throw new TypeError(`A table called ${name.toString()} already exists`); + } + const table = new CommandTable(name); + COMMAND_TABLE_TABLE.set(name, table); + return table; } -export function findCommandTable(name: string|symbol): CommandTable { - const entry = COMMAND_TABLE_TABLE.get(name); - if (!entry) { - throw new TypeError(`Couldn't find a table called ${name.toString()}`); - } - return entry as CommandTable; +export function findCommandTable( + name: string | symbol +): CommandTable { + const entry = COMMAND_TABLE_TABLE.get(name); + if (!entry) { + throw new TypeError(`Couldn't find a table called ${name.toString()}`); + } + return entry as CommandTable; } /** * Used to find a table command at the internal DSL level, not as a client for commands. */ -export function findTableCommand(tableName: string|symbol, ...designator: string[]): InterfaceCommand { - const table = findCommandTable(tableName); - const command = table.findAMatchingCommand(new ArgumentStream(designator)); - if (command === undefined || !designator.every(part => command.designator.includes(part))) { - throw new TypeError(`Could not find a table command in the table ${tableName.toString()} with the designator ${JSON.stringify(designator)}`) - } - return command as InterfaceCommand; +export function findTableCommand( + tableName: string | symbol, + ...designator: string[] +): InterfaceCommand { + const table = findCommandTable(tableName); + const command = table.findAMatchingCommand(new ArgumentStream(designator)); + if ( + command === undefined || + !designator.every((part) => command.designator.includes(part)) + ) { + throw new TypeError( + `Could not find a table command in the table ${tableName.toString()} with the designator ${JSON.stringify(designator)}` + ); + } + return command as InterfaceCommand; } -export class InterfaceCommand { - constructor( - public readonly argumentListParser: IArgumentListParser, - private readonly command: ExecutorType, - public readonly designator: string[], - /** A short one line summary of what the command does to display alongside it's help */ - public readonly summary: string, - /** A longer description that goes into detail. */ - public readonly description?: string, - ) { - } +export class InterfaceCommand< + ExecutorType extends BaseFunction = BaseFunction, +> { + constructor( + public readonly argumentListParser: IArgumentListParser, + private readonly command: ExecutorType, + public readonly designator: string[], + /** A short one line summary of what the command does to display alongside it's help */ + public readonly summary: string, + /** A longer description that goes into detail. */ + public readonly description?: string + ) {} - // Really, surely this should be part of invoke? - // probably... it's just that means that invoke has to return the validation result lol. - // Though this makes no sense if parsing is part of finding a matching command. - private async parseArguments(stream: IArgumentStream): ReturnType { - return await this.argumentListParser.parse(stream); - } + // Really, surely this should be part of invoke? + // probably... it's just that means that invoke has to return the validation result lol. + // Though this makes no sense if parsing is part of finding a matching command. + private async parseArguments( + stream: IArgumentStream + ): ReturnType { + return await this.argumentListParser.parse(stream); + } - public invoke(context: ThisParameterType, ...args: Parameters): ReturnType { - return this.command.apply(context, args); - } + public invoke( + context: ThisParameterType, + ...args: Parameters + ): ReturnType { + return this.command.apply(context, args); + } - public async parseThenInvoke(context: ThisParameterType, stream: IArgumentStream): Promise> { - const ParsedArguments = await this.parseArguments(stream); - if (isError(ParsedArguments)) { - // The inner type is irrelevant when it is Err, i don't know how to encode this in TS's type system but whatever. - return ParsedArguments; - } - return await this.command.apply(context, [ - ParsedArguments.ok.keywords, - ...ParsedArguments.ok.immediateArguments, - ...ParsedArguments.ok.rest ?? [] - ]); + public async parseThenInvoke( + context: ThisParameterType, + stream: IArgumentStream + ): Promise> { + const ParsedArguments = await this.parseArguments(stream); + if (isError(ParsedArguments)) { + // The inner type is irrelevant when it is Err, i don't know how to encode this in TS's type system but whatever. + return ParsedArguments; } + return await this.command.apply(context, [ + ParsedArguments.ok.keywords, + ...ParsedArguments.ok.immediateArguments, + ...(ParsedArguments.ok.rest ?? []), + ]); + } } // Shouldn't there be a callback interface. @@ -218,22 +266,24 @@ export class InterfaceCommand // has been cleared or there was an error applying a ban to that room. // There could be a description in defineInterfaceComomand // for what each callback is and does for the adaptors to hook into. -export function defineInterfaceCommand(description: { - parameters: IArgumentListParser, - table: string|symbol, - command: ExecutorType, - designator: string[], - summary: string, - description?: string, +export function defineInterfaceCommand< + ExecutorType extends BaseFunction, +>(description: { + parameters: IArgumentListParser; + table: string | symbol; + command: ExecutorType; + designator: string[]; + summary: string; + description?: string; }) { - const command = new InterfaceCommand( - description.parameters, - description.command, - description.designator, - description.summary, - description.description, - ); - const table = findCommandTable(description.table); - table.internCommand(command); - return command; + const command = new InterfaceCommand( + description.parameters, + description.command, + description.designator, + description.summary, + description.description + ); + const table = findCommandTable(description.table); + table.internCommand(command); + return command; } diff --git a/src/commands/interface-manager/JSXFactory.ts b/src/commands/interface-manager/JSXFactory.ts index d9f36fcc..6040adf7 100644 --- a/src/commands/interface-manager/JSXFactory.ts +++ b/src/commands/interface-manager/JSXFactory.ts @@ -3,10 +3,17 @@ * All rights reserved. */ -import { DocumentNode, LeafNode, makeDocumentNode, makeLeafNode, NodeTag, TextNode } from "./DeadDocument"; +import { + DocumentNode, + LeafNode, + makeDocumentNode, + makeLeafNode, + NodeTag, + TextNode, +} from "./DeadDocument"; import { DeadDocumentPresentationMirror } from "./DeadDocumentPresentation"; -type JSXChild = DocumentNode|LeafNode|string|number|JSXChild[]; +type JSXChild = DocumentNode | LeafNode | string | number | JSXChild[]; // TODO: For `children?` to work without allowing people to accidentally use // `{undefined}` then we need to enable: // https://www.typescriptlang.org/tsconfig/#exactOptionalPropertyTypes @@ -14,70 +21,76 @@ type JSXChild = DocumentNode|LeafNode|string|number|JSXChild[]; // this is really annoying AaaAaaaAaaAaaAAaaAaa. // To enable exactOptionalPropertyTypes, we'll have to start with MPS // or extract DeadDocument into a library. -type NodeProperties = { children?: JSXChild[]|JSXChild }; +type NodeProperties = { children?: JSXChild[] | JSXChild }; type LeafNodeProperties = { children?: never[] }; // We need to use a namespace here for the JSXFactory, at least i think. // eslint-disable-next-line @typescript-eslint/no-namespace export namespace DeadDocumentJSX { - export function JSXFactory(tag: NodeTag, properties: unknown, ...rawChildren: (DocumentNode|LeafNode|string)[]) { - const node = makeDocumentNode(tag); - if (properties) { - for (const [key, value] of Object.entries(properties)) { - node.attributeMap.set(key, value); - } - } - const ensureChild = (rawChild: JSXChild) => { - if (typeof rawChild === 'string') { - makeLeafNode(NodeTag.TextNode, node, rawChild); - } else if (typeof rawChild === 'number') { - makeLeafNode(NodeTag.TextNode, node, (rawChild).toString()); - } else if (Array.isArray(rawChild)) { - rawChild.forEach(ensureChild); - // Then it's a DocumentNode|LeafNode - } else if (typeof rawChild.leafNode === 'boolean') { - if (rawChild.tag === NodeTag.Fragment) { - (rawChild as DocumentNode).getChildren().forEach(node.addChild.bind(node)); - } else { - node.addChild(rawChild); - } - } else { - node.addChild(DeadDocumentPresentationMirror.present(rawChild)); - } - } - rawChildren.forEach(ensureChild); - return node; + export function JSXFactory( + tag: NodeTag, + properties: unknown, + ...rawChildren: (DocumentNode | LeafNode | string)[] + ) { + const node = makeDocumentNode(tag); + if (properties) { + for (const [key, value] of Object.entries(properties)) { + node.attributeMap.set(key, value); + } } - // eslint-disable-next-line @typescript-eslint/no-namespace - export namespace JSX { - export interface IntrinsicElements { - a: NodeProperties & { href?: string, name?: string, target?: string }, - b: NodeProperties, - br: LeafNodeProperties, - code: NodeProperties & { class?: string }, - details: NodeProperties, - em: NodeProperties, - font: NodeProperties & { color?: string}, - fragment: NodeProperties, - h1: NodeProperties, - i: NodeProperties, - li: NodeProperties, - ol: NodeProperties & { 'start'?: number }, - p: NodeProperties, - pre: NodeProperties, - root: NodeProperties, - span: NodeProperties & { - 'data-mx-bg-color'?: string, - 'data-mx-color'?: string, - 'data-mx-spoiler'?: string | undefined - } - strong: NodeProperties, - summary: NodeProperties, - ul: NodeProperties, - } - export type Element = DocumentNode - export type ElementChildrenAttribute = { - children?: JSXChild[]|JSXChild|never[] + const ensureChild = (rawChild: JSXChild) => { + if (typeof rawChild === "string") { + makeLeafNode(NodeTag.TextNode, node, rawChild); + } else if (typeof rawChild === "number") { + makeLeafNode(NodeTag.TextNode, node, rawChild.toString()); + } else if (Array.isArray(rawChild)) { + rawChild.forEach(ensureChild); + // Then it's a DocumentNode|LeafNode + } else if (typeof rawChild.leafNode === "boolean") { + if (rawChild.tag === NodeTag.Fragment) { + (rawChild as DocumentNode) + .getChildren() + .forEach(node.addChild.bind(node)); + } else { + node.addChild(rawChild); } + } else { + node.addChild(DeadDocumentPresentationMirror.present(rawChild)); + } + }; + rawChildren.forEach(ensureChild); + return node; + } + // eslint-disable-next-line @typescript-eslint/no-namespace + export namespace JSX { + export interface IntrinsicElements { + a: NodeProperties & { href?: string; name?: string; target?: string }; + b: NodeProperties; + br: LeafNodeProperties; + code: NodeProperties & { class?: string }; + details: NodeProperties; + em: NodeProperties; + font: NodeProperties & { color?: string }; + fragment: NodeProperties; + h1: NodeProperties; + i: NodeProperties; + li: NodeProperties; + ol: NodeProperties & { start?: number }; + p: NodeProperties; + pre: NodeProperties; + root: NodeProperties; + span: NodeProperties & { + "data-mx-bg-color"?: string; + "data-mx-color"?: string; + "data-mx-spoiler"?: string | undefined; + }; + strong: NodeProperties; + summary: NodeProperties; + ul: NodeProperties; } + export type Element = DocumentNode; + export type ElementChildrenAttribute = { + children?: JSXChild[] | JSXChild | never[]; + }; + } } diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 7608b2b7..39a37452 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -2,208 +2,325 @@ * Copyright (C) 2022 Gnuxie */ -import { BaseFunction, CommandTable, InterfaceCommand } from "./InterfaceCommand"; -import { MatrixContext, MatrixInterfaceAdaptor, RendererSignature } from "./MatrixInterfaceAdaptor"; -import { ArgumentParseError, ParameterDescription, RestDescription } from "./ParameterParsing"; +import { + BaseFunction, + CommandTable, + InterfaceCommand, +} from "./InterfaceCommand"; +import { + MatrixContext, + MatrixInterfaceAdaptor, + RendererSignature, +} from "./MatrixInterfaceAdaptor"; +import { + ArgumentParseError, + ParameterDescription, + RestDescription, +} from "./ParameterParsing"; import { DeadDocumentJSX } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { LogService } from "matrix-bot-sdk"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionError, ActionException, ActionExceptionKind, ActionResult, MatrixRoomReference, Ok, RoomEvent, StringRoomID, Task, isError, isOk } from "matrix-protection-suite"; -import { renderDetailsNotice, renderElaborationTrail, renderExceptionTrail } from "../../capabilities/CommonRenderers"; +import { + ActionError, + ActionException, + ActionExceptionKind, + ActionResult, + MatrixRoomReference, + Ok, + RoomEvent, + StringRoomID, + Task, + isError, + isOk, +} from "matrix-protection-suite"; +import { + renderDetailsNotice, + renderElaborationTrail, + renderExceptionTrail, +} from "../../capabilities/CommonRenderers"; import { printReadably } from "./PrintReadably"; function requiredArgument(argumentName: string): string { - return `<${argumentName}>`; + return `<${argumentName}>`; } function keywordArgument(keyword: string): string { - // ahh fuck what about defaults for keys? - return `[--${keyword}]`; + // ahh fuck what about defaults for keys? + return `[--${keyword}]`; } // they should be allowed to name the rest argument... function restArgument(rest: RestDescription): string { - return `[...${rest.name}]`; + return `[...${rest.name}]`; } -export function renderParameterDescription(description: ParameterDescription): DocumentNode { - return - {description.name} - {description.description ?? 'no description'}
            +export function renderParameterDescription( + description: ParameterDescription +): DocumentNode { + return ( + + {description.name} - {description.description ?? "no description"} +
            + ); } export function renderCommandSummary(command: InterfaceCommand): DocumentNode { - return
            - - {renderCommandHelp(command)} - {command.summary} - - {command.description - ? Description:
            {command.description}
            - : - } - {command.argumentListParser.descriptions.length > 0 - ? - Parameters:
            {...command.argumentListParser.descriptions.map(renderParameterDescription)} -
            - : - } + return ( +
            + + {renderCommandHelp(command)} - {command.summary} + + {command.description ? ( + + Description: +
            + {command.description} +
            +
            + ) : ( + + )} + {command.argumentListParser.descriptions.length > 0 ? ( + + Parameters: +
            + {...command.argumentListParser.descriptions.map( + renderParameterDescription + )} +
            + ) : ( + + )}
            + ); } export function renderCommandHelp(command: InterfaceCommand): string { - const rest = command.argumentListParser.rest; - const keywords = command.argumentListParser.keywords; - return [ - ...command.designator, - ...command.argumentListParser.descriptions - .map(d => requiredArgument(d.name)), - ...rest ? [restArgument(rest)] : [], - ...Object.keys(keywords.description).map(k => keywordArgument(k)), - ].join(' '); + const rest = command.argumentListParser.rest; + const keywords = command.argumentListParser.keywords; + return [ + ...command.designator, + ...command.argumentListParser.descriptions.map((d) => + requiredArgument(d.name) + ), + ...(rest ? [restArgument(rest)] : []), + ...Object.keys(keywords.description).map((k) => keywordArgument(k)), + ].join(" "); } function renderTableHelp(table: CommandTable): DocumentNode { - let tableName = table.name; - if (typeof table.name === 'string') { - tableName = table.name.charAt(0).toUpperCase() + table.name.slice(1); - } - return -
            - {tableName.toString()} commands: - {table.getExportedCommands().map(renderCommandSummary)} - {table.getImportedTables().map(renderTableHelp)} -
            + let tableName = table.name; + if (typeof table.name === "string") { + tableName = table.name.charAt(0).toUpperCase() + table.name.slice(1); + } + return ( + +
            + + {tableName.toString()} commands: + + {table.getExportedCommands().map(renderCommandSummary)} + {table.getImportedTables().map(renderTableHelp)} +
            + ); } -export async function renderHelp(client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult): Promise { - if (isError(result)) { - throw new TypeError("This command isn't supposed to fail"); - } - await renderMatrixAndSend( - renderTableHelp(result.ok), - commandRoomID, - event, - client - ); +export async function renderHelp( + client: MatrixSendClient, + commandRoomID: StringRoomID, + event: RoomEvent, + result: ActionResult +): Promise { + if (isError(result)) { + throw new TypeError("This command isn't supposed to fail"); + } + await renderMatrixAndSend( + renderTableHelp(result.ok), + commandRoomID, + event, + client + ); } -export async function reactToEventWithResult(client: MatrixSendClient, event: RoomEvent, result: ActionResult): Promise> { - // implement this so we can use it in the invitation protection - // then in the invitation protection makes ure we render when the listener fails - // then in the ban propagation protection also do this. - const react = async (emote: string): Promise> => { - try { - await client.unstableApis.addReactionToEvent(event.room_id, event.event_id, emote); - return Ok(undefined); - } catch (e) { - return ActionException.Result(`tickCrossRenderer Couldn't react to the event ${event.event_id}`, { - exception: e, - exceptionKind: ActionExceptionKind.Unknown - }); +export async function reactToEventWithResult( + client: MatrixSendClient, + event: RoomEvent, + result: ActionResult +): Promise> { + // implement this so we can use it in the invitation protection + // then in the invitation protection makes ure we render when the listener fails + // then in the ban propagation protection also do this. + const react = async (emote: string): Promise> => { + try { + await client.unstableApis.addReactionToEvent( + event.room_id, + event.event_id, + emote + ); + return Ok(undefined); + } catch (e) { + return ActionException.Result( + `tickCrossRenderer Couldn't react to the event ${event.event_id}`, + { + exception: e, + exceptionKind: ActionExceptionKind.Unknown, } - }; - if (isOk(result)) { - return await react('✅'); - } else { - return await react('❌'); + ); } + }; + if (isOk(result)) { + return await react("✅"); + } else { + return await react("❌"); + } } -export async function replyToEventWithErrorDetails(client: MatrixSendClient, event: RoomEvent, error: ActionError): Promise> { - try { - await renderMatrixAndSend( - -
            - {error.mostRelevantElaboration} - {renderDetailsNotice(error)} - {renderElaborationTrail(error)} - {renderExceptionTrail(error)} -
            -
            , - event.room_id, - event, - client, - ); - return Ok(undefined); - } catch (e) { - return ActionException.Result(`replyToEventIfError Couldn't send a reply to the event ${event.event_id}`, { - exception: e, - exceptionKind: ActionExceptionKind.Unknown - }); - } +export async function replyToEventWithErrorDetails( + client: MatrixSendClient, + event: RoomEvent, + error: ActionError +): Promise> { + try { + await renderMatrixAndSend( + +
            + {error.mostRelevantElaboration} + {renderDetailsNotice(error)} + {renderElaborationTrail(error)} + {renderExceptionTrail(error)} +
            +
            , + event.room_id, + event, + client + ); + return Ok(undefined); + } catch (e) { + return ActionException.Result( + `replyToEventIfError Couldn't send a reply to the event ${event.event_id}`, + { + exception: e, + exceptionKind: ActionExceptionKind.Unknown, + } + ); + } } -export function renderActionResultToEvent(client: MatrixSendClient, event: RoomEvent, result: ActionResult): void { - if (isError(result)) { - void Task(replyToEventWithErrorDetails(client, event, result.error)); - } - void Task(reactToEventWithResult(client, event, result)); +export function renderActionResultToEvent( + client: MatrixSendClient, + event: RoomEvent, + result: ActionResult +): void { + if (isError(result)) { + void Task(replyToEventWithErrorDetails(client, event, result.error)); + } + void Task(reactToEventWithResult(client, event, result)); } -export const tickCrossRenderer: RendererSignature = async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult): Promise { +export const tickCrossRenderer: RendererSignature = + async function tickCrossRenderer( + this: MatrixInterfaceAdaptor, + client: MatrixSendClient, + commandRoomID: StringRoomID, + event: RoomEvent, + result: ActionResult + ): Promise { void Task(reactToEventWithResult(client, event, result)); if (isError(result)) { - if (result.error instanceof ArgumentParseError) { - await renderMatrixAndSend( - renderArgumentParseError(this.interfaceCommand, result.error), - commandRoomID, - event, - client); - } else if (result.error instanceof ActionException) { - const commandError = result.error; - LogService.error("CommandException", commandError.uuid, commandError.message, commandError.exception); - await renderMatrixAndSend( - renderCommandException(this.interfaceCommand, result.error), - commandRoomID, - event, - client); - } else { - await client.replyNotice(commandRoomID, event, result.error.message); - } + if (result.error instanceof ArgumentParseError) { + await renderMatrixAndSend( + renderArgumentParseError(this.interfaceCommand, result.error), + commandRoomID, + event, + client + ); + } else if (result.error instanceof ActionException) { + const commandError = result.error; + LogService.error( + "CommandException", + commandError.uuid, + commandError.message, + commandError.exception + ); + await renderMatrixAndSend( + renderCommandException(this.interfaceCommand, result.error), + commandRoomID, + event, + client + ); + } else { + await client.replyNotice(commandRoomID, event, result.error.message); + } } -} + }; // Maybe we need something like the MatrixInterfaceAdaptor but for Error types? -function formattedArgumentHint(command: InterfaceCommand, error: ArgumentParseError): string { - const argumentsUpToError = error.stream.source.slice(0, error.stream.getPosition()); - let commandContext = 'Command context:'; - for (const designator of command.designator) { - commandContext += ` ${designator}`; - } - for (const argument of argumentsUpToError) { - commandContext += ` ${JSON.stringify(argument)}`; - } - const badArgument = ` ${printReadably(error.stream.peekItem())}\n${Array(commandContext.length + 1).join(' ')} ^ expected ${error.parameter.acceptor.name} here`; - return commandContext + badArgument; +function formattedArgumentHint( + command: InterfaceCommand, + error: ArgumentParseError +): string { + const argumentsUpToError = error.stream.source.slice( + 0, + error.stream.getPosition() + ); + let commandContext = "Command context:"; + for (const designator of command.designator) { + commandContext += ` ${designator}`; + } + for (const argument of argumentsUpToError) { + commandContext += ` ${JSON.stringify(argument)}`; + } + const badArgument = ` ${printReadably(error.stream.peekItem())}\n${Array(commandContext.length + 1).join(" ")} ^ expected ${error.parameter.acceptor.name} here`; + return commandContext + badArgument; } -function renderArgumentParseError(command: InterfaceCommand, error: ArgumentParseError): DocumentNode { - return - There was a problem when parsing the {error.parameter.name} parameter for this command.
            - {renderCommandHelp(command)}
            - {error.message}
            -
            {formattedArgumentHint(command, error)}
            +function renderArgumentParseError( + command: InterfaceCommand, + error: ArgumentParseError +): DocumentNode { + return ( + + There was a problem when parsing the {error.parameter.name}{" "} + parameter for this command. +
            + {renderCommandHelp(command)} +
            + {error.message} +
            +
            {formattedArgumentHint(command, error)}
            + ); } -function renderCommandException(command: InterfaceCommand, error: ActionException): DocumentNode { - return - There was an unexpected error when processing this command:
            - {error.message}
            - Details can be found by providing the reference {error.uuid} - to an administrator. +function renderCommandException( + command: InterfaceCommand, + error: ActionException +): DocumentNode { + return ( + + There was an unexpected error when processing this command: +
            + {error.message} +
            + Details can be found by providing the reference {error.uuid} + to an administrator.
            + ); } -export function renderMentionPill(mxid: string, displayName: string): DocumentNode { - const url = `https://matrix.to/#/${mxid}`; - return {displayName} +export function renderMentionPill( + mxid: string, + displayName: string +): DocumentNode { + const url = `https://matrix.to/#/${mxid}`; + return {displayName}; } export function renderRoomPill(room: MatrixRoomReference): DocumentNode { - return {room.toRoomIDOrAlias()} + return {room.toRoomIDOrAlias()}; } diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index be549676..5665f98c 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -33,112 +33,198 @@ import { LogService } from "matrix-bot-sdk"; import { ReadItem } from "./CommandReader"; import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; import { tickCrossRenderer } from "./MatrixHelpRenderer"; -import { InterfaceAcceptor, PromptOptions, PromptableArgumentStream } from "./PromptForAccept"; +import { + InterfaceAcceptor, + PromptOptions, + PromptableArgumentStream, +} from "./PromptForAccept"; import { ParameterDescription } from "./ParameterParsing"; import { promptDefault, promptSuggestions } from "./MatrixPromptForAccept"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionError, ActionResult, ClientPlatform, ResultError, RoomEvent, StringRoomID, Task, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + ClientPlatform, + ResultError, + RoomEvent, + StringRoomID, + Task, + isError, +} from "matrix-protection-suite"; import { MatrixReactionHandler } from "./MatrixReactionHandler"; import { PromptRequiredError } from "./PromptRequiredError"; export interface MatrixContext { - reactionHandler: MatrixReactionHandler, - client: MatrixSendClient, - // Use the client platform capabilities over the `MatrixSendClient`, since - // they can use join preemption. - // TODO: How can we make commands declare which things they want (from the context) - // similar to capability providers in MPS protections? - // we kind of need to remove the context object. - clientPlatform: ClientPlatform, - roomID: StringRoomID, - event: RoomEvent, + reactionHandler: MatrixReactionHandler; + client: MatrixSendClient; + // Use the client platform capabilities over the `MatrixSendClient`, since + // they can use join preemption. + // TODO: How can we make commands declare which things they want (from the context) + // similar to capability providers in MPS protections? + // we kind of need to remove the context object. + clientPlatform: ClientPlatform; + roomID: StringRoomID; + event: RoomEvent; } -export type RendererSignature = ( - this: MatrixInterfaceAdaptor, - client: MatrixSendClient, - commandRoomID: StringRoomID, - event: RoomEvent, - result: ActionResult) => Promise; +export type RendererSignature< + C extends MatrixContext, + ExecutorType extends BaseFunction, +> = ( + this: MatrixInterfaceAdaptor, + client: MatrixSendClient, + commandRoomID: StringRoomID, + event: RoomEvent, + result: ActionResult +) => Promise; -export class MatrixInterfaceAdaptor implements InterfaceAcceptor { - public readonly isPromptable = true; - constructor( - public readonly interfaceCommand: InterfaceCommand, - private readonly renderer: RendererSignature, - private readonly validationErrorHandler?: (client: MatrixSendClient, roomID: StringRoomID, event: RoomEvent, validationError: ActionError) => Promise - ) { +export class MatrixInterfaceAdaptor< + C extends MatrixContext, + ExecutorType extends BaseFunction = BaseFunction, +> implements InterfaceAcceptor +{ + public readonly isPromptable = true; + constructor( + public readonly interfaceCommand: InterfaceCommand, + private readonly renderer: RendererSignature, + private readonly validationErrorHandler?: ( + client: MatrixSendClient, + roomID: StringRoomID, + event: RoomEvent, + validationError: ActionError + ) => Promise + ) {} - } - - /** - * Parse the context required by the command, call the associated application command and then render the result to a Matrix room. - * The arguments to invoke will be given directly to the parser. - * The executor of the application command will then be applied to whatever is returned by the parser. - * Then the renderer will be applied to the same arguments given to the parser (so it knows which matrix room to respond to) - * along with the result of the executor. - * @param args These will be the arguments to the parser function. - */ - public async invoke(executorContext: ThisParameterType, matrixContext: C, ...args: ReadItem[]): Promise { - const stream = new PromptableArgumentStream(args, this); - const executorResult: Awaited> = await this.interfaceCommand.parseThenInvoke(executorContext, stream); - // FIXME: IT's really not clear to me what reportValidationError is - // or how `renderer` gets called if a command fails? - // maybe it never did, i think the validation error handler uses tick cross renderer :skull: - // so it'd be hard to know. - if (isError(executorResult)) { - if (executorResult.error instanceof PromptRequiredError) { - const parameter = executorResult.error.parameterRequiringPrompt as ParameterDescription; - if (parameter.prompt === undefined) { - throw new TypeError(`A PromptRequiredError was given for a parameter which doesn't support prompts, this shouldn't happen`); - } - const promptOptions: PromptOptions = await parameter.prompt.call(executorContext, parameter); - if (promptOptions.default) { - await promptDefault.call(matrixContext, parameter, this.interfaceCommand, promptOptions.default, args); - } else { - await promptSuggestions.call(matrixContext, parameter, this.interfaceCommand, promptOptions.suggestions, args); - } - return; - } else { - void Task(this.reportValidationError(matrixContext.client, matrixContext.roomID, matrixContext.event, executorResult.error)); - return; - } + /** + * Parse the context required by the command, call the associated application command and then render the result to a Matrix room. + * The arguments to invoke will be given directly to the parser. + * The executor of the application command will then be applied to whatever is returned by the parser. + * Then the renderer will be applied to the same arguments given to the parser (so it knows which matrix room to respond to) + * along with the result of the executor. + * @param args These will be the arguments to the parser function. + */ + public async invoke( + executorContext: ThisParameterType, + matrixContext: C, + ...args: ReadItem[] + ): Promise { + const stream = new PromptableArgumentStream(args, this); + const executorResult: Awaited< + ReturnType + > = await this.interfaceCommand.parseThenInvoke(executorContext, stream); + // FIXME: IT's really not clear to me what reportValidationError is + // or how `renderer` gets called if a command fails? + // maybe it never did, i think the validation error handler uses tick cross renderer :skull: + // so it'd be hard to know. + if (isError(executorResult)) { + if (executorResult.error instanceof PromptRequiredError) { + const parameter = executorResult.error + .parameterRequiringPrompt as ParameterDescription; + if (parameter.prompt === undefined) { + throw new TypeError( + `A PromptRequiredError was given for a parameter which doesn't support prompts, this shouldn't happen` + ); } - // just give the renderer the MatrixContext. - // we need to give the renderer the command itself! - await this.renderer.apply(this, [matrixContext.client, matrixContext.roomID, matrixContext.event, executorResult]); + const promptOptions: PromptOptions = await parameter.prompt.call( + executorContext, + parameter + ); + if (promptOptions.default) { + await promptDefault.call( + matrixContext, + parameter, + this.interfaceCommand, + promptOptions.default, + args + ); + } else { + await promptSuggestions.call( + matrixContext, + parameter, + this.interfaceCommand, + promptOptions.suggestions, + args + ); + } + return; + } else { + void Task( + this.reportValidationError( + matrixContext.client, + matrixContext.roomID, + matrixContext.event, + executorResult.error + ) + ); + return; + } } + // just give the renderer the MatrixContext. + // we need to give the renderer the command itself! + await this.renderer.apply(this, [ + matrixContext.client, + matrixContext.roomID, + matrixContext.event, + executorResult, + ]); + } - // is this still necessary, surely this should be handled entirely by the renderer? - // well an argument against it being handled entirely by the renderer is that this provides a clear distinction between an error during parsing - // and an error discovered because their is a fault or an error running the command. Though i don't think this is correct - // since any CommandError recieved is an expected error. It means there is no fault. An exception on the other hand does - // so this suggests we should just remove this. - private async reportValidationError(client: MatrixSendClient, roomID: StringRoomID, event: RoomEvent, validationError: ActionError): Promise { - LogService.info("MatrixInterfaceCommand", `User input validation error when parsing command ${JSON.stringify(this.interfaceCommand.designator)}: ${validationError.message}`); - if (this.validationErrorHandler) { - await this.validationErrorHandler(client, roomID, event, validationError); - return; - } - await tickCrossRenderer.call(this, client, roomID, event, ResultError(validationError)); + // is this still necessary, surely this should be handled entirely by the renderer? + // well an argument against it being handled entirely by the renderer is that this provides a clear distinction between an error during parsing + // and an error discovered because their is a fault or an error running the command. Though i don't think this is correct + // since any CommandError recieved is an expected error. It means there is no fault. An exception on the other hand does + // so this suggests we should just remove this. + private async reportValidationError( + client: MatrixSendClient, + roomID: StringRoomID, + event: RoomEvent, + validationError: ActionError + ): Promise { + LogService.info( + "MatrixInterfaceCommand", + `User input validation error when parsing command ${JSON.stringify(this.interfaceCommand.designator)}: ${validationError.message}` + ); + if (this.validationErrorHandler) { + await this.validationErrorHandler(client, roomID, event, validationError); + return; } + await tickCrossRenderer.call( + this, + client, + roomID, + event, + ResultError(validationError) + ); + } } -const MATRIX_INTERFACE_ADAPTORS = new Map>(); +const MATRIX_INTERFACE_ADAPTORS = new Map< + InterfaceCommand, + MatrixInterfaceAdaptor +>(); -function internMatrixInterfaceAdaptor(interfaceCommand: InterfaceCommand, adapator: MatrixInterfaceAdaptor): void { - if (MATRIX_INTERFACE_ADAPTORS.has(interfaceCommand)) { - throw new TypeError(`An adaptor is already defined for the command ${interfaceCommand.designator.toString()}`); - } - MATRIX_INTERFACE_ADAPTORS.set(interfaceCommand, adapator); +function internMatrixInterfaceAdaptor( + interfaceCommand: InterfaceCommand, + adapator: MatrixInterfaceAdaptor +): void { + if (MATRIX_INTERFACE_ADAPTORS.has(interfaceCommand)) { + throw new TypeError( + `An adaptor is already defined for the command ${interfaceCommand.designator.toString()}` + ); + } + MATRIX_INTERFACE_ADAPTORS.set(interfaceCommand, adapator); } -export function findMatrixInterfaceAdaptor(interfaceCommand: InterfaceCommand): MatrixInterfaceAdaptor { - const entry = MATRIX_INTERFACE_ADAPTORS.get(interfaceCommand); - if (entry) { - return entry - } - throw new TypeError(`Couldn't find an adaptor for the command ${interfaceCommand.designator.toString()}`); +export function findMatrixInterfaceAdaptor( + interfaceCommand: InterfaceCommand +): MatrixInterfaceAdaptor { + const entry = MATRIX_INTERFACE_ADAPTORS.get(interfaceCommand); + if (entry) { + return entry; + } + throw new TypeError( + `Couldn't find an adaptor for the command ${interfaceCommand.designator.toString()}` + ); } /** @@ -148,15 +234,14 @@ export function findMatrixInterfaceAdaptor(interfaceCommand: InterfaceCommand): * @param applicationCommmand The ApplicationCommand this is an interface wrapper for. * @param renderer Render the result of the application command back to a room. */ -export function defineMatrixInterfaceAdaptor(details: { - interfaceCommand: InterfaceCommand, - renderer: RendererSignature +export function defineMatrixInterfaceAdaptor< + ExecutorType extends BaseFunction, +>(details: { + interfaceCommand: InterfaceCommand; + renderer: RendererSignature; }) { - internMatrixInterfaceAdaptor( - details.interfaceCommand, - new MatrixInterfaceAdaptor( - details.interfaceCommand, - details.renderer - ) - ); + internMatrixInterfaceAdaptor( + details.interfaceCommand, + new MatrixInterfaceAdaptor(details.interfaceCommand, details.renderer) + ); } diff --git a/src/commands/interface-manager/MatrixPresentations.tsx b/src/commands/interface-manager/MatrixPresentations.tsx index 44eb667d..26e80294 100644 --- a/src/commands/interface-manager/MatrixPresentations.tsx +++ b/src/commands/interface-manager/MatrixPresentations.tsx @@ -4,45 +4,83 @@ */ import { ReadItem } from "./CommandReader"; -import { findPresentationType, makePresentationType, simpleTypeValidator } from "./ParameterParsing"; +import { + findPresentationType, + makePresentationType, + simpleTypeValidator, +} from "./ParameterParsing"; import { definePresentationRenderer } from "./DeadDocumentPresentation"; import { DeadDocumentJSX } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; -import { MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomAlias, MatrixRoomID, MatrixRoomReference, UserID } from "matrix-protection-suite"; - +import { + MatrixEventViaAlias, + MatrixEventViaRoomID, + MatrixRoomAlias, + MatrixRoomID, + MatrixRoomReference, + UserID, +} from "matrix-protection-suite"; makePresentationType({ - name: 'UserID', - validator: simpleTypeValidator('UserID', (item: ReadItem) => item instanceof UserID), -}) + name: "UserID", + validator: simpleTypeValidator( + "UserID", + (item: ReadItem) => item instanceof UserID + ), +}); makePresentationType({ - name: 'MatrixRoomReference', - validator: simpleTypeValidator('MatrixRoomReference', (item: ReadItem) => item instanceof MatrixRoomID || item instanceof MatrixRoomAlias), -}) + name: "MatrixRoomReference", + validator: simpleTypeValidator( + "MatrixRoomReference", + (item: ReadItem) => + item instanceof MatrixRoomID || item instanceof MatrixRoomAlias + ), +}); -definePresentationRenderer(findPresentationType('MatrixRoomReference'), function(presentation: MatrixRoomReference): DocumentNode { - return {presentation.toRoomIDOrAlias()} -}) +definePresentationRenderer( + findPresentationType("MatrixRoomReference"), + function (presentation: MatrixRoomReference): DocumentNode { + return ( + {presentation.toRoomIDOrAlias()} + ); + } +); makePresentationType({ - name: 'MatrixRoomID', - validator: simpleTypeValidator('MatrixRoomID', (item: ReadItem) => item instanceof MatrixRoomID) -}) + name: "MatrixRoomID", + validator: simpleTypeValidator( + "MatrixRoomID", + (item: ReadItem) => item instanceof MatrixRoomID + ), +}); makePresentationType({ - name: 'MatrixRoomAlias', - validator: simpleTypeValidator('MatrixRoomAlias', (item: ReadItem) => item instanceof MatrixRoomAlias) -}) + name: "MatrixRoomAlias", + validator: simpleTypeValidator( + "MatrixRoomAlias", + (item: ReadItem) => item instanceof MatrixRoomAlias + ), +}); // Wouldn't this be better as a custom document node so that we could render the plain text version differently? -definePresentationRenderer(findPresentationType('UserID'), function (presentation: UserID): DocumentNode { - return +definePresentationRenderer( + findPresentationType("UserID"), + function (presentation: UserID): DocumentNode { + return ( + {presentation.toString()} - -}) + + ); + } +); makePresentationType({ - name: 'MatrixEventReference', - validator: simpleTypeValidator('MatrixEventReference', (item) => item instanceof MatrixEventViaAlias || item instanceof MatrixEventViaRoomID) -}) + name: "MatrixEventReference", + validator: simpleTypeValidator( + "MatrixEventReference", + (item) => + item instanceof MatrixEventViaAlias || + item instanceof MatrixEventViaRoomID + ), +}); diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index 697e59ba..a52a6383 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -3,210 +3,266 @@ * All rights reserved. */ -import { ClientPlatform, Logger, RoomEvent, StringRoomID, Task, Value, isError } from "matrix-protection-suite"; +import { + ClientPlatform, + Logger, + RoomEvent, + StringRoomID, + Task, + Value, + isError, +} from "matrix-protection-suite"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { CommandTable, InterfaceCommand } from "./InterfaceCommand"; import { DeadDocumentJSX } from "./JSXFactory"; -import { MatrixContext, findMatrixInterfaceAdaptor } from "./MatrixInterfaceAdaptor"; +import { + MatrixContext, + findMatrixInterfaceAdaptor, +} from "./MatrixInterfaceAdaptor"; import { ArgumentStream, ParameterDescription } from "./ParameterParsing"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { MatrixReactionHandler, ReactionListener } from "./MatrixReactionHandler"; +import { + MatrixReactionHandler, + ReactionListener, +} from "./MatrixReactionHandler"; import { StaticDecode, Type } from "@sinclair/typebox"; import { ReadItem, readCommand } from "./CommandReader"; import { printReadably } from "./PrintReadably"; import { DeadDocumentPresentationMirror } from "./DeadDocumentPresentation"; -const log = new Logger('MatrixPromptForAccept'); +const log = new Logger("MatrixPromptForAccept"); type PromptContext = StaticDecode; // FIXME: Remove no-redeclare entirely, it is wrong. const PromptContext = Type.Object({ - command_designator: Type.Array(Type.String()), - read_items: Type.Array(Type.String()), + command_designator: Type.Array(Type.String()), + read_items: Type.Array(Type.String()), }); type DefaultPromptContext = StaticDecode; // FIXME: Remove no-redeclare entirely, it is wrong. const DefaultPromptContext = Type.Composite([ - PromptContext, - Type.Object({ - default: Type.String(), - }) + PromptContext, + Type.Object({ + default: Type.String(), + }), ]); -function continueCommandAcceptingPrompt( - promptContext: PromptContext, - serializedPrompt: string, - commandTable: CommandTable, - client: MatrixSendClient, - clientPlatform: ClientPlatform, - commandRoomID: StringRoomID, - reactionHandler: MatrixReactionHandler, - annotatedEvent: RoomEvent, - additionalCommandContext: Omit): void { - // TODO: We do this because we don't have a way to deserialize the individual serialized - // read items. Well we probably should. - const itemStream = new ArgumentStream(readCommand([ +function continueCommandAcceptingPrompt< + CommandContext extends MatrixContext = MatrixContext, +>( + promptContext: PromptContext, + serializedPrompt: string, + commandTable: CommandTable, + client: MatrixSendClient, + clientPlatform: ClientPlatform, + commandRoomID: StringRoomID, + reactionHandler: MatrixReactionHandler, + annotatedEvent: RoomEvent, + additionalCommandContext: Omit +): void { + // TODO: We do this because we don't have a way to deserialize the individual serialized + // read items. Well we probably should. + const itemStream = new ArgumentStream( + readCommand( + [ ...promptContext.command_designator, ...promptContext.read_items, - serializedPrompt - ].join(' '))); - const command = commandTable.findAMatchingCommand(itemStream); - if (command === undefined) { - log.error(`couldn't find the associated command for a default prompt`, promptContext.command_designator); - return; - } - const adaptor = findMatrixInterfaceAdaptor(command); - const commandContext = { - roomID: commandRoomID, - client, - clientPlatform, - reactionHandler: reactionHandler, - event: annotatedEvent, - ...additionalCommandContext, - }; - void Task((async () => { await adaptor.invoke(commandContext, commandContext, ...itemStream.rest()); })()); + serializedPrompt, + ].join(" ") + ) + ); + const command = commandTable.findAMatchingCommand(itemStream); + if (command === undefined) { + log.error( + `couldn't find the associated command for a default prompt`, + promptContext.command_designator + ); + return; + } + const adaptor = findMatrixInterfaceAdaptor(command); + const commandContext = { + roomID: commandRoomID, + client, + clientPlatform, + reactionHandler: reactionHandler, + event: annotatedEvent, + ...additionalCommandContext, + }; + void Task( + (async () => { + await adaptor.invoke( + commandContext, + commandContext, + ...itemStream.rest() + ); + })() + ); } -export const DEFAUILT_ARGUMENT_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.default_argument_prompt'; -export function makeListenerForPromptDefault( - client: MatrixSendClient, - clientPlatform: ClientPlatform, - commandRoomID: StringRoomID, - reactionHandler: MatrixReactionHandler, - commandTable: CommandTable, - additionalCommandContext: Omit +export const DEFAUILT_ARGUMENT_PROMPT_LISTENER = + "ge.applied-langua.ge.draupnir.default_argument_prompt"; +export function makeListenerForPromptDefault< + CommandContext extends MatrixContext = MatrixContext, +>( + client: MatrixSendClient, + clientPlatform: ClientPlatform, + commandRoomID: StringRoomID, + reactionHandler: MatrixReactionHandler, + commandTable: CommandTable, + additionalCommandContext: Omit ): ReactionListener { - return function(reactionKey, item, context, reactionMap, annotatedEvent) { - if (item !== 'ok') { - return; - } - const promptContext = Value.Decode(DefaultPromptContext, context); - if (isError(promptContext)) { - log.error(`malformed event context when trying to accept a default prompt`, context); - return; - } - continueCommandAcceptingPrompt( - promptContext.ok, - promptContext.ok.default, - commandTable, - client, - clientPlatform, - commandRoomID, - reactionHandler, - annotatedEvent, - additionalCommandContext - ); + return function (reactionKey, item, context, reactionMap, annotatedEvent) { + if (item !== "ok") { + return; } + const promptContext = Value.Decode(DefaultPromptContext, context); + if (isError(promptContext)) { + log.error( + `malformed event context when trying to accept a default prompt`, + context + ); + return; + } + continueCommandAcceptingPrompt( + promptContext.ok, + promptContext.ok.default, + commandTable, + client, + clientPlatform, + commandRoomID, + reactionHandler, + annotatedEvent, + additionalCommandContext + ); + }; } -export const ARGUMENT_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.argument_prompt'; -export function makeListenerForArgumentPrompt( - client: MatrixSendClient, - clientPlatform: ClientPlatform, - commandRoomID: StringRoomID, - reactionHandler: MatrixReactionHandler, - commandTable: CommandTable, - additionalCommandContext: Omit +export const ARGUMENT_PROMPT_LISTENER = + "ge.applied-langua.ge.draupnir.argument_prompt"; +export function makeListenerForArgumentPrompt< + CommandContext extends MatrixContext = MatrixContext, +>( + client: MatrixSendClient, + clientPlatform: ClientPlatform, + commandRoomID: StringRoomID, + reactionHandler: MatrixReactionHandler, + commandTable: CommandTable, + additionalCommandContext: Omit ): ReactionListener { - return function(reactionKey, item, context, reactionMap, annotatedEvent) { - const promptContext = Value.Decode(PromptContext, context); - if (isError(promptContext)) { - log.error(`malformed event context when trying to accept a prompted argument`, context); - return; - } - continueCommandAcceptingPrompt( - promptContext.ok, - item, - commandTable, - client, - clientPlatform, - commandRoomID, - reactionHandler, - annotatedEvent, - additionalCommandContext - ); + return function (reactionKey, item, context, reactionMap, annotatedEvent) { + const promptContext = Value.Decode(PromptContext, context); + if (isError(promptContext)) { + log.error( + `malformed event context when trying to accept a prompted argument`, + context + ); + return; } + continueCommandAcceptingPrompt( + promptContext.ok, + item, + commandTable, + client, + clientPlatform, + commandRoomID, + reactionHandler, + annotatedEvent, + additionalCommandContext + ); + }; } export async function promptDefault( - this: MatrixContext, - parameter: ParameterDescription, - command: InterfaceCommand, - defaultPrompt: PresentationType, - existingArguments: ReadItem[] + this: MatrixContext, + parameter: ParameterDescription, + command: InterfaceCommand, + defaultPrompt: PresentationType, + existingArguments: ReadItem[] ): Promise { - const reactionMap = new Map(Object.entries({ - 'Ok': 'ok' - })); - const events = await renderMatrixAndSend( - - No argument was provided for the parameter {parameter.name}, would you like to accept the default?
            - {DeadDocumentPresentationMirror.present(defaultPrompt)} -
            , - this.roomID, this.event, this.client, - this.reactionHandler.createAnnotation( - DEFAUILT_ARGUMENT_PROMPT_LISTENER, - reactionMap, - { - command_designator: command.designator, - read_items: existingArguments.map(printReadably), - default: printReadably(defaultPrompt) - } - ) - ); - if (events[0] === undefined) { - throw new TypeError(`We should have got at least one event, the one that we just sent`); - } - await this.reactionHandler.addReactionsToEvent( - this.client, - this.roomID, - events[0], - reactionMap + const reactionMap = new Map( + Object.entries({ + Ok: "ok", + }) + ); + const events = await renderMatrixAndSend( + + No argument was provided for the parameter {parameter.name}, would you + like to accept the default? +
            + {DeadDocumentPresentationMirror.present(defaultPrompt)} +
            , + this.roomID, + this.event, + this.client, + this.reactionHandler.createAnnotation( + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + reactionMap, + { + command_designator: command.designator, + read_items: existingArguments.map(printReadably), + default: printReadably(defaultPrompt), + } + ) + ); + if (events[0] === undefined) { + throw new TypeError( + `We should have got at least one event, the one that we just sent` ); + } + await this.reactionHandler.addReactionsToEvent( + this.client, + this.roomID, + events[0], + reactionMap + ); } // FIXME:
              raw tags will not work if the message is sent across events. // If there isn't a start attribute for `ol` then we'll need to take this into our own hands. export async function promptSuggestions( - this: MatrixContext, - parameter: ParameterDescription, - command: InterfaceCommand, - suggestions: ReadItem[], - existingArguments: ReadItem[], + this: MatrixContext, + parameter: ParameterDescription, + command: InterfaceCommand, + suggestions: ReadItem[], + existingArguments: ReadItem[] ): Promise { - const reactionMap = MatrixReactionHandler.createItemizedReactionMap( - suggestions.map(printReadably) - ); - const events = await renderMatrixAndSend( - Please select one of the following options to provide as an argument for the parameter {parameter.name}: -
                - {suggestions.map((suggestion) => { - return
              1. - {DeadDocumentPresentationMirror.present(suggestion)} -
              2. - })} -
              -
              , - this.roomID, this.event, this.client, - this.reactionHandler.createAnnotation( - ARGUMENT_PROMPT_LISTENER, - reactionMap, - { - read_items: existingArguments.map(printReadably), - command_designator: command.designator - } - ) - ); - if (events[0] === undefined) { - throw new TypeError(`We should have got at least one event, the one that we just sent`); - } - await this.reactionHandler.addReactionsToEvent( - this.client, - this.roomID, - events[0], - reactionMap + const reactionMap = MatrixReactionHandler.createItemizedReactionMap( + suggestions.map(printReadably) + ); + const events = await renderMatrixAndSend( + + Please select one of the following options to provide as an argument for + the parameter {parameter.name}: +
                + {suggestions.map((suggestion) => { + return
              1. {DeadDocumentPresentationMirror.present(suggestion)}
              2. ; + })} +
              +
              , + this.roomID, + this.event, + this.client, + this.reactionHandler.createAnnotation( + ARGUMENT_PROMPT_LISTENER, + reactionMap, + { + read_items: existingArguments.map(printReadably), + command_designator: command.designator, + } + ) + ); + if (events[0] === undefined) { + throw new TypeError( + `We should have got at least one event, the one that we just sent` ); + } + await this.reactionHandler.addReactionsToEvent( + this.client, + this.roomID, + events[0], + reactionMap + ); } diff --git a/src/commands/interface-manager/MatrixReactionHandler.ts b/src/commands/interface-manager/MatrixReactionHandler.ts index 6a054108..bcf8e476 100644 --- a/src/commands/interface-manager/MatrixReactionHandler.ts +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -4,211 +4,273 @@ import { EventEmitter } from "stream"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionResult, ClientPlatform, Logger, ReactionEvent, RoomEvent, StringEventID, StringRoomID, StringUserID, Task, Value, isError } from "matrix-protection-suite"; +import { + ActionResult, + ClientPlatform, + Logger, + ReactionEvent, + RoomEvent, + StringEventID, + StringRoomID, + StringUserID, + Task, + Value, + isError, +} from "matrix-protection-suite"; const log = new Logger("MatrixReactionHandler"); -const REACTION_ANNOTATION_KEY = 'ge.applied-langua.ge.draupnir.reaction_handler'; +const REACTION_ANNOTATION_KEY = + "ge.applied-langua.ge.draupnir.reaction_handler"; -type ItemByReactionKey = Map; +type ItemByReactionKey = Map< + string /*reaction key*/, + string /*serialized presentation*/ +>; export type ReactionListener = ( - key: string, - item: string, - additionalContext: unknown, - reactionMap: ItemByReactionKey, - annotatedEvent: RoomEvent + key: string, + item: string, + additionalContext: unknown, + reactionMap: ItemByReactionKey, + annotatedEvent: RoomEvent ) => void; export declare interface MatrixReactionHandlerListeners { - on(eventName: string, listener: ReactionListener): void; - emit(eventName: string, ...args: Parameters): void; + on(eventName: string, listener: ReactionListener): void; + emit(eventName: string, ...args: Parameters): void; } /** * A utility that can be associated with an `MatrixEmitter` to listen for * reactions to Matrix Events. The aim is to simplify reaction UX. */ -export class MatrixReactionHandler extends EventEmitter implements MatrixReactionHandlerListeners { - public constructor( - /** - * The room the handler is for. Cannot be enabled for every room as the - * OG event lookup is very slow. So usually draupnir's management room. - */ - public readonly roomID: StringRoomID, - /** - * A client to lookup the related events to reactions. - */ - private readonly client: MatrixSendClient, - /** - * The user id of the client. Ignores reactions from this user - */ - private readonly clientUserID: StringUserID, - private readonly clientPlatform: ClientPlatform - ) { - super(); - } - +export class MatrixReactionHandler + extends EventEmitter + implements MatrixReactionHandlerListeners +{ + public constructor( /** - * Handle an event from a `MatrixEmitter` and see if it is a reaction to - * a previously annotated event. If it is a reaction to an annotated event, - * then call its associated listener. - * @param roomID The room the event took place in. - * @param event The Matrix event. + * The room the handler is for. Cannot be enabled for every room as the + * OG event lookup is very slow. So usually draupnir's management room. */ - public async handleEvent(roomID: StringRoomID, event: RoomEvent): Promise { - if (roomID !== this.roomID) { - return; - } - if (event.sender === this.clientUserID) { - return; - } - if (!Value.Check(ReactionEvent, event)) { - return; - } - const relatesTo = event.content["m.relates_to"]; - if (relatesTo === undefined) { - return; - } - const reactionKey = relatesTo['key']; - const relatedEventId = relatesTo['event_id']; - if (!(typeof relatedEventId === 'string' && typeof reactionKey === 'string')) { - return; - } - const annotatedEvent = await this.client.getEvent(roomID, relatedEventId); - const annotation = annotatedEvent.content[REACTION_ANNOTATION_KEY]; - if (annotation === undefined) { - return; - } - const reactionMap = annotation['reaction_map']; - if (typeof reactionMap !== 'object' || reactionMap === null) { - log.warn( `Missing reaction_map for the annotated event ${relatedEventId} in ${roomID}`); - return; - } - const listenerName = annotation['name']; - if (typeof listenerName !== 'string') { - log.warn( `The event ${relatedEventId} in ${roomID} is missing the name of the annotation`); - return; - } - const association = reactionMap[reactionKey]; - if (association === undefined) { - log.info( `There wasn't a defined key for ${reactionKey} on event ${relatedEventId} in ${roomID}`); - return; - } - this.emit(listenerName, reactionKey, association, annotation['additional_context'], new Map(Object.entries(reactionMap)), annotatedEvent); - } - + public readonly roomID: StringRoomID, /** - * Create the annotation required to setup a listener for when a reaction is encountered for the list. - * @param listenerName The name of the event to emit when a reaction is encountered for a matrix event that matches a key in the `reactionMap`. - * @param reactionMap A map of reaction keys to items that will be provided to the listener. - * @param additionalContext Any additional context that should be associated with a matrix event for the listener. - * @returns An object that should be deep copied into a the content of a new Matrix event. + * A client to lookup the related events to reactions. */ - public createAnnotation(listenerName: string, reactionMap: ItemByReactionKey, additionalContext: Record | undefined = undefined): Record { - return { - [REACTION_ANNOTATION_KEY]: { - name: listenerName, - reaction_map: Object.fromEntries(reactionMap), - additional_context: additionalContext, - } - } - } - + private readonly client: MatrixSendClient, /** - * Use a reaction map to create the initial reactions to an event so that the user has access to quick reactions. - * @param client A client to add the reactions with. - * @param roomId The room id of the event to add the reactions to. - * @param eventId The event id of the event to add reactions to. - * @param reactionMap The reaction map. + * The user id of the client. Ignores reactions from this user */ - public async addReactionsToEvent(client: MatrixSendClient, roomId: string, eventId: string, reactionMap: ItemByReactionKey): Promise { - await [...reactionMap.keys()] - .reduce((acc, key) => acc.then(_ => client.unstableApis.addReactionToEvent(roomId, eventId, key)), - Promise.resolve() - ).catch((e: unknown) => { - if (e instanceof Error) { - log.error( `Could not add reaction to event ${eventId}`, e) - return Promise.reject(e) - } else { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - return Promise.reject(new TypeError(`Something is throwing rubbish ${e}`)) - } - }); - } + private readonly clientUserID: StringUserID, + private readonly clientPlatform: ClientPlatform + ) { + super(); + } - public async completePrompt( - roomID: StringRoomID, - eventID: StringEventID, - reason?: string - ): Promise> { - const eventRelationsGetter = this.clientPlatform.toRoomEventRelationsGetter(); - const redacter = this.clientPlatform.toRoomEventRedacter(); - return await eventRelationsGetter.forEachRelation( - roomID, - eventID, - { - relationType: 'm.annotation', - eventType: 'm.reaction', - forEachCB: (event) => { - const key = event.content?.["m.relates_to"]?.key - // skip the bots own reactions that mark the event as complete - if (key === '✅' || key === '❌') { - return; - } - void Task(redacter.redactEvent(roomID, event.event_id, reason) as Promise>) - } - } - ); + /** + * Handle an event from a `MatrixEmitter` and see if it is a reaction to + * a previously annotated event. If it is a reaction to an annotated event, + * then call its associated listener. + * @param roomID The room the event took place in. + * @param event The Matrix event. + */ + public async handleEvent( + roomID: StringRoomID, + event: RoomEvent + ): Promise { + if (roomID !== this.roomID) { + return; + } + if (event.sender === this.clientUserID) { + return; + } + if (!Value.Check(ReactionEvent, event)) { + return; + } + const relatesTo = event.content["m.relates_to"]; + if (relatesTo === undefined) { + return; + } + const reactionKey = relatesTo["key"]; + const relatedEventId = relatesTo["event_id"]; + if ( + !(typeof relatedEventId === "string" && typeof reactionKey === "string") + ) { + return; + } + const annotatedEvent = await this.client.getEvent(roomID, relatedEventId); + const annotation = annotatedEvent.content[REACTION_ANNOTATION_KEY]; + if (annotation === undefined) { + return; + } + const reactionMap = annotation["reaction_map"]; + if (typeof reactionMap !== "object" || reactionMap === null) { + log.warn( + `Missing reaction_map for the annotated event ${relatedEventId} in ${roomID}` + ); + return; + } + const listenerName = annotation["name"]; + if (typeof listenerName !== "string") { + log.warn( + `The event ${relatedEventId} in ${roomID} is missing the name of the annotation` + ); + return; + } + const association = reactionMap[reactionKey]; + if (association === undefined) { + log.info( + `There wasn't a defined key for ${reactionKey} on event ${relatedEventId} in ${roomID}` + ); + return; } + this.emit( + listenerName, + reactionKey, + association, + annotation["additional_context"], + new Map(Object.entries(reactionMap)), + annotatedEvent + ); + } - /** - * Removes all reactions from the prompt event in an attempt to stop it being used further. - */ - public async cancelPrompt( - promptEvent: RoomEvent, - cancelReason?: string - ): Promise> { - const completeResult = await this.completePrompt( - promptEvent.room_id, - promptEvent.event_id, - cancelReason ?? 'prompt cancelled' - ); - if (isError(completeResult)) { - return completeResult; + /** + * Create the annotation required to setup a listener for when a reaction is encountered for the list. + * @param listenerName The name of the event to emit when a reaction is encountered for a matrix event that matches a key in the `reactionMap`. + * @param reactionMap A map of reaction keys to items that will be provided to the listener. + * @param additionalContext Any additional context that should be associated with a matrix event for the listener. + * @returns An object that should be deep copied into a the content of a new Matrix event. + */ + public createAnnotation( + listenerName: string, + reactionMap: ItemByReactionKey, + additionalContext: Record | undefined = undefined + ): Record { + return { + [REACTION_ANNOTATION_KEY]: { + name: listenerName, + reaction_map: Object.fromEntries(reactionMap), + additional_context: additionalContext, + }, + }; + } + + /** + * Use a reaction map to create the initial reactions to an event so that the user has access to quick reactions. + * @param client A client to add the reactions with. + * @param roomId The room id of the event to add the reactions to. + * @param eventId The event id of the event to add reactions to. + * @param reactionMap The reaction map. + */ + public async addReactionsToEvent( + client: MatrixSendClient, + roomId: string, + eventId: string, + reactionMap: ItemByReactionKey + ): Promise { + await [...reactionMap.keys()] + .reduce( + (acc, key) => + acc.then((_) => + client.unstableApis.addReactionToEvent(roomId, eventId, key) + ), + Promise.resolve() + ) + .catch((e: unknown) => { + if (e instanceof Error) { + log.error(`Could not add reaction to event ${eventId}`, e); + return Promise.reject(e); + } else { + return Promise.reject( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new TypeError(`Something is throwing rubbish ${e}`) + ); } - void this.client.unstableApis.addReactionToEvent( - promptEvent.room_id, - promptEvent.event_id, - `🚫 Cancelled by ${promptEvent.sender}` - ).catch((e: unknown) => { log.error(`Could not send cancelled reaction event for prompt ${promptEvent.event_id} in ${promptEvent.room_id}`, e); }); - return completeResult; - } + }); + } - public static createItemizedReactionMap(items: string[]): ItemByReactionKey { - return items.reduce( - (acc, item, index) => { - const key = MatrixReactionHandler.numberToEmoji(index + 1); - acc.set(key, item); - return acc; - }, - new Map() - ); - } + public async completePrompt( + roomID: StringRoomID, + eventID: StringEventID, + reason?: string + ): Promise> { + const eventRelationsGetter = + this.clientPlatform.toRoomEventRelationsGetter(); + const redacter = this.clientPlatform.toRoomEventRedacter(); + return await eventRelationsGetter.forEachRelation( + roomID, + eventID, + { + relationType: "m.annotation", + eventType: "m.reaction", + forEachCB: (event) => { + const key = event.content?.["m.relates_to"]?.key; + // skip the bots own reactions that mark the event as complete + if (key === "✅" || key === "❌") { + return; + } + void Task( + redacter.redactEvent(roomID, event.event_id, reason) as Promise< + ActionResult + > + ); + }, + } + ); + } - public static numberToEmoji(number: number): string { - // https://github.com/anton-bot/number-to-emoji - // licensed with unlicense. - const key = number.toString(); - return key - .replace(/0/g, '0️⃣') - .replace(/1/g, '1️⃣') - .replace(/2/g, '2️⃣') - .replace(/3/g, '3️⃣') - .replace(/4/g, '4️⃣') - .replace(/5/g, '5️⃣') - .replace(/6/g, '6️⃣') - .replace(/7/g, '7️⃣') - .replace(/8/g, '8️⃣') - .replace(/9/g, '9️⃣'); + /** + * Removes all reactions from the prompt event in an attempt to stop it being used further. + */ + public async cancelPrompt( + promptEvent: RoomEvent, + cancelReason?: string + ): Promise> { + const completeResult = await this.completePrompt( + promptEvent.room_id, + promptEvent.event_id, + cancelReason ?? "prompt cancelled" + ); + if (isError(completeResult)) { + return completeResult; } + void this.client.unstableApis + .addReactionToEvent( + promptEvent.room_id, + promptEvent.event_id, + `🚫 Cancelled by ${promptEvent.sender}` + ) + .catch((e: unknown) => { + log.error( + `Could not send cancelled reaction event for prompt ${promptEvent.event_id} in ${promptEvent.room_id}`, + e + ); + }); + return completeResult; + } + + public static createItemizedReactionMap(items: string[]): ItemByReactionKey { + return items.reduce((acc, item, index) => { + const key = MatrixReactionHandler.numberToEmoji(index + 1); + acc.set(key, item); + return acc; + }, new Map()); + } + + public static numberToEmoji(number: number): string { + // https://github.com/anton-bot/number-to-emoji + // licensed with unlicense. + const key = number.toString(); + return key + .replace(/0/g, "0️⃣") + .replace(/1/g, "1️⃣") + .replace(/2/g, "2️⃣") + .replace(/3/g, "3️⃣") + .replace(/4/g, "4️⃣") + .replace(/5/g, "5️⃣") + .replace(/6/g, "6️⃣") + .replace(/7/g, "7️⃣") + .replace(/8/g, "8️⃣") + .replace(/9/g, "9️⃣"); + } } diff --git a/src/commands/interface-manager/PagedDuplexStream.ts b/src/commands/interface-manager/PagedDuplexStream.ts index 43feec38..ba467d6c 100644 --- a/src/commands/interface-manager/PagedDuplexStream.ts +++ b/src/commands/interface-manager/PagedDuplexStream.ts @@ -13,89 +13,91 @@ import { DocumentNode } from "./DeadDocument"; */ export class PagedDuplexStream { - private buffer = ''; - private pages: string[] = ['']; + private buffer = ""; + private pages: string[] = [""]; - private lastCommittedNode?: DocumentNode; - constructor( - public readonly sizeLimit = 20000 - ) { - } + private lastCommittedNode?: DocumentNode; + constructor(public readonly sizeLimit = 20000) {} - private get currentPage(): string { - const page = this.pages.at(this.pages.length - 1); - if (page === undefined) { - throw new TypeError(`The code tracking this page is all wrong`); - } - return page; + private get currentPage(): string { + const page = this.pages.at(this.pages.length - 1); + if (page === undefined) { + throw new TypeError(`The code tracking this page is all wrong`); } + return page; + } - private appendToCurrentPage(string: string) { - const currentIndex = this.pages.length - 1; - this.pages[currentIndex] = this.currentPage + string; - } + private appendToCurrentPage(string: string) { + const currentIndex = this.pages.length - 1; + this.pages[currentIndex] = this.currentPage + string; + } - public writeString(string: string): this { - this.buffer += string; - return this; - } + public writeString(string: string): this { + this.buffer += string; + return this; + } - public getPosition(): number { - return this.buffer.length; - } + public getPosition(): number { + return this.buffer.length; + } - public isPageAndBufferOverSize(): boolean { - return (this.currentPage.length + this.buffer.length) > this.sizeLimit; - } + public isPageAndBufferOverSize(): boolean { + return this.currentPage.length + this.buffer.length > this.sizeLimit; + } - /** - * Creates a new page from the previously committed text - * @returns A page with all committed text. - */ - public ensureNewPage(): void { - if (this.currentPage.length !== 0) { - this.pages.push(''); - } + /** + * Creates a new page from the previously committed text + * @returns A page with all committed text. + */ + public ensureNewPage(): void { + if (this.currentPage.length !== 0) { + this.pages.push(""); } + } - /** - * Commit the buffered text to the current page. - * If the buffered text is over the `sizeLimit`, then the current - * page will be returned first, and then replaced with a new one in order - * to commit the buffer. - * @param node A DocumentNode to associate with the commit. - * @throws TypeError if the buffer is larger than the `sizeLimit`. - * @returns A page if the buffered text will force the current page to go over the size limit. - */ - public commit(node: DocumentNode): void { - if (this.isPageAndBufferOverSize()) { - if (this.currentPage.length === 0 && (this.buffer.length > this.sizeLimit)) { - throw new TypeError('Commit is too large, could not write a page for this commit'); - } - this.ensureNewPage(); - } - this.appendToCurrentPage(this.buffer); - this.buffer = ''; - this.lastCommittedNode = node; + /** + * Commit the buffered text to the current page. + * If the buffered text is over the `sizeLimit`, then the current + * page will be returned first, and then replaced with a new one in order + * to commit the buffer. + * @param node A DocumentNode to associate with the commit. + * @throws TypeError if the buffer is larger than the `sizeLimit`. + * @returns A page if the buffered text will force the current page to go over the size limit. + */ + public commit(node: DocumentNode): void { + if (this.isPageAndBufferOverSize()) { + if ( + this.currentPage.length === 0 && + this.buffer.length > this.sizeLimit + ) { + throw new TypeError( + "Commit is too large, could not write a page for this commit" + ); + } + this.ensureNewPage(); } + this.appendToCurrentPage(this.buffer); + this.buffer = ""; + this.lastCommittedNode = node; + } - public getLastCommittedNode(): DocumentNode | undefined { - return this.lastCommittedNode; - } + public getLastCommittedNode(): DocumentNode | undefined { + return this.lastCommittedNode; + } - public peekPage(): string | undefined { - // We consider a page "ready" when it is no longer the current page. - if (this.pages.length < 2) { - return undefined; - } - return this.pages.at(0); + public peekPage(): string | undefined { + // We consider a page "ready" when it is no longer the current page. + if (this.pages.length < 2) { + return undefined; } + return this.pages.at(0); + } - public readPage(): string | undefined { - // We consider a page "ready" when it is no longer the current page. - if (this.pages.length < 2) { - return undefined; - } - return this.pages.shift(); + public readPage(): string | undefined { + // We consider a page "ready" when it is no longer the current page. + if (this.pages.length < 2) { + return undefined; } + return this.pages.shift(); + } } diff --git a/src/commands/interface-manager/ParameterParsing.ts b/src/commands/interface-manager/ParameterParsing.ts index 5a2879ad..47a45fc1 100644 --- a/src/commands/interface-manager/ParameterParsing.ts +++ b/src/commands/interface-manager/ParameterParsing.ts @@ -24,37 +24,57 @@ limitations under the License. * are NOT distributed, contributed, or committed under the Apache License. */ -import { ActionError, ActionResult, Ok, ResultError, isError } from "matrix-protection-suite"; -import { Keyword, ReadItem, StandardSuperCoolStream, SuperCoolStream } from "./CommandReader"; +import { + ActionError, + ActionResult, + Ok, + ResultError, + isError, +} from "matrix-protection-suite"; +import { + Keyword, + ReadItem, + StandardSuperCoolStream, + SuperCoolStream, +} from "./CommandReader"; import { PromptOptions } from "./PromptForAccept"; import { PromptRequiredError } from "./PromptRequiredError"; import { printReadably } from "./PrintReadably"; export interface IArgumentStream extends SuperCoolStream { - rest(): ReadItem[], - // All of the read items before the current position. - priorItems(): ReadItem[], - isPromptable(): boolean, - // should prompt really return a new stream? - prompt(parameterDescription: ParameterDescription): Promise>, + rest(): ReadItem[]; + // All of the read items before the current position. + priorItems(): ReadItem[]; + isPromptable(): boolean; + // should prompt really return a new stream? + prompt( + parameterDescription: ParameterDescription + ): Promise>; } -export class ArgumentStream extends StandardSuperCoolStream implements IArgumentStream { - public rest() { - return this.source.slice(this.position); - } - - public priorItems(): ReadItem[] { - return this.source.slice(0, this.position); - } - - public isPromptable(): boolean { - return false; - } - - prompt(_parameterDescription: ParameterDescription): Promise> { - throw new TypeError("This argument stream is NOT promptable, did you even check isPromptable()."); - } +export class ArgumentStream + extends StandardSuperCoolStream + implements IArgumentStream +{ + public rest() { + return this.source.slice(this.position); + } + + public priorItems(): ReadItem[] { + return this.source.slice(0, this.position); + } + + public isPromptable(): boolean { + return false; + } + + prompt( + _parameterDescription: ParameterDescription + ): Promise> { + throw new TypeError( + "This argument stream is NOT promptable, did you even check isPromptable()." + ); + } } // TODO: Presentation types should be extracted to their own file. @@ -63,83 +83,111 @@ export class ArgumentStream extends StandardSuperCoolStream ActionResult; export interface PresentationType { - validator: PredicateIsParameter, - name: string, + validator: PredicateIsParameter; + name: string; } -const PRESENTATION_TYPES = new Map(); +const PRESENTATION_TYPES = new Map< + /* the name of the presentation type. */ string, + PresentationType +>(); export function findPresentationType(name: string): PresentationType { - const entry = PRESENTATION_TYPES.get(name); - if (entry) { - return entry; - } else { - throw new TypeError(`presentation type with the name: ${name} was not registered`); - } + const entry = PRESENTATION_TYPES.get(name); + if (entry) { + return entry; + } else { + throw new TypeError( + `presentation type with the name: ${name} was not registered` + ); + } } -export function registerPresentationType(name: string, presentationType: PresentationType): void { - if (PRESENTATION_TYPES.has(name)) { - throw new TypeError(`presentation type with the name: ${name} has already been registered`); - } - PRESENTATION_TYPES.set(name, presentationType); +export function registerPresentationType( + name: string, + presentationType: PresentationType +): void { + if (PRESENTATION_TYPES.has(name)) { + throw new TypeError( + `presentation type with the name: ${name} has already been registered` + ); + } + PRESENTATION_TYPES.set(name, presentationType); } export function makePresentationType(description: PresentationType) { - registerPresentationType(description.name, description); - return description; + registerPresentationType(description.name, description); + return description; } -export function simpleTypeValidator(name: string, predicate: (readItem: ReadItem) => boolean): PredicateIsParameter { - return (readItem: ReadItem) => { - const result = predicate(readItem); - if (result) { - return Ok(result); - } else { - // How do we accurately denote the type when it includes spaces in its name, same for the read item? - return ActionError.Result(`Was expecting a match for the presentation type: ${name} but got ${printReadably(readItem)}.`); - } +export function simpleTypeValidator( + name: string, + predicate: (readItem: ReadItem) => boolean +): PredicateIsParameter { + return (readItem: ReadItem) => { + const result = predicate(readItem); + if (result) { + return Ok(result); + } else { + // How do we accurately denote the type when it includes spaces in its name, same for the read item? + return ActionError.Result( + `Was expecting a match for the presentation type: ${name} but got ${printReadably(readItem)}.` + ); } + }; } -export function presentationTypeOf(presentation: unknown): PresentationType|undefined { - // We have no concept of presentation-subtype - // But we have a top type which is any... - const candidates = [...PRESENTATION_TYPES.values()] - .filter(possibleType => possibleType.validator(presentation as ReadItem).isOkay - && possibleType.name !== 'any' - ); - if (candidates.length === 0) { - return undefined; - } else if (candidates.length === 1) { - return candidates[0]; - } else { - // until there are subtype semantics we have to fail early so that we have a chance of knowing - // that we have a conflicting type. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new TypeError(`presentationTypeof: There are multiple candidates for the presentation ${presentation}: ${JSON.stringify(candidates.map(c => c.name))}`) - } +export function presentationTypeOf( + presentation: unknown +): PresentationType | undefined { + // We have no concept of presentation-subtype + // But we have a top type which is any... + const candidates = [...PRESENTATION_TYPES.values()].filter( + (possibleType) => + possibleType.validator(presentation as ReadItem).isOkay && + possibleType.name !== "any" + ); + if (candidates.length === 0) { + return undefined; + } else if (candidates.length === 1) { + return candidates[0]; + } else { + throw new TypeError( + // until there are subtype semantics we have to fail early so that we have a chance of knowing + // that we have a conflicting type. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `presentationTypeof: There are multiple candidates for the presentation ${presentation}: ${JSON.stringify(candidates.map((c) => c.name))}` + ); + } } makePresentationType({ - name: "Keyword", - validator: simpleTypeValidator("Keyword", (item: ReadItem) => item instanceof Keyword), + name: "Keyword", + validator: simpleTypeValidator( + "Keyword", + (item: ReadItem) => item instanceof Keyword + ), }); makePresentationType({ - name: 'string', - validator: simpleTypeValidator('string', (item: ReadItem) => typeof item === 'string'), -}) + name: "string", + validator: simpleTypeValidator( + "string", + (item: ReadItem) => typeof item === "string" + ), +}); makePresentationType({ - name: 'boolean', - validator: simpleTypeValidator('boolean', (item: ReadItem) => item === 'true' ? true : item === 'false') -}) + name: "boolean", + validator: simpleTypeValidator("boolean", (item: ReadItem) => + item === "true" ? true : item === "false" + ), +}); makePresentationType({ - name: 'any', - validator: simpleTypeValidator('any', (_item: ReadItem) => true) -}) + name: "any", + validator: simpleTypeValidator("any", (_item: ReadItem) => true), +}); /** * Describes a rest parameter for a command. @@ -148,55 +196,64 @@ makePresentationType({ * * Any keywords in the rest of the command will be given to the `keywordParser`. */ -export class RestDescription implements ParameterDescription { - constructor( - public readonly name: string, - /** The presentation type of each item. */ - public readonly acceptor: PresentationType, - public readonly prompt?: Prompt, - public readonly description?: string, +export class RestDescription + implements ParameterDescription +{ + constructor( + public readonly name: string, + /** The presentation type of each item. */ + public readonly acceptor: PresentationType, + public readonly prompt?: Prompt, + public readonly description?: string + ) {} + + /** + * Parse the rest of a command. + * @param stream An argument stream that starts at the rest of a command. + * @param keywordParser Used to store any keywords found in the rest of the command. + * @returns A ActionResult of ReadItems associated with the rest of the command. + * If a ReadItem or Keyword is invalid for the command, then an error will be returned. + */ + public async parseRest( + stream: IArgumentStream, + promptForRest: boolean, + keywordParser: KeywordParser + ): Promise> { + const items: ReadItem[] = []; + if ( + this.prompt && + promptForRest && + stream.isPromptable() && + stream.peekItem(undefined) === undefined ) { - - } - - /** - * Parse the rest of a command. - * @param stream An argument stream that starts at the rest of a command. - * @param keywordParser Used to store any keywords found in the rest of the command. - * @returns A ActionResult of ReadItems associated with the rest of the command. - * If a ReadItem or Keyword is invalid for the command, then an error will be returned. - */ - public async parseRest(stream: IArgumentStream, promptForRest: boolean, keywordParser: KeywordParser): Promise> { - const items: ReadItem[] = []; - if (this.prompt && promptForRest && stream.isPromptable() && stream.peekItem(undefined) === undefined) { - return PromptRequiredError.Result( - `A prompt is required for the missing argument for the ${this.name} parameter`, - { - promptParameter: this, - stream, - } - ); + return PromptRequiredError.Result( + `A prompt is required for the missing argument for the ${this.name} parameter`, + { + promptParameter: this, + stream, } - while (stream.peekItem(undefined) !== undefined) { - const keywordResult = keywordParser.parseKeywords(stream); - if (isError(keywordResult)) { - return keywordResult; - } - const keywordValue = stream.peekItem(undefined); - if (keywordValue !== undefined) { - const validationResult = this.acceptor.validator(keywordValue); - if (isError(validationResult)) { - return ArgumentParseError.Result( - validationResult.error.message, - { parameter: this, stream } - ); - } - items.push(keywordValue); - stream.readItem(); // dispose of keyword's associated value from the stream. - } + ); + } + while (stream.peekItem(undefined) !== undefined) { + const keywordResult = keywordParser.parseKeywords(stream); + if (isError(keywordResult)) { + return keywordResult; + } + const keywordValue = stream.peekItem(undefined); + if (keywordValue !== undefined) { + const validationResult = this.acceptor.validator(keywordValue); + if (isError(validationResult)) { + return ArgumentParseError.Result(validationResult.error.message, { + parameter: this, + stream, + }); } - return Ok(items); + items.push(keywordValue); + stream.readItem(); // dispose of keyword's associated value from the stream. + } } + return Ok(items); + } } /** @@ -204,7 +261,7 @@ export class RestDescription implements ParameterDesc * argument that can be accepted by a command. */ interface KeywordArgumentsDescription { - readonly [prop: string]: KeywordPropertyDescription|undefined; + readonly [prop: string]: KeywordPropertyDescription | undefined; } /** @@ -213,51 +270,52 @@ interface KeywordArgumentsDescription { * and their presence is to associate the value `true`. */ interface KeywordPropertyDescription extends ParameterDescription { - readonly isFlag: boolean; + readonly isFlag: boolean; } /** * Describes all of the keyword arguments for a command. */ export class KeywordsDescription { - constructor( - public readonly description: KeywordArgumentsDescription, - public readonly allowOtherKeys?: boolean, - ) { - - } - - /** - * @returns A parser that will create a map of all keywords and their associated properties. - */ - public getParser(): KeywordParser { - return new KeywordParser(this); - } + constructor( + public readonly description: KeywordArgumentsDescription, + public readonly allowOtherKeys?: boolean + ) {} + + /** + * @returns A parser that will create a map of all keywords and their associated properties. + */ + public getParser(): KeywordParser { + return new KeywordParser(this); + } } /** * A read only map of keywords to their associated properties. */ export class ParsedKeywords { - constructor ( - private readonly descriptions: KeywordArgumentsDescription, - private readonly keywords: ReadonlyMap - ) { - + constructor( + private readonly descriptions: KeywordArgumentsDescription, + private readonly keywords: ReadonlyMap + ) {} + + public getKeyword( + keyword: string, + defaultValue: T | undefined = undefined + ): T | undefined { + const keywordDescription = this.descriptions[keyword]; + if (keywordDescription === undefined) { + throw new TypeError( + `${keyword} is not a keyword that has been expected for this command.` + ); } - - public getKeyword(keyword: string, defaultValue: T|undefined = undefined): T|undefined { - const keywordDescription = this.descriptions[keyword]; - if (keywordDescription === undefined) { - throw new TypeError(`${keyword} is not a keyword that has been expected for this command.`); - } - const value = this.keywords.get(keyword); - if (value !== undefined) { - return value as T; - } else { - return defaultValue; - } + const value = this.keywords.get(keyword); + if (value !== undefined) { + return value as T; + } else { + return defaultValue; } + } } /** @@ -265,118 +323,148 @@ export class ParsedKeywords { * the map representing the association between keywords and their properties. */ class KeywordParser { - private readonly arguments = new Map(); - - constructor( - public readonly description: KeywordsDescription - ) { - } + private readonly arguments = new Map(); - public getKeywords(): ParsedKeywords { - return new ParsedKeywords(this.description.description, this.arguments); - } + constructor(public readonly description: KeywordsDescription) {} + public getKeywords(): ParsedKeywords { + return new ParsedKeywords(this.description.description, this.arguments); + } - private readKeywordAssociatedProperty(keyword: KeywordPropertyDescription, itemStream: IArgumentStream): ActionResult { - if (itemStream.peekItem() !== undefined && !(itemStream.peekItem() instanceof Keyword)) { - const validationResult = keyword.acceptor.validator(itemStream.peekItem()); - if (validationResult.isOkay) { - return Ok(itemStream.readItem()); - } else { - return ArgumentParseError.Result(validationResult.error.message, { parameter: keyword, stream: itemStream }); - } - } else { - if (!keyword.isFlag) { - return ArgumentParseError.Result(`An associated argument was not provided for the keyword ${keyword.name}.`, { parameter: keyword, stream: itemStream }); - } else { - return Ok(true); - } - } + private readKeywordAssociatedProperty( + keyword: KeywordPropertyDescription, + itemStream: IArgumentStream + ): ActionResult { + if ( + itemStream.peekItem() !== undefined && + !(itemStream.peekItem() instanceof Keyword) + ) { + const validationResult = keyword.acceptor.validator( + itemStream.peekItem() + ); + if (validationResult.isOkay) { + return Ok(itemStream.readItem()); + } else { + return ArgumentParseError.Result(validationResult.error.message, { + parameter: keyword, + stream: itemStream, + }); + } + } else { + if (!keyword.isFlag) { + return ArgumentParseError.Result( + `An associated argument was not provided for the keyword ${keyword.name}.`, + { parameter: keyword, stream: itemStream } + ); + } else { + return Ok(true); + } } + } - public parseKeywords(itemStream: IArgumentStream): ActionResult { - while (itemStream.peekItem() !== undefined && itemStream.peekItem() instanceof Keyword) { - const item = itemStream.readItem() as Keyword; - const description = this.description.description[item.designator]; - if (description === undefined) { - if (this.description.allowOtherKeys) { - throw new TypeError("Allow other keys is umimplemented"); - // i don't think this can be implemented, - // how do you tell an extra key is a flag or has an associated - // property? - } else { - return UnexpectedArgumentError.Result( - `Encountered unexpected keyword argument: ${item.designator}`, - { stream: itemStream } - ); - } - } else { - const associatedPropertyResult = this.readKeywordAssociatedProperty(description, itemStream); - if (isError(associatedPropertyResult)) { - return associatedPropertyResult; - } else { - this.arguments.set(description.name, associatedPropertyResult.ok); - } - } - + public parseKeywords(itemStream: IArgumentStream): ActionResult { + while ( + itemStream.peekItem() !== undefined && + itemStream.peekItem() instanceof Keyword + ) { + const item = itemStream.readItem() as Keyword; + const description = this.description.description[item.designator]; + if (description === undefined) { + if (this.description.allowOtherKeys) { + throw new TypeError("Allow other keys is umimplemented"); + // i don't think this can be implemented, + // how do you tell an extra key is a flag or has an associated + // property? + } else { + return UnexpectedArgumentError.Result( + `Encountered unexpected keyword argument: ${item.designator}`, + { stream: itemStream } + ); } - return Ok(this); - } - - public async parseRest(stream: IArgumentStream, shouldPromptForRest = false, restDescription?: RestDescription): Promise> { - if (restDescription !== undefined) { - return await restDescription.parseRest(stream, shouldPromptForRest, this) + } else { + const associatedPropertyResult = this.readKeywordAssociatedProperty( + description, + itemStream + ); + if (isError(associatedPropertyResult)) { + return associatedPropertyResult; } else { - const result = this.parseKeywords(stream); - if (isError(result)) { - return result; - } - if (stream.peekItem() !== undefined) { - return ActionError.Result(`There is an unexpected non-keyword argument ${JSON.stringify(stream.peekItem())}`); - } else { - return Ok(undefined); - } + this.arguments.set(description.name, associatedPropertyResult.ok); } + } + } + return Ok(this); + } + + public async parseRest( + stream: IArgumentStream, + shouldPromptForRest = false, + restDescription?: RestDescription + ): Promise> { + if (restDescription !== undefined) { + return await restDescription.parseRest(stream, shouldPromptForRest, this); + } else { + const result = this.parseKeywords(stream); + if (isError(result)) { + return result; + } + if (stream.peekItem() !== undefined) { + return ActionError.Result( + `There is an unexpected non-keyword argument ${JSON.stringify(stream.peekItem())}` + ); + } else { + return Ok(undefined); + } } + } } export interface ParsedArguments { - readonly immediateArguments: ReadItem[], - readonly rest?: ReadItem[], - readonly keywords: ParsedKeywords, + readonly immediateArguments: ReadItem[]; + readonly rest?: ReadItem[]; + readonly keywords: ParsedKeywords; } -export type Prompt = (this: ExecutorContext, description: ParameterDescription) => Promise; +export type Prompt = ( + this: ExecutorContext, + description: ParameterDescription +) => Promise; export interface ParameterDescription { - name: string, - description?: string | undefined, - acceptor: PresentationType, - /** - * Prompt the interface for an argument that was not provided. - * @param this Expected to be the executor context that is used to provided to the command executor. - * @param description The parameter description being accepted. - * @returns PromptOptions, to be handled by the interface adaptor. - */ - prompt?: Prompt | undefined, + name: string; + description?: string | undefined; + acceptor: PresentationType; + /** + * Prompt the interface for an argument that was not provided. + * @param this Expected to be the executor context that is used to provided to the command executor. + * @param description The parameter description being accepted. + * @returns PromptOptions, to be handled by the interface adaptor. + */ + prompt?: Prompt | undefined; } -export type ParameterParser = (stream: IArgumentStream) => Promise>; +export type ParameterParser = ( + stream: IArgumentStream +) => Promise>; // So this should really just be something used by defineInterfaceCommand which turns parameters into a validator that can be used. // It can't be, because then otherwise how does the semantics for union work? // We should have a new type of ActionResult that accepts a ParamterDescription, and can render what's wrong (e.g. missing parameter). // Showing where in the item stream it is missing and the command syntax and everything lovely like that. // How does that work with Union? -export function parameters(descriptions: ParameterDescription[], rest: undefined|RestDescription = undefined, keywords: KeywordsDescription = new KeywordsDescription({}, false)): IArgumentListParser { - return new ArgumentListParser(descriptions, keywords, rest); +export function parameters( + descriptions: ParameterDescription[], + rest: undefined | RestDescription = undefined, + keywords: KeywordsDescription = new KeywordsDescription({}, false) +): IArgumentListParser { + return new ArgumentListParser(descriptions, keywords, rest); } export interface IArgumentListParser { - readonly parse: ParameterParser, - readonly descriptions: ParameterDescription[], - readonly rest?: RestDescription | undefined, - readonly keywords: KeywordsDescription, + readonly parse: ParameterParser; + readonly descriptions: ParameterDescription[]; + readonly rest?: RestDescription | undefined; + readonly keywords: KeywordsDescription; } /** @@ -384,90 +472,113 @@ export interface IArgumentListParser { * before being able to invoke a command. */ class ArgumentListParser implements IArgumentListParser { - constructor( - public readonly descriptions: ParameterDescription[], - public readonly keywords: KeywordsDescription, - public readonly rest?: RestDescription | undefined, - ) { - } - - public async parse(stream: IArgumentStream): Promise> { - const hasPrompted = false; - const keywordsParser = this.keywords.getParser(); - for (const parameter of this.descriptions) { - // it eats any keywords at any point in the stream - // as they can appear at any point technically. - const keywordResult = keywordsParser.parseKeywords(stream); - if (isError(keywordResult)) { - return keywordResult; - } - if (stream.peekItem() === undefined) { - if (parameter.prompt && stream.isPromptable()) { - return PromptRequiredError.Result( - `A prompt is required for the parameter ${parameter.name}`, - { - promptParameter: parameter, - stream - } - ); - } else { - return ArgumentParseError.Result( - `An argument for the parameter ${parameter.name} was expected but was not provided.`, - { parameter, stream } - ); - } + constructor( + public readonly descriptions: ParameterDescription[], + public readonly keywords: KeywordsDescription, + public readonly rest?: RestDescription | undefined + ) {} + + public async parse( + stream: IArgumentStream + ): Promise> { + const hasPrompted = false; + const keywordsParser = this.keywords.getParser(); + for (const parameter of this.descriptions) { + // it eats any keywords at any point in the stream + // as they can appear at any point technically. + const keywordResult = keywordsParser.parseKeywords(stream); + if (isError(keywordResult)) { + return keywordResult; + } + if (stream.peekItem() === undefined) { + if (parameter.prompt && stream.isPromptable()) { + return PromptRequiredError.Result( + `A prompt is required for the parameter ${parameter.name}`, + { + promptParameter: parameter, + stream, } - const result = parameter.acceptor.validator(stream.peekItem()); - if (isError(result)) { - return ArgumentParseError.Result(result.error.message, { parameter, stream }); - } - stream.readItem(); - } - const restResult = await keywordsParser.parseRest(stream, hasPrompted, this.rest); - if (isError(restResult)) { - return restResult; + ); + } else { + return ArgumentParseError.Result( + `An argument for the parameter ${parameter.name} was expected but was not provided.`, + { parameter, stream } + ); } - const immediateArguments = restResult.ok === undefined - || restResult.ok.length === 0 || restResult.ok[0] === undefined - ? stream.source - : stream.source.slice(0, stream.source.indexOf(restResult.ok[0])) - return Ok({ - immediateArguments: immediateArguments, - keywords: keywordsParser.getKeywords(), - rest: restResult.ok ?? [] + } + const result = parameter.acceptor.validator(stream.peekItem()); + if (isError(result)) { + return ArgumentParseError.Result(result.error.message, { + parameter, + stream, }); + } + stream.readItem(); } + const restResult = await keywordsParser.parseRest( + stream, + hasPrompted, + this.rest + ); + if (isError(restResult)) { + return restResult; + } + const immediateArguments = + restResult.ok === undefined || + restResult.ok.length === 0 || + restResult.ok[0] === undefined + ? stream.source + : stream.source.slice(0, stream.source.indexOf(restResult.ok[0])); + return Ok({ + immediateArguments: immediateArguments, + keywords: keywordsParser.getKeywords(), + rest: restResult.ok ?? [], + }); + } } export class AbstractArgumentParseError extends ActionError { - constructor( - public readonly stream: IArgumentStream, - message: string) { - super(message) - } - - public static Result(message: string, options: { stream: IArgumentStream }): ActionResult { - return ResultError(new AbstractArgumentParseError(options.stream, message)); - } + constructor( + public readonly stream: IArgumentStream, + message: string + ) { + super(message); + } + + public static Result( + message: string, + options: { stream: IArgumentStream } + ): ActionResult { + return ResultError(new AbstractArgumentParseError(options.stream, message)); + } } export class ArgumentParseError extends AbstractArgumentParseError { - constructor( - public readonly parameter: ParameterDescription, - stream: IArgumentStream, - message: string) { - super(stream, message) - } - - public static Result(message: string, options: { parameter: ParameterDescription, stream: IArgumentStream }): ActionResult { - return ResultError(new ArgumentParseError(options.parameter, options.stream, message)); - } + constructor( + public readonly parameter: ParameterDescription, + stream: IArgumentStream, + message: string + ) { + super(stream, message); + } + + public static Result( + message: string, + options: { parameter: ParameterDescription; stream: IArgumentStream } + ): ActionResult { + return ResultError( + new ArgumentParseError(options.parameter, options.stream, message) + ); + } } export class UnexpectedArgumentError extends AbstractArgumentParseError { - public static Result(message: string, options: { stream: IArgumentStream }): ActionResult { - return ResultError(new UnexpectedArgumentError(options.stream, message)); - } + public static Result( + message: string, + options: { stream: IArgumentStream } + ): ActionResult { + return ResultError(new UnexpectedArgumentError(options.stream, message)); + } } /** @@ -475,16 +586,20 @@ export class UnexpectedArgumentError extends AbstractArgumentParseError { * these are specific to applications e.g. imagine you want to resolve an alias or something. * It oculd also work by making an anonymous presentation type, but dunno about that. */ -export function union(...presentationTypes: PresentationType[]): PresentationType { - const name = presentationTypes.map(type => type.name).join(" | "); - return { - name, - validator: (readItem: ReadItem) => { - if (presentationTypes.some(p => p.validator(readItem).isOkay)) { - return Ok(true); - } else { - return ActionError.Result(`Read item didn't match any of the presentaiton types ${name}`); - } - } - } +export function union( + ...presentationTypes: PresentationType[] +): PresentationType { + const name = presentationTypes.map((type) => type.name).join(" | "); + return { + name, + validator: (readItem: ReadItem) => { + if (presentationTypes.some((p) => p.validator(readItem).isOkay)) { + return Ok(true); + } else { + return ActionError.Result( + `Read item didn't match any of the presentaiton types ${name}` + ); + } + }, + }; } diff --git a/src/commands/interface-manager/PrintReadably.ts b/src/commands/interface-manager/PrintReadably.ts index 682ac493..4e968c74 100644 --- a/src/commands/interface-manager/PrintReadably.ts +++ b/src/commands/interface-manager/PrintReadably.ts @@ -2,22 +2,36 @@ // // SPDX-License-Identifier: AFL-3.0 -import { MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomAlias, MatrixRoomID, Permalinks, UserID } from "matrix-protection-suite"; +import { + MatrixEventViaAlias, + MatrixEventViaRoomID, + MatrixRoomAlias, + MatrixRoomID, + Permalinks, + UserID, +} from "matrix-protection-suite"; import { ReadItem } from "./CommandReader"; export function printReadably(item: ReadItem): string { - if (item instanceof MatrixRoomID || item instanceof MatrixRoomAlias) { - return item.toPermalink(); - } else if (item instanceof UserID) { - return item.toString(); - } else if (item instanceof MatrixEventViaAlias || item instanceof MatrixEventViaRoomID) { - return Permalinks.forEvent(item.reference.toRoomIDOrAlias(), item.eventID, item.reference.getViaServers()); - } else { - // doesn't feel great that we can go to [Object object] from this line - // At the moment, ReadItems implement their behaviour with toString which - // obviously sucks because we can't just erorr here when we get something - // that is likely going to print [Object object]. - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return item.toString() - } + if (item instanceof MatrixRoomID || item instanceof MatrixRoomAlias) { + return item.toPermalink(); + } else if (item instanceof UserID) { + return item.toString(); + } else if ( + item instanceof MatrixEventViaAlias || + item instanceof MatrixEventViaRoomID + ) { + return Permalinks.forEvent( + item.reference.toRoomIDOrAlias(), + item.eventID, + item.reference.getViaServers() + ); + } else { + // doesn't feel great that we can go to [Object object] from this line + // At the moment, ReadItems implement their behaviour with toString which + // obviously sucks because we can't just erorr here when we get something + // that is likely going to print [Object object]. + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return item.toString(); + } } diff --git a/src/commands/interface-manager/PromptForAccept.ts b/src/commands/interface-manager/PromptForAccept.ts index 02c745fc..5459f8c4 100644 --- a/src/commands/interface-manager/PromptForAccept.ts +++ b/src/commands/interface-manager/PromptForAccept.ts @@ -7,8 +7,8 @@ import { ReadItem } from "./CommandReader"; import { ArgumentStream } from "./ParameterParsing"; export interface PromptOptions { - readonly suggestions: PresentationType[] - readonly default?: PresentationType + readonly suggestions: PresentationType[]; + readonly default?: PresentationType; } /** @@ -16,22 +16,22 @@ export interface PromptOptions { * to derive the prompt, or use the prompt given by the ParameterDescription. */ export interface InterfaceAcceptor { - readonly isPromptable: boolean + readonly isPromptable: boolean; } export class PromptableArgumentStream extends ArgumentStream { - constructor( - source: ReadItem[], - private readonly interfaceAcceptor: InterfaceAcceptor, - start = 0, - ) { - super([...source], start); - } - public rest() { - return this.source.slice(this.position); - } + constructor( + source: ReadItem[], + private readonly interfaceAcceptor: InterfaceAcceptor, + start = 0 + ) { + super([...source], start); + } + public rest() { + return this.source.slice(this.position); + } - public isPromptable(): boolean { - return this.interfaceAcceptor.isPromptable - } + public isPromptable(): boolean { + return this.interfaceAcceptor.isPromptable; + } } diff --git a/src/commands/interface-manager/PromptRequiredError.ts b/src/commands/interface-manager/PromptRequiredError.ts index fea7fb56..70e8cef3 100644 --- a/src/commands/interface-manager/PromptRequiredError.ts +++ b/src/commands/interface-manager/PromptRequiredError.ts @@ -3,29 +3,38 @@ * All rights reserved. */ -import { ActionError, ActionResult, ResultError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + ResultError, +} from "matrix-protection-suite"; import { IArgumentStream, ParameterDescription } from "./ParameterParsing"; import { ReadItem } from "./CommandReader"; export interface PromptContext { - items: string[], - designator: string[] + items: string[]; + designator: string[]; } export class PromptRequiredError extends ActionError { - constructor( - message: string, - context: string[], - public readonly parameterRequiringPrompt: ParameterDescription, - public readonly priorItems: ReadItem[] - ) { - super(message, context); - } + constructor( + message: string, + context: string[], + public readonly parameterRequiringPrompt: ParameterDescription, + public readonly priorItems: ReadItem[] + ) { + super(message, context); + } - public static Result( - message: string, - { promptParameter, stream }: { promptParameter: ParameterDescription, stream: IArgumentStream } - ): ActionResult { - return ResultError(new PromptRequiredError(message, [], promptParameter, stream.priorItems())); - } + public static Result( + message: string, + { + promptParameter, + stream, + }: { promptParameter: ParameterDescription; stream: IArgumentStream } + ): ActionResult { + return ResultError( + new PromptRequiredError(message, [], promptParameter, stream.priorItems()) + ); + } } diff --git a/src/config.ts b/src/config.ts index 27db20fc..81a375c6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,281 +40,302 @@ import path from "path"; // from the file specified by `NODE_ENV`, e.g. production.yaml // or harness.yaml. export interface IConfig { - homeserverUrl: string; - rawHomeserverUrl: string; - accessToken: string; - pantalaimon: { - use: boolean; - username: string; - password: string; + homeserverUrl: string; + rawHomeserverUrl: string; + accessToken: string; + pantalaimon: { + use: boolean; + username: string; + password: string; + }; + dataPath: string; + /** + * If true, Mjolnir will only accept invites from users present in managementRoom. + * Otherwise a space must be provided to `acceptInvitesFromSpace`. + */ + autojoinOnlyIfManager: boolean; + /** Mjolnir will accept invites from members of this space if `autojoinOnlyIfManager` is false. */ + acceptInvitesFromSpace: string | undefined; + recordIgnoredInvites: boolean; + managementRoom: string; + verboseLogging: boolean; + logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; + logMutedModules: string[]; + syncOnStartup: boolean; + verifyPermissionsOnStartup: boolean; + disableServerACL: boolean; + noop: boolean; + protectedRooms: string[]; // matrix.to urls + fasterMembershipChecks: boolean; + automaticallyRedactForReasons: string[]; // case-insensitive globs + protectAllJoinedRooms: boolean; + /** + * Backgrounded tasks: number of milliseconds to wait between the completion + * of one background task and the start of the next one. + */ + backgroundDelayMS: number; + pollReports: boolean; + /** + * Whether or not new reports, received either by webapi or polling, + * should be printed to our managementRoom. + */ + displayReports: boolean; + admin?: { + enableMakeRoomAdminCommand?: boolean; + }; + commands: { + allowNoPrefix: boolean; + additionalPrefixes: string[]; + confirmWildcardBan: boolean; + features: string[]; + ban: { + defaultReasons: string[]; }; - dataPath: string; - /** - * If true, Mjolnir will only accept invites from users present in managementRoom. - * Otherwise a space must be provided to `acceptInvitesFromSpace`. - */ - autojoinOnlyIfManager: boolean; - /** Mjolnir will accept invites from members of this space if `autojoinOnlyIfManager` is false. */ - acceptInvitesFromSpace: string | undefined; - recordIgnoredInvites: boolean; - managementRoom: string; - verboseLogging: boolean; - logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; - logMutedModules: string[], - syncOnStartup: boolean; - verifyPermissionsOnStartup: boolean; - disableServerACL: boolean; - noop: boolean; - protectedRooms: string[]; // matrix.to urls - fasterMembershipChecks: boolean; - automaticallyRedactForReasons: string[]; // case-insensitive globs - protectAllJoinedRooms: boolean; - /** - * Backgrounded tasks: number of milliseconds to wait between the completion - * of one background task and the start of the next one. - */ - backgroundDelayMS: number; - pollReports: boolean; - /** - * Whether or not new reports, received either by webapi or polling, - * should be printed to our managementRoom. - */ - displayReports: boolean; - admin?: { - enableMakeRoomAdminCommand?: boolean; - } - commands: { - allowNoPrefix: boolean; - additionalPrefixes: string[]; - confirmWildcardBan: boolean; - features: string[]; - ban: { - defaultReasons: string[] - } + }; + protections: { + wordlist: { + words: string[]; + minutesBeforeTrusting: number; }; - protections: { - wordlist: { - words: string[]; - minutesBeforeTrusting: number; - }; + }; + health: { + healthz: { + enabled: boolean; + port: number; + address: string; + endpoint: string; + healthyStatus: number; + unhealthyStatus: number; }; - health: { - healthz: { - enabled: boolean; - port: number; - address: string; - endpoint: string; - healthyStatus: number; - unhealthyStatus: number; - }; - // If specified, attempt to upload any crash statistics to sentry. - sentry?: { - dsn: string; + // If specified, attempt to upload any crash statistics to sentry. + sentry?: { + dsn: string; - // Frequency of performance monitoring. - // - // A number in [0.0, 1.0], where 0.0 means "don't bother with tracing" - // and 1.0 means "trace performance at every opportunity". - tracesSampleRate: number; - }; + // Frequency of performance monitoring. + // + // A number in [0.0, 1.0], where 0.0 means "don't bother with tracing" + // and 1.0 means "trace performance at every opportunity". + tracesSampleRate: number; }; - web: { - enabled: boolean; - port: number; - address: string; - abuseReporting: { - enabled: boolean; - } + }; + web: { + enabled: boolean; + port: number; + address: string; + abuseReporting: { + enabled: boolean; }; - // Store room state using sqlite to improve startup time when Synapse responds - // slowly to requests for `/state`. - roomStateBackingStore: { - enabled?: boolean; - }; - // Experimental usage of the matrix-bot-sdk rust crypto. - // This can not be used with Pantalaimon. - experimentalRustCrypto: boolean; + }; + // Store room state using sqlite to improve startup time when Synapse responds + // slowly to requests for `/state`. + roomStateBackingStore: { + enabled?: boolean; + }; + // Experimental usage of the matrix-bot-sdk rust crypto. + // This can not be used with Pantalaimon. + experimentalRustCrypto: boolean; - /** - * Config options only set at runtime. Try to avoid using the objects - * here as much as possible. - */ - RUNTIME: { - client?: MatrixClient; - }; + /** + * Config options only set at runtime. Try to avoid using the objects + * here as much as possible. + */ + RUNTIME: { + client?: MatrixClient; + }; } const defaultConfig: IConfig = { - homeserverUrl: "http://localhost:8008", - rawHomeserverUrl: "http://localhost:8008", - accessToken: "NONE_PROVIDED", - pantalaimon: { - use: false, - username: "", - password: "", - }, - dataPath: "/data/storage", - acceptInvitesFromSpace: '!noop:example.org', - autojoinOnlyIfManager: true, - recordIgnoredInvites: false, - managementRoom: "!noop:example.org", - verboseLogging: false, - logLevel: "INFO", - logMutedModules: ['MatrixHttpClient', 'MatrixClientLite'], - syncOnStartup: true, - verifyPermissionsOnStartup: true, - noop: false, - disableServerACL: false, - protectedRooms: [], - fasterMembershipChecks: false, - automaticallyRedactForReasons: ["spam", "advertising"], - protectAllJoinedRooms: false, - backgroundDelayMS: 500, - pollReports: false, - displayReports: true, - commands: { - allowNoPrefix: false, - additionalPrefixes: ["draupnir"], - confirmWildcardBan: true, - features: [ - "synapse admin", - ], - ban: { - defaultReasons: [ - "spam", - "brigading", - "harassment", - "disagreement", - ] - } + homeserverUrl: "http://localhost:8008", + rawHomeserverUrl: "http://localhost:8008", + accessToken: "NONE_PROVIDED", + pantalaimon: { + use: false, + username: "", + password: "", + }, + dataPath: "/data/storage", + acceptInvitesFromSpace: "!noop:example.org", + autojoinOnlyIfManager: true, + recordIgnoredInvites: false, + managementRoom: "!noop:example.org", + verboseLogging: false, + logLevel: "INFO", + logMutedModules: ["MatrixHttpClient", "MatrixClientLite"], + syncOnStartup: true, + verifyPermissionsOnStartup: true, + noop: false, + disableServerACL: false, + protectedRooms: [], + fasterMembershipChecks: false, + automaticallyRedactForReasons: ["spam", "advertising"], + protectAllJoinedRooms: false, + backgroundDelayMS: 500, + pollReports: false, + displayReports: true, + commands: { + allowNoPrefix: false, + additionalPrefixes: ["draupnir"], + confirmWildcardBan: true, + features: ["synapse admin"], + ban: { + defaultReasons: ["spam", "brigading", "harassment", "disagreement"], }, - protections: { - wordlist: { - words: [], - minutesBeforeTrusting: 20 - } + }, + protections: { + wordlist: { + words: [], + minutesBeforeTrusting: 20, }, - health: { - healthz: { - enabled: false, - port: 8080, - address: "0.0.0.0", - endpoint: "/healthz", - healthyStatus: 200, - unhealthyStatus: 418, - }, + }, + health: { + healthz: { + enabled: false, + port: 8080, + address: "0.0.0.0", + endpoint: "/healthz", + healthyStatus: 200, + unhealthyStatus: 418, }, - web: { - enabled: false, - port: 8080, - address: "localhost", - abuseReporting: { - enabled: false, - }, + }, + web: { + enabled: false, + port: 8080, + address: "localhost", + abuseReporting: { + enabled: false, }, - roomStateBackingStore: { - enabled: false, - }, - experimentalRustCrypto: false, + }, + roomStateBackingStore: { + enabled: false, + }, + experimentalRustCrypto: false, - // Needed to make the interface happy. - RUNTIME: { - }, + // Needed to make the interface happy. + RUNTIME: {}, }; export function getDefaultConfig(): IConfig { - return Config.util.cloneDeep(defaultConfig); + return Config.util.cloneDeep(defaultConfig); } /** * @returns The users's raw config, deep copied over the `defaultConfig`. */ function readConfigSource(): IConfig { - const explicitConfigPath = getCommandLineOption(process.argv, "--draupnir-config"); - if (explicitConfigPath !== undefined) { - const content = fs.readFileSync(explicitConfigPath, "utf8"); - const parsed = load(content); - return Config.util.extendDeep({}, defaultConfig, parsed); - } else { - return Config.util.extendDeep({}, defaultConfig, Config.util.toObject()) as IConfig; - } + const explicitConfigPath = getCommandLineOption( + process.argv, + "--draupnir-config" + ); + if (explicitConfigPath !== undefined) { + const content = fs.readFileSync(explicitConfigPath, "utf8"); + const parsed = load(content); + return Config.util.extendDeep({}, defaultConfig, parsed); + } else { + return Config.util.extendDeep( + {}, + defaultConfig, + Config.util.toObject() + ) as IConfig; + } } export function read(): IConfig { - const config = readConfigSource(); - const explicitAccessTokenPath = getCommandLineOption(process.argv, "--access-token-path"); - const explicitPantalaimonPasswordPath = getCommandLineOption(process.argv, "--pantalaimon-password-path"); - if (explicitAccessTokenPath !== undefined) { - config.accessToken = fs.readFileSync(explicitAccessTokenPath, "utf8"); - } - if (explicitPantalaimonPasswordPath) { - config.pantalaimon.password = fs.readFileSync(explicitPantalaimonPasswordPath, "utf8"); - } - return config; + const config = readConfigSource(); + const explicitAccessTokenPath = getCommandLineOption( + process.argv, + "--access-token-path" + ); + const explicitPantalaimonPasswordPath = getCommandLineOption( + process.argv, + "--pantalaimon-password-path" + ); + if (explicitAccessTokenPath !== undefined) { + config.accessToken = fs.readFileSync(explicitAccessTokenPath, "utf8"); + } + if (explicitPantalaimonPasswordPath) { + config.pantalaimon.password = fs.readFileSync( + explicitPantalaimonPasswordPath, + "utf8" + ); + } + return config; } - /** * Provides a config for each newly provisioned mjolnir in appservice mode. * @param managementRoomId A room that has been created to serve as the mjolnir's management room for the owner. * @returns A config that can be directly used by the new mjolnir. */ export function getProvisionedMjolnirConfig(managementRoomId: string): IConfig { - // These are keys that are allowed to be configured for provisioned mjolnirs. - // We need a restricted set so that someone doesn't accidentally enable webservers etc - // on every created Mjolnir, which would result in very confusing error messages. - const allowedKeys = [ - "commands", - "verboseLogging", - "logLevel", - "syncOnStartup", - "verifyPermissionsOnStartup", - "fasterMembershipChecks", - "automaticallyRedactForReasons", - "protectAllJoinedRooms", - "backgroundDelayMS", - ]; - const configTemplate = read(); // we use the standard bot config as a template for every provisioned mjolnir. - const unusedKeys = Object.keys(configTemplate).filter(key => !allowedKeys.includes(key)); - if (unusedKeys.length > 0) { - LogService.warn("config", "The config provided for provisioned mjolnirs contains keys which are not used by the appservice.", unusedKeys); - } - const config = Config.util.extendDeep( - getDefaultConfig(), - allowedKeys.reduce((existingConfig, key) => { - return { ...existingConfig, [key]: configTemplate[key as keyof IConfig] } - }, {}) + // These are keys that are allowed to be configured for provisioned mjolnirs. + // We need a restricted set so that someone doesn't accidentally enable webservers etc + // on every created Mjolnir, which would result in very confusing error messages. + const allowedKeys = [ + "commands", + "verboseLogging", + "logLevel", + "syncOnStartup", + "verifyPermissionsOnStartup", + "fasterMembershipChecks", + "automaticallyRedactForReasons", + "protectAllJoinedRooms", + "backgroundDelayMS", + ]; + const configTemplate = read(); // we use the standard bot config as a template for every provisioned mjolnir. + const unusedKeys = Object.keys(configTemplate).filter( + (key) => !allowedKeys.includes(key) + ); + if (unusedKeys.length > 0) { + LogService.warn( + "config", + "The config provided for provisioned mjolnirs contains keys which are not used by the appservice.", + unusedKeys ); + } + const config = Config.util.extendDeep( + getDefaultConfig(), + allowedKeys.reduce((existingConfig, key) => { + return { ...existingConfig, [key]: configTemplate[key as keyof IConfig] }; + }, {}) + ); - config.managementRoom = managementRoomId; - config.protectedRooms = []; - return config; + config.managementRoom = managementRoomId; + config.protectedRooms = []; + return config; } export const PACKAGE_JSON = (() => { - try { - return JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8')); - } catch (e) { - LogService.error("config", "Could not read Draupnir package.json", e); - return {}; - } + try { + return JSON.parse( + fs.readFileSync(path.join(__dirname, "../package.json"), "utf-8") + ); + } catch (e) { + LogService.error("config", "Could not read Draupnir package.json", e); + return {}; + } })(); export const SOFTWARE_VERSION = (() => { - let versionFile; - const defaultText = PACKAGE_JSON.version ?? "A version was either not provided when building Draupnir or could not be read."; - try { - versionFile = fs.readFileSync(path.join(__dirname, '../version.txt'), 'utf-8'); - } catch (e) { - LogService.error("config", "Could not read Draupnir version", e); - versionFile = defaultText; - } - // it's important to ignore the newline if the version is going to be put - // into
               or  where it will create an unnecessary newline.
              -    return /^(.*)$/m.exec(versionFile)?.at(0) ?? defaultText;
              +  let versionFile;
              +  const defaultText =
              +    PACKAGE_JSON.version ??
              +    "A version was either not provided when building Draupnir or could not be read.";
              +  try {
              +    versionFile = fs.readFileSync(
              +      path.join(__dirname, "../version.txt"),
              +      "utf-8"
              +    );
              +  } catch (e) {
              +    LogService.error("config", "Could not read Draupnir version", e);
              +    versionFile = defaultText;
              +  }
              +  // it's important to ignore the newline if the version is going to be put
              +  // into 
               or  where it will create an unnecessary newline.
              +  return /^(.*)$/m.exec(versionFile)?.at(0) ?? defaultText;
               })();
               
              -export const DOCUMENTATION_URL = "https://the-draupnir-project.github.io/draupnir-documentation/";
              +export const DOCUMENTATION_URL =
              +  "https://the-draupnir-project.github.io/draupnir-documentation/";
               
               // Command line related functions
               
              @@ -324,8 +345,11 @@ export const DOCUMENTATION_URL = "https://the-draupnir-project.github.io/draupni
                * @param optionName Option name
                * @returns True if the option is present, otherwise false.
                */
              -function isCommandLineOptionPresent(args: string[], optionName: string): boolean {
              -    return args.includes(optionName);
              +function isCommandLineOptionPresent(
              +  args: string[],
              +  optionName: string
              +): boolean {
              +  return args.includes(optionName);
               }
               
               /**
              @@ -335,20 +359,26 @@ function isCommandLineOptionPresent(args: string[], optionName: string): boolean
                * @returns The value passed to the option, or undefined if the option is not specified.
                * @throws Error if the option is present but has no value.
                */
              -function getCommandLineOption(args: string[], optionName: string): string | undefined {
              -    // We don't want to throw if the option is not present
              -    if(!isCommandLineOptionPresent(args, optionName)) {
              -        return undefined;
              -    }
              +function getCommandLineOption(
              +  args: string[],
              +  optionName: string
              +): string | undefined {
              +  // We don't want to throw if the option is not present
              +  if (!isCommandLineOptionPresent(args, optionName)) {
              +    return undefined;
              +  }
               
              -    const optionIndex = args.indexOf(optionName);
              +  const optionIndex = args.indexOf(optionName);
               
              -    //check if the next index is not an option
              -    const associatedArgument = args[optionIndex + 1];
              -    if (associatedArgument !== undefined && !associatedArgument.startsWith("--")){
              -        return associatedArgument;
              -    }
              +  //check if the next index is not an option
              +  const associatedArgument = args[optionIndex + 1];
              +  if (
              +    associatedArgument !== undefined &&
              +    !associatedArgument.startsWith("--")
              +  ) {
              +    return associatedArgument;
              +  }
               
              -    // No value was provided, or the next argument is another option
              -    throw new Error(`No value provided for ${optionName}`);
              +  // No value was provided, or the next argument is another option
              +  throw new Error(`No value provided for ${optionName}`);
               }
              diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts
              index 541d0467..34d49010 100644
              --- a/src/draupnirfactory/DraupnirFactory.ts
              +++ b/src/draupnirfactory/DraupnirFactory.ts
              @@ -3,62 +3,84 @@
                * All rights reserved.
                */
               
              -import { ActionResult, ClientsInRoomMap, MatrixRoomID, StandardLoggableConfigTracker, StringUserID, isError } from "matrix-protection-suite";
              +import {
              +  ActionResult,
              +  ClientsInRoomMap,
              +  MatrixRoomID,
              +  StandardLoggableConfigTracker,
              +  StringUserID,
              +  isError,
              +} from "matrix-protection-suite";
               import { Draupnir } from "../Draupnir";
              -import { ClientCapabilityFactory, ClientForUserID, RoomStateManagerFactory, joinedRoomsSafe } from "matrix-protection-suite-for-matrix-bot-sdk";
              +import {
              +  ClientCapabilityFactory,
              +  ClientForUserID,
              +  RoomStateManagerFactory,
              +  joinedRoomsSafe,
              +} from "matrix-protection-suite-for-matrix-bot-sdk";
               import { IConfig } from "../config";
               import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet";
               
               export class DraupnirFactory {
              -    public constructor(
              -        private readonly clientsInRoomMap: ClientsInRoomMap,
              -        private readonly clientCapabilityFactory: ClientCapabilityFactory,
              -        private readonly clientProvider: ClientForUserID,
              -        private readonly roomStateManagerFactory: RoomStateManagerFactory
              -    ) {
              -        // nothing to do.
              -    }
              +  public constructor(
              +    private readonly clientsInRoomMap: ClientsInRoomMap,
              +    private readonly clientCapabilityFactory: ClientCapabilityFactory,
              +    private readonly clientProvider: ClientForUserID,
              +    private readonly roomStateManagerFactory: RoomStateManagerFactory
              +  ) {
              +    // nothing to do.
              +  }
               
              -    public async makeDraupnir(clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig): Promise> {
              -        const roomStateManager = await this.roomStateManagerFactory.getRoomStateManager(clientUserID);
              -        const policyRoomManager = await this.roomStateManagerFactory.getPolicyRoomManager(clientUserID);
              -        const roomMembershipManager = await this.roomStateManagerFactory.getRoomMembershipManager(clientUserID);
              -        const client = await this.clientProvider(clientUserID);
              -        const clientRooms = await this.clientsInRoomMap.makeClientRooms(
              -            clientUserID,
              -            async () => joinedRoomsSafe(client),
              -        );
              -        if (isError(clientRooms)) {
              -            return clientRooms;
              -        }
              -        const clientPlatform = this.clientCapabilityFactory.makeClientPlatform(clientUserID, client);
              -        const configLogTracker = new StandardLoggableConfigTracker();
              -        const protectedRoomsSet = await makeProtectedRoomsSet(
              -            managementRoom,
              -            roomStateManager,
              -            policyRoomManager,
              -            roomMembershipManager,
              -            client,
              -            clientPlatform,
              -            clientUserID,
              -            config,
              -            configLogTracker
              -        );
              -        if (isError(protectedRoomsSet)) {
              -            return protectedRoomsSet;
              -        }
              -        return await Draupnir.makeDraupnirBot(
              -            client,
              -            clientUserID,
              -            clientPlatform,
              -            managementRoom,
              -            clientRooms.ok,
              -            protectedRoomsSet.ok,
              -            roomStateManager,
              -            policyRoomManager,
              -            roomMembershipManager,
              -            config,
              -            configLogTracker
              -        );
              +  public async makeDraupnir(
              +    clientUserID: StringUserID,
              +    managementRoom: MatrixRoomID,
              +    config: IConfig
              +  ): Promise> {
              +    const roomStateManager =
              +      await this.roomStateManagerFactory.getRoomStateManager(clientUserID);
              +    const policyRoomManager =
              +      await this.roomStateManagerFactory.getPolicyRoomManager(clientUserID);
              +    const roomMembershipManager =
              +      await this.roomStateManagerFactory.getRoomMembershipManager(clientUserID);
              +    const client = await this.clientProvider(clientUserID);
              +    const clientRooms = await this.clientsInRoomMap.makeClientRooms(
              +      clientUserID,
              +      async () => joinedRoomsSafe(client)
              +    );
              +    if (isError(clientRooms)) {
              +      return clientRooms;
              +    }
              +    const clientPlatform = this.clientCapabilityFactory.makeClientPlatform(
              +      clientUserID,
              +      client
              +    );
              +    const configLogTracker = new StandardLoggableConfigTracker();
              +    const protectedRoomsSet = await makeProtectedRoomsSet(
              +      managementRoom,
              +      roomStateManager,
              +      policyRoomManager,
              +      roomMembershipManager,
              +      client,
              +      clientPlatform,
              +      clientUserID,
              +      config,
              +      configLogTracker
              +    );
              +    if (isError(protectedRoomsSet)) {
              +      return protectedRoomsSet;
                   }
              +    return await Draupnir.makeDraupnirBot(
              +      client,
              +      clientUserID,
              +      clientPlatform,
              +      managementRoom,
              +      clientRooms.ok,
              +      protectedRoomsSet.ok,
              +      roomStateManager,
              +      policyRoomManager,
              +      roomMembershipManager,
              +      config,
              +      configLogTracker
              +    );
              +  }
               }
              diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts
              index d22afd22..052dabba 100644
              --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts
              +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts
              @@ -25,152 +25,202 @@ limitations under the License.
                * are NOT distributed, contributed, committed, or licensed under the Apache License.
                */
               
              -import { ActionResult, ClientPlatform, LoggableConfigTracker, Logger, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MatrixRoomID, MissingProtectionCB, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsManager, RoomJoiner, RoomMembershipManager, RoomResolver, RoomStateManager, StandardProtectedRoomsManager, StandardProtectedRoomsSet, StandardProtectionsManager, StandardSetMembership, StandardSetRoomState, StringUserID, isError } from "matrix-protection-suite";
              -import { BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
              +import {
              +  ActionResult,
              +  ClientPlatform,
              +  LoggableConfigTracker,
              +  Logger,
              +  MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
              +  MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE,
              +  MatrixRoomID,
              +  MissingProtectionCB,
              +  MjolnirEnabledProtectionsEvent,
              +  MjolnirEnabledProtectionsEventType,
              +  MjolnirPolicyRoomsConfig,
              +  MjolnirProtectedRoomsConfig,
              +  MjolnirProtectedRoomsEvent,
              +  MjolnirProtectionSettingsEventType,
              +  MjolnirProtectionsConfig,
              +  MjolnirWatchedPolicyRoomsEvent,
              +  Ok,
              +  PolicyListConfig,
              +  PolicyRoomManager,
              +  ProtectedRoomsConfig,
              +  ProtectedRoomsSet,
              +  ProtectionsManager,
              +  RoomJoiner,
              +  RoomMembershipManager,
              +  RoomResolver,
              +  RoomStateManager,
              +  StandardProtectedRoomsManager,
              +  StandardProtectedRoomsSet,
              +  StandardProtectionsManager,
              +  StandardSetMembership,
              +  StandardSetRoomState,
              +  StringUserID,
              +  isError,
              +} from "matrix-protection-suite";
              +import {
              +  BotSDKMatrixAccountData,
              +  BotSDKMatrixStateData,
              +  MatrixSendClient,
              +} from "matrix-protection-suite-for-matrix-bot-sdk";
               import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration";
              -import '../protections/DraupnirProtectionsIndex';
              +import "../protections/DraupnirProtectionsIndex";
               import { IConfig } from "../config";
               import { runProtectionConfigHooks } from "../protections/ConfigHooks";
               import { makeHandleMissingProtectionPermissions } from "../protections/MissingProtectionPermissions";
               
              -const log = new Logger('DraupnirProtectedRoomsSet');
              +const log = new Logger("DraupnirProtectedRoomsSet");
               
               async function makePolicyListConfig(
              -    client: MatrixSendClient,
              -    policyRoomManager: PolicyRoomManager,
              -    roomJoiner: RoomJoiner,
              +  client: MatrixSendClient,
              +  policyRoomManager: PolicyRoomManager,
              +  roomJoiner: RoomJoiner
               ): Promise> {
              -    const result = await MjolnirPolicyRoomsConfig.createFromStore(
              -        new BotSDKMatrixAccountData(
              -            MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE,
              -            MjolnirWatchedPolicyRoomsEvent,
              -            client
              -        ),
              -        policyRoomManager,
              -        roomJoiner
              -    );
              -    return result;
              +  const result = await MjolnirPolicyRoomsConfig.createFromStore(
              +    new BotSDKMatrixAccountData(
              +      MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE,
              +      MjolnirWatchedPolicyRoomsEvent,
              +      client
              +    ),
              +    policyRoomManager,
              +    roomJoiner
              +  );
              +  return result;
               }
               
               async function makeProtectedRoomsConfig(
              -    client: MatrixSendClient,
              -    roomResolver: RoomResolver,
              -    loggableConfigTracker: LoggableConfigTracker
              +  client: MatrixSendClient,
              +  roomResolver: RoomResolver,
              +  loggableConfigTracker: LoggableConfigTracker
               ): Promise> {
              -    return await MjolnirProtectedRoomsConfig.createFromStore(
              -        new BotSDKMatrixAccountData(
              -            MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
              -            MjolnirProtectedRoomsEvent,
              -            client
              -        ),
              -        roomResolver,
              -        loggableConfigTracker
              -    );
              +  return await MjolnirProtectedRoomsConfig.createFromStore(
              +    new BotSDKMatrixAccountData(
              +      MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
              +      MjolnirProtectedRoomsEvent,
              +      client
              +    ),
              +    roomResolver,
              +    loggableConfigTracker
              +  );
               }
               
               function missingProtectionCB(protectionName: string): void {
              -    log.warn(`Unable to find a protection description for the protection named`, protectionName);
              +  log.warn(
              +    `Unable to find a protection description for the protection named`,
              +    protectionName
              +  );
               }
               
               // FIXME: https://github.com/the-draupnir-project/Draupnir/issues/338
               function makeMissingProtectionCB(): MissingProtectionCB {
              -    return missingProtectionCB
              +  return missingProtectionCB;
               }
               
               async function makeProtectionsManager(
              -    client: MatrixSendClient,
              -    roomStateManager: RoomStateManager,
              -    managementRoom: MatrixRoomID,
              -    config: IConfig,
              -    loggableConfigTracker: LoggableConfigTracker
              +  client: MatrixSendClient,
              +  roomStateManager: RoomStateManager,
              +  managementRoom: MatrixRoomID,
              +  config: IConfig,
              +  loggableConfigTracker: LoggableConfigTracker
               ): Promise> {
              -    const result = await roomStateManager.getRoomStateRevisionIssuer(
              -        managementRoom
              -    );
              -    if (isError(result)) {
              -        return result;
              -    }
              -    const protectionsConfigResult = await MjolnirProtectionsConfig.create(
              -        new BotSDKMatrixAccountData(
              -            MjolnirEnabledProtectionsEventType,
              -            MjolnirEnabledProtectionsEvent,
              -            client
              -        ),
              -        loggableConfigTracker,
              -        {
              -            migrationHandler: DefaultEnabledProtectionsMigration,
              -            missingProtectionCB: makeMissingProtectionCB()
              -        }
              -    );
              -    if (isError(protectionsConfigResult)) {
              -        return protectionsConfigResult;
              -    }
              -    const hookResult = await runProtectionConfigHooks(config, protectionsConfigResult.ok);
              -    if (isError(hookResult)) {
              -        return hookResult;
              +  const result =
              +    await roomStateManager.getRoomStateRevisionIssuer(managementRoom);
              +  if (isError(result)) {
              +    return result;
              +  }
              +  const protectionsConfigResult = await MjolnirProtectionsConfig.create(
              +    new BotSDKMatrixAccountData(
              +      MjolnirEnabledProtectionsEventType,
              +      MjolnirEnabledProtectionsEvent,
              +      client
              +    ),
              +    loggableConfigTracker,
              +    {
              +      migrationHandler: DefaultEnabledProtectionsMigration,
              +      missingProtectionCB: makeMissingProtectionCB(),
                   }
              -    return Ok(
              -        new StandardProtectionsManager(
              -            protectionsConfigResult.ok,
              -            new BotSDKMatrixStateData(
              -                MjolnirProtectionSettingsEventType,
              -                result.ok,
              -                client
              -            )
              -        )
              -    );
              +  );
              +  if (isError(protectionsConfigResult)) {
              +    return protectionsConfigResult;
              +  }
              +  const hookResult = await runProtectionConfigHooks(
              +    config,
              +    protectionsConfigResult.ok
              +  );
              +  if (isError(hookResult)) {
              +    return hookResult;
              +  }
              +  return Ok(
              +    new StandardProtectionsManager(
              +      protectionsConfigResult.ok,
              +      new BotSDKMatrixStateData(
              +        MjolnirProtectionSettingsEventType,
              +        result.ok,
              +        client
              +      )
              +    )
              +  );
               }
               
              -
               export async function makeProtectedRoomsSet(
              -    managementRoom: MatrixRoomID,
              -    roomStateManager: RoomStateManager,
              -    policyRoomManager: PolicyRoomManager,
              -    roomMembershipManager: RoomMembershipManager,
              -    client: MatrixSendClient,
              -    clientPlatform: ClientPlatform,
              -    userID: StringUserID,
              -    config: IConfig,
              -    loggableConfigTracker: LoggableConfigTracker,
              +  managementRoom: MatrixRoomID,
              +  roomStateManager: RoomStateManager,
              +  policyRoomManager: PolicyRoomManager,
              +  roomMembershipManager: RoomMembershipManager,
              +  client: MatrixSendClient,
              +  clientPlatform: ClientPlatform,
              +  userID: StringUserID,
              +  config: IConfig,
              +  loggableConfigTracker: LoggableConfigTracker
               ): Promise> {
              -    const protectedRoomsConfig = await makeProtectedRoomsConfig(client, clientPlatform.toRoomResolver(), loggableConfigTracker)
              -    if (isError(protectedRoomsConfig)) {
              -        return protectedRoomsConfig;
              -    }
              -    const protectedRoomsManager = await StandardProtectedRoomsManager.create(
              -        protectedRoomsConfig.ok,
              -        roomStateManager,
              -        roomMembershipManager,
              -        clientPlatform.toRoomJoiner(),
              -        StandardSetMembership.blankSet(),
              -        StandardSetRoomState.blankSet(),
              -    );
              -    if (isError(protectedRoomsManager)) {
              -        return protectedRoomsManager;
              -    }
              -    const policyListConfig = await makePolicyListConfig(client, policyRoomManager, clientPlatform.toRoomJoiner());
              -    if (isError(policyListConfig)) {
              -        return policyListConfig;
              -    }
              -    const protectionsConfig = await makeProtectionsManager(
              -        client,
              -        roomStateManager,
              -        managementRoom,
              -        config,
              -        loggableConfigTracker
              -    );
              -    if (isError(protectionsConfig)) {
              -        return protectionsConfig;
              -    }
              -    const protectedRoomsSet = new StandardProtectedRoomsSet(
              -        policyListConfig.ok,
              -        protectedRoomsManager.ok,
              -        protectionsConfig.ok,
              -        userID,
              -        makeHandleMissingProtectionPermissions(
              -            client,
              -            managementRoom.toRoomIDOrAlias()
              -        )
              -    );
              -    return Ok(protectedRoomsSet);
              +  const protectedRoomsConfig = await makeProtectedRoomsConfig(
              +    client,
              +    clientPlatform.toRoomResolver(),
              +    loggableConfigTracker
              +  );
              +  if (isError(protectedRoomsConfig)) {
              +    return protectedRoomsConfig;
              +  }
              +  const protectedRoomsManager = await StandardProtectedRoomsManager.create(
              +    protectedRoomsConfig.ok,
              +    roomStateManager,
              +    roomMembershipManager,
              +    clientPlatform.toRoomJoiner(),
              +    StandardSetMembership.blankSet(),
              +    StandardSetRoomState.blankSet()
              +  );
              +  if (isError(protectedRoomsManager)) {
              +    return protectedRoomsManager;
              +  }
              +  const policyListConfig = await makePolicyListConfig(
              +    client,
              +    policyRoomManager,
              +    clientPlatform.toRoomJoiner()
              +  );
              +  if (isError(policyListConfig)) {
              +    return policyListConfig;
              +  }
              +  const protectionsConfig = await makeProtectionsManager(
              +    client,
              +    roomStateManager,
              +    managementRoom,
              +    config,
              +    loggableConfigTracker
              +  );
              +  if (isError(protectionsConfig)) {
              +    return protectionsConfig;
              +  }
              +  const protectedRoomsSet = new StandardProtectedRoomsSet(
              +    policyListConfig.ok,
              +    protectedRoomsManager.ok,
              +    protectionsConfig.ok,
              +    userID,
              +    makeHandleMissingProtectionPermissions(
              +      client,
              +      managementRoom.toRoomIDOrAlias()
              +    )
              +  );
              +  return Ok(protectedRoomsSet);
               }
              diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts
              index bc91c87a..2f545827 100644
              --- a/src/draupnirfactory/StandardDraupnirManager.ts
              +++ b/src/draupnirfactory/StandardDraupnirManager.ts
              @@ -25,118 +25,136 @@ limitations under the License.
                * are NOT distributed, contributed, committed, or licensed under the Apache License.
                */
               
              -import { ActionError, ActionResult, MatrixRoomID, StringUserID, Task, isError } from "matrix-protection-suite";
              +import {
              +  ActionError,
              +  ActionResult,
              +  MatrixRoomID,
              +  StringUserID,
              +  Task,
              +  isError,
              +} from "matrix-protection-suite";
               import { IConfig } from "../config";
               import { DraupnirFactory } from "./DraupnirFactory";
               import { Draupnir } from "../Draupnir";
               
               export class StandardDraupnirManager {
              -    private readonly readyDraupnirs = new Map();
              -    private readonly listeningDraupnirs = new Map();
              -    private readonly failedDraupnirs = new Map();
              -
              -    public constructor(
              -        protected readonly draupnirFactory: DraupnirFactory
              -    ) {
              -        // nothing to do.
              +  private readonly readyDraupnirs = new Map();
              +  private readonly listeningDraupnirs = new Map();
              +  private readonly failedDraupnirs = new Map();
              +
              +  public constructor(protected readonly draupnirFactory: DraupnirFactory) {
              +    // nothing to do.
              +  }
              +
              +  public async makeDraupnir(
              +    clientUserID: StringUserID,
              +    managementRoom: MatrixRoomID,
              +    config: IConfig
              +  ): Promise> {
              +    const draupnir = await this.draupnirFactory.makeDraupnir(
              +      clientUserID,
              +      managementRoom,
              +      config
              +    );
              +    if (this.isDraupnirReady(clientUserID)) {
              +      return ActionError.Result(
              +        `There is a draupnir for ${clientUserID} already waiting to be started`
              +      );
              +    } else if (this.isDraupnirListening(clientUserID)) {
              +      return ActionError.Result(
              +        `There is a draupnir for ${clientUserID} already running`
              +      );
                   }
              -
              -    public async makeDraupnir(
              -        clientUserID: StringUserID,
              -        managementRoom: MatrixRoomID,
              -        config: IConfig
              -    ): Promise> {
              -        const draupnir = await this.draupnirFactory.makeDraupnir(
              -            clientUserID,
              -            managementRoom,
              -            config
              -        );
              -        if (this.isDraupnirReady(clientUserID)) {
              -            return ActionError.Result(`There is a draupnir for ${clientUserID} already waiting to be started`);
              -        } else if (this.isDraupnirListening(clientUserID)) {
              -            return ActionError.Result(`There is a draupnir for ${clientUserID} already running`);
              -        }
              -        if (isError(draupnir)) {
              -            this.reportUnstartedDraupnir(
              -                DraupnirFailType.InitializationError,
              -                draupnir.error,
              -                clientUserID
              -            );
              -            return draupnir;
              -        }
              -        this.readyDraupnirs.set(clientUserID, draupnir.ok);
              -        this.failedDraupnirs.delete(clientUserID);
              -        return draupnir;
              -    }
              -
              -    public isDraupnirReady(draupnirClientID: StringUserID): boolean {
              -        return this.readyDraupnirs.has(draupnirClientID);
              -    }
              -
              -    public isDraupnirListening(draupnirClientID: StringUserID): boolean {
              -        return this.listeningDraupnirs.has(draupnirClientID);
              -    }
              -
              -    public isDraupnirFailed(draupnirClientID: StringUserID): boolean {
              -        return this.failedDraupnirs.has(draupnirClientID);
              -    }
              -
              -    public reportUnstartedDraupnir(failType: DraupnirFailType, cause: unknown, draupnirClientID: StringUserID): void {
              -        this.failedDraupnirs.set(draupnirClientID, new UnstartedDraupnir(draupnirClientID, failType, cause));
              +    if (isError(draupnir)) {
              +      this.reportUnstartedDraupnir(
              +        DraupnirFailType.InitializationError,
              +        draupnir.error,
              +        clientUserID
              +      );
              +      return draupnir;
                   }
              -
              -    public getUnstartedDraupnirs(): UnstartedDraupnir[] {
              -        return [...this.failedDraupnirs.values()];
              +    this.readyDraupnirs.set(clientUserID, draupnir.ok);
              +    this.failedDraupnirs.delete(clientUserID);
              +    return draupnir;
              +  }
              +
              +  public isDraupnirReady(draupnirClientID: StringUserID): boolean {
              +    return this.readyDraupnirs.has(draupnirClientID);
              +  }
              +
              +  public isDraupnirListening(draupnirClientID: StringUserID): boolean {
              +    return this.listeningDraupnirs.has(draupnirClientID);
              +  }
              +
              +  public isDraupnirFailed(draupnirClientID: StringUserID): boolean {
              +    return this.failedDraupnirs.has(draupnirClientID);
              +  }
              +
              +  public reportUnstartedDraupnir(
              +    failType: DraupnirFailType,
              +    cause: unknown,
              +    draupnirClientID: StringUserID
              +  ): void {
              +    this.failedDraupnirs.set(
              +      draupnirClientID,
              +      new UnstartedDraupnir(draupnirClientID, failType, cause)
              +    );
              +  }
              +
              +  public getUnstartedDraupnirs(): UnstartedDraupnir[] {
              +    return [...this.failedDraupnirs.values()];
              +  }
              +
              +  public findUnstartedDraupnir(
              +    draupnirClientID: StringUserID
              +  ): UnstartedDraupnir | undefined {
              +    return this.failedDraupnirs.get(draupnirClientID);
              +  }
              +
              +  public findRunningDraupnir(
              +    draupnirClientID: StringUserID
              +  ): Draupnir | undefined {
              +    return this.listeningDraupnirs.get(draupnirClientID);
              +  }
              +
              +  public startDraupnir(clientUserID: StringUserID): void {
              +    const draupnir = this.readyDraupnirs.get(clientUserID);
              +    if (draupnir === undefined) {
              +      throw new TypeError(
              +        `Trying to start a draupnir that hasn't been created ${clientUserID}`
              +      );
                   }
              -
              -    public findUnstartedDraupnir(draupnirClientID: StringUserID): UnstartedDraupnir | undefined {
              -        return this.failedDraupnirs.get(draupnirClientID);
              -    }
              -
              -    public findRunningDraupnir(draupnirClientID: StringUserID): Draupnir | undefined {
              -        return this.listeningDraupnirs.get(draupnirClientID);
              -    }
              -
              -    public startDraupnir(
              -        clientUserID: StringUserID
              -    ): void {
              -        const draupnir = this.readyDraupnirs.get(clientUserID);
              -        if (draupnir === undefined) {
              -            throw new TypeError(`Trying to start a draupnir that hasn't been created ${clientUserID}`);
              -        }
              -        // FIXME: This is a little more than suspect that there are no handlers if starting fails?
              -        // unclear to me what can fail though.
              -        void Task(draupnir.start());
              -        this.listeningDraupnirs.set(clientUserID, draupnir);
              -        this.readyDraupnirs.delete(clientUserID);
              -    }
              -
              -    public stopDraupnir(
              -        clientUserID: StringUserID
              -    ): void {
              -        const draupnir = this.listeningDraupnirs.get(clientUserID);
              -        if (draupnir === undefined) {
              -            return;
              -        } else {
              -            draupnir.stop();
              -            this.listeningDraupnirs.delete(clientUserID);
              -            this.readyDraupnirs.set(clientUserID, draupnir);
              -        }
              +    // FIXME: This is a little more than suspect that there are no handlers if starting fails?
              +    // unclear to me what can fail though.
              +    void Task(draupnir.start());
              +    this.listeningDraupnirs.set(clientUserID, draupnir);
              +    this.readyDraupnirs.delete(clientUserID);
              +  }
              +
              +  public stopDraupnir(clientUserID: StringUserID): void {
              +    const draupnir = this.listeningDraupnirs.get(clientUserID);
              +    if (draupnir === undefined) {
              +      return;
              +    } else {
              +      draupnir.stop();
              +      this.listeningDraupnirs.delete(clientUserID);
              +      this.readyDraupnirs.set(clientUserID, draupnir);
                   }
              +  }
               }
               
               export class UnstartedDraupnir {
              -    constructor(
              -        public readonly clientUserID: StringUserID,
              -        public readonly failType: DraupnirFailType,
              -        public readonly cause: unknown,
              -    ) {
              -        // nothing to do.
              -    }
              +  constructor(
              +    public readonly clientUserID: StringUserID,
              +    public readonly failType: DraupnirFailType,
              +    public readonly cause: unknown
              +  ) {
              +    // nothing to do.
              +  }
               }
               
               export enum DraupnirFailType {
              -    Unauthorized = "Unauthorized",
              -    StartError = "StartError",
              -    InitializationError = "InitializationError",
              +  Unauthorized = "Unauthorized",
              +  StartError = "StartError",
              +  InitializationError = "InitializationError",
               }
              diff --git a/src/health/healthz.ts b/src/health/healthz.ts
              index 20909b18..8dad2479 100644
              --- a/src/health/healthz.ts
              +++ b/src/health/healthz.ts
              @@ -31,25 +31,34 @@ import { IConfig } from "../config";
               // allowed to use the global configuration since this is only intended to be used by `src/index.ts`.
               
               export class Healthz {
              -    private healthCode: number;
              -
              -    constructor(private config: IConfig) { }
              -
              -    public set isHealthy(val: boolean) {
              -        this.healthCode = val ? this.config.health.healthz.healthyStatus : this.config.health.healthz.unhealthyStatus;
              -    }
              -
              -    public get isHealthy(): boolean {
              -        return this.healthCode === this.config.health.healthz.healthyStatus;
              -    }
              -
              -    public listen() {
              -        const server = http.createServer((req, res) => {
              -            res.writeHead(this.healthCode);
              -            res.end(`health code: ${this.healthCode}`);
              -        });
              -        server.listen(this.config.health.healthz.port, this.config.health.healthz.address, () => {
              -            LogService.info("Healthz", `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}`);
              -        });
              -    }
              +  private healthCode: number;
              +
              +  constructor(private config: IConfig) {}
              +
              +  public set isHealthy(val: boolean) {
              +    this.healthCode = val
              +      ? this.config.health.healthz.healthyStatus
              +      : this.config.health.healthz.unhealthyStatus;
              +  }
              +
              +  public get isHealthy(): boolean {
              +    return this.healthCode === this.config.health.healthz.healthyStatus;
              +  }
              +
              +  public listen() {
              +    const server = http.createServer((req, res) => {
              +      res.writeHead(this.healthCode);
              +      res.end(`health code: ${this.healthCode}`);
              +    });
              +    server.listen(
              +      this.config.health.healthz.port,
              +      this.config.health.healthz.address,
              +      () => {
              +        LogService.info(
              +          "Healthz",
              +          `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}`
              +        );
              +      }
              +    );
              +  }
               }
              diff --git a/src/index.ts b/src/index.ts
              index 5fcaa42b..eb13a148 100644
              --- a/src/index.ts
              +++ b/src/index.ts
              @@ -30,84 +30,119 @@ import * as path from "path";
               import { Healthz } from "./health/healthz";
               
               import {
              -    LogLevel,
              -    LogService,
              -    MatrixClient,
              -    PantalaimonClient,
              -    RichConsoleLogger,
              -    SimpleFsStorageProvider,
              -    RustSdkCryptoStorageProvider
              +  LogLevel,
              +  LogService,
              +  MatrixClient,
              +  PantalaimonClient,
              +  RichConsoleLogger,
              +  SimpleFsStorageProvider,
              +  RustSdkCryptoStorageProvider,
               } from "matrix-bot-sdk";
               import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs";
               import { read as configRead } from "./config";
               import { initializeSentry, patchMatrixClient } from "./utils";
              -import { constructWebAPIs, makeDraupnirBotModeFromConfig } from "./DraupnirBotMode";
              +import {
              +  constructWebAPIs,
              +  makeDraupnirBotModeFromConfig,
              +} from "./DraupnirBotMode";
               import { Draupnir } from "./Draupnir";
               import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk";
               import { DefaultEventDecoder } from "matrix-protection-suite";
               import { WebAPIs } from "./webapis/WebAPIs";
               import { SqliteRoomStateBackingStore } from "./backingstore/better-sqlite3/SqliteRoomStateBackingStore";
               
              -
               void (async function () {
              -    const config = configRead();
              -
              -    config.RUNTIME = {};
              -
              -    LogService.setLogger(new RichConsoleLogger());
              -    LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
              -
              -    LogService.info("index", "Starting bot...");
              -
              -    // Initialize error reporting as early as possible.
              -    if (config.health.sentry) {
              -        initializeSentry(config);
              -    }
              -    const healthz = new Healthz(config);
              -    healthz.isHealthy = false; // start off unhealthy
              -    if (config.health.healthz.enabled) {
              -        healthz.listen();
              -    }
              -
              -    let bot: Draupnir | null = null;
              -    let apis: WebAPIs | null = null;
              -    try {
              -        const storagePath = path.isAbsolute(config.dataPath) ? config.dataPath : path.join(__dirname, '../', config.dataPath);
              -        const storage = new SimpleFsStorageProvider(path.join(storagePath, "bot.json"));
              -
              -        let client: MatrixClient;
              -        if (config.pantalaimon.use && !config.experimentalRustCrypto) {
              -            const pantalaimon = new PantalaimonClient(config.homeserverUrl, storage);
              -            client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password);
              -        } else if (config.experimentalRustCrypto) {
              -            if (config.pantalaimon.use) {
              -                throw Error("You have a pantalaimon config activated and experimentalRustCrypto. Make sure the accessToken is set and pantalaimon is disabled!");
              -            }
              -            const cryptoStorage = new RustSdkCryptoStorageProvider(path.join(storagePath, "crypto"), StoreType.Sqlite);
              -
              -            client = new MatrixClient(config.homeserverUrl, config.accessToken, storage, cryptoStorage);
              -        } else {
              -            client = new MatrixClient(config.homeserverUrl, config.accessToken, storage);
              -        }
              -        patchMatrixClient();
              -        config.RUNTIME.client = client;
              -        const eventDecoder = DefaultEventDecoder;
              -        const store = config.roomStateBackingStore.enabled ? new SqliteRoomStateBackingStore(path.join(config.dataPath, 'room-state-backing-store.db'), eventDecoder) : undefined;
              -        bot = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, eventDecoder), config, store);
              -        apis = constructWebAPIs(bot);
              -    } catch (err) {
              -        console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`);
              -        throw err;
              -    }
              -    try {
              -        await bot.start();
              -        await config.RUNTIME.client.start();
              -        await apis.start();
              -        healthz.isHealthy = true;
              -    } catch (err) {
              -        console.error(`Mjolnir failed to start: ${err}`);
              -        bot.stop();
              -        apis.stop();
              -        throw err;
              +  const config = configRead();
              +
              +  config.RUNTIME = {};
              +
              +  LogService.setLogger(new RichConsoleLogger());
              +  LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
              +
              +  LogService.info("index", "Starting bot...");
              +
              +  // Initialize error reporting as early as possible.
              +  if (config.health.sentry) {
              +    initializeSentry(config);
              +  }
              +  const healthz = new Healthz(config);
              +  healthz.isHealthy = false; // start off unhealthy
              +  if (config.health.healthz.enabled) {
              +    healthz.listen();
              +  }
              +
              +  let bot: Draupnir | null = null;
              +  let apis: WebAPIs | null = null;
              +  try {
              +    const storagePath = path.isAbsolute(config.dataPath)
              +      ? config.dataPath
              +      : path.join(__dirname, "../", config.dataPath);
              +    const storage = new SimpleFsStorageProvider(
              +      path.join(storagePath, "bot.json")
              +    );
              +
              +    let client: MatrixClient;
              +    if (config.pantalaimon.use && !config.experimentalRustCrypto) {
              +      const pantalaimon = new PantalaimonClient(config.homeserverUrl, storage);
              +      client = await pantalaimon.createClientWithCredentials(
              +        config.pantalaimon.username,
              +        config.pantalaimon.password
              +      );
              +    } else if (config.experimentalRustCrypto) {
              +      if (config.pantalaimon.use) {
              +        throw Error(
              +          "You have a pantalaimon config activated and experimentalRustCrypto. Make sure the accessToken is set and pantalaimon is disabled!"
              +        );
              +      }
              +      const cryptoStorage = new RustSdkCryptoStorageProvider(
              +        path.join(storagePath, "crypto"),
              +        StoreType.Sqlite
              +      );
              +
              +      client = new MatrixClient(
              +        config.homeserverUrl,
              +        config.accessToken,
              +        storage,
              +        cryptoStorage
              +      );
              +    } else {
              +      client = new MatrixClient(
              +        config.homeserverUrl,
              +        config.accessToken,
              +        storage
              +      );
                   }
              +    patchMatrixClient();
              +    config.RUNTIME.client = client;
              +    const eventDecoder = DefaultEventDecoder;
              +    const store = config.roomStateBackingStore.enabled
              +      ? new SqliteRoomStateBackingStore(
              +          path.join(config.dataPath, "room-state-backing-store.db"),
              +          eventDecoder
              +        )
              +      : undefined;
              +    bot = await makeDraupnirBotModeFromConfig(
              +      client,
              +      new SafeMatrixEmitterWrapper(client, eventDecoder),
              +      config,
              +      store
              +    );
              +    apis = constructWebAPIs(bot);
              +  } catch (err) {
              +    console.error(
              +      `Failed to setup mjolnir from the config ${config.dataPath}: ${err}`
              +    );
              +    throw err;
              +  }
              +  try {
              +    await bot.start();
              +    await config.RUNTIME.client.start();
              +    await apis.start();
              +    healthz.isHealthy = true;
              +  } catch (err) {
              +    console.error(`Mjolnir failed to start: ${err}`);
              +    bot.stop();
              +    apis.stop();
              +    throw err;
              +  }
               })();
              diff --git a/src/models/RoomUpdateError.tsx b/src/models/RoomUpdateError.tsx
              index 6649e40f..956cdec5 100644
              --- a/src/models/RoomUpdateError.tsx
              +++ b/src/models/RoomUpdateError.tsx
              @@ -28,13 +28,23 @@ limitations under the License.
               import { DocumentNode } from "../commands/interface-manager/DeadDocument";
               import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix";
               import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory";
              -import { ActionException, ActionExceptionKind, ActionResult, Ok, RoomUpdateError, StringRoomID } from "matrix-protection-suite";
              +import {
              +  ActionException,
              +  ActionExceptionKind,
              +  ActionResult,
              +  Ok,
              +  RoomUpdateError,
              +  StringRoomID,
              +} from "matrix-protection-suite";
               import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
               
               function renderErrorItem(error: RoomUpdateError): DocumentNode {
              -    return 
            1. - {error.room.toRoomIDOrAlias()} - {error.message} + return ( +
            2. + {error.room.toRoomIDOrAlias()} -{" "} + {error.message}
            3. + ); } /** @@ -47,27 +57,36 @@ function renderErrorItem(error: RoomUpdateError): DocumentNode { * @returns A `DocumentNode` fragment that can be sent to Matrix or incorperated into another message. */ export async function renderActionResult( - errors: RoomUpdateError[], - { title = 'There were errors updating protected rooms.', noErrorsText = 'Done updating rooms - no errors.'}: { title?: string, noErrorsText?: string } = {} + errors: RoomUpdateError[], + { + title = "There were errors updating protected rooms.", + noErrorsText = "Done updating rooms - no errors.", + }: { title?: string; noErrorsText?: string } = {} ): Promise { - if (errors.length === 0) { - return {noErrorsText} - } - return - - {title}
              -
              -
              - - - {errors.length} errors updating protected rooms!
              -
              -
              -
                - {errors.map(error => renderErrorItem(error))} -
              -
              + if (errors.length === 0) { + return ( + + {noErrorsText} + + ); + } + return ( + + + {title} +
              +
              +
              + + + {errors.length} errors updating protected rooms! +
              +
              +
              +
                {errors.map((error) => renderErrorItem(error))}
              +
              + ); } /** @@ -80,23 +99,25 @@ export async function renderActionResult( * @returns */ export async function printActionResult( - client: MatrixSendClient, - roomID: StringRoomID, - errors: RoomUpdateError[], - renderOptions: { title?: string, noErrorsText?: string } = {} + client: MatrixSendClient, + roomID: StringRoomID, + errors: RoomUpdateError[], + renderOptions: { title?: string; noErrorsText?: string } = {} ): Promise> { - return await renderMatrixAndSend( - {await renderActionResult(errors, renderOptions)}, - roomID, - undefined, - client, - ).then( - (_) => Ok(undefined), - (exception) => ActionException.Result( - `Could not printActionResult to the management room.`, - { - exception, exceptionKind: ActionExceptionKind.Unknown - } - ) - ) + return await renderMatrixAndSend( + {await renderActionResult(errors, renderOptions)}, + roomID, + undefined, + client + ).then( + (_) => Ok(undefined), + (exception) => + ActionException.Result( + `Could not printActionResult to the management room.`, + { + exception, + exceptionKind: ActionExceptionKind.Unknown, + } + ) + ); } diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index ba1d2a1b..fb8e0804 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -30,32 +30,66 @@ limitations under the License. import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; -import { renderMentionPill, renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; +import { + renderMentionPill, + renderRoomPill, +} from "../commands/interface-manager/MatrixHelpRenderer"; import { ListMatches, renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; -import { AbstractProtection, ActionResult, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName, UnknownSettings, UserConsequences, Membership } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + Logger, + MatrixRoomID, + MatrixRoomReference, + MembershipChange, + MembershipChangeType, + Ok, + PermissionError, + PolicyRule, + PolicyRuleType, + ProtectedRoomsSet, + ProtectionDescription, + Recommendation, + RoomActionError, + RoomMembershipRevision, + RoomUpdateError, + StringRoomID, + StringUserID, + Task, + describeProtection, + isError, + serverName, + UnknownSettings, + UserConsequences, + Membership, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirProtection } from "./Protection"; import { listInfo } from "../commands/StatusCommand"; import { MatrixReactionHandler } from "../commands/interface-manager/MatrixReactionHandler"; -const log = new Logger('BanPropagationProtection'); +const log = new Logger("BanPropagationProtection"); -const BAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.ban_propagation'; -const UNBAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.unban_propagation'; +const BAN_PROPAGATION_PROMPT_LISTENER = + "ge.applied-langua.ge.draupnir.ban_propagation"; +const UNBAN_PROPAGATION_PROMPT_LISTENER = + "ge.applied-langua.ge.draupnir.unban_propagation"; // FIXME: https://github.com/the-draupnir-project/Draupnir/issues/160 -function makePolicyRoomReactionReferenceMap(rooms: MatrixRoomID[]): Map { - return MatrixReactionHandler.createItemizedReactionMap( - rooms.map(room => room.toPermalink()) - ); +function makePolicyRoomReactionReferenceMap( + rooms: MatrixRoomID[] +): Map { + return MatrixReactionHandler.createItemizedReactionMap( + rooms.map((room) => room.toPermalink()) + ); } // would be nice to be able to use presentation types here idk. interface BanPropagationMessageContext { - target: string, - reason?: string, + target: string; + reason?: string; } /** @@ -66,255 +100,423 @@ interface BanPropagationMessageContext { * @returns An event id which can be used by the `PromptResponseListener`. */ async function promptBanPropagation( - draupnir: Draupnir, - change: MembershipChange, + draupnir: Draupnir, + change: MembershipChange ): Promise { - const editablePolicyRoomIDs = draupnir.policyRoomManager.getEditablePolicyRoomIDs( - draupnir.clientUserID, - PolicyRuleType.User + const editablePolicyRoomIDs = + draupnir.policyRoomManager.getEditablePolicyRoomIDs( + draupnir.clientUserID, + PolicyRuleType.User ); - const reactionMap = makePolicyRoomReactionReferenceMap(editablePolicyRoomIDs); - const promptEventId = (await renderMatrixAndSend( - The user {renderMentionPill(change.userID, change.content.displayname ?? change.userID)} was banned - in {change.roomID} by {renderMentionPill(change.sender, change.sender)} for {change.content.reason ?? ''}.
              - Would you like to add the ban to a policy list? + const reactionMap = makePolicyRoomReactionReferenceMap(editablePolicyRoomIDs); + const promptEventId = ( + await renderMatrixAndSend( + + The user{" "} + {renderMentionPill( + change.userID, + change.content.displayname ?? change.userID + )}{" "} + was banned in{" "} + {change.roomID} by{" "} + {renderMentionPill(change.sender, change.sender)} for{" "} + {change.content.reason ?? ""}.
              + Would you like to add the ban to a policy list?
                - {editablePolicyRoomIDs.map((room) =>
              1. {room.toRoomIDOrAlias()}
              2. )} + {editablePolicyRoomIDs.map((room) => ( +
              3. + {room.toRoomIDOrAlias()} +
              4. + ))}
              -
              , - draupnir.managementRoomID, - undefined, - draupnir.client, - draupnir.reactionHandler.createAnnotation( - BAN_PROPAGATION_PROMPT_LISTENER, - reactionMap, - { - target: change.userID, - reason: change.content.reason, - } - ) - )).at(0) as string; - await draupnir.reactionHandler.addReactionsToEvent(draupnir.client, draupnir.managementRoomID, promptEventId, reactionMap); +
              , + draupnir.managementRoomID, + undefined, + draupnir.client, + draupnir.reactionHandler.createAnnotation( + BAN_PROPAGATION_PROMPT_LISTENER, + reactionMap, + { + target: change.userID, + reason: change.content.reason, + } + ) + ) + ).at(0) as string; + await draupnir.reactionHandler.addReactionsToEvent( + draupnir.client, + draupnir.managementRoomID, + promptEventId, + reactionMap + ); } async function promptUnbanPropagation( - draupnir: Draupnir, - membershipChange: MembershipChange, - roomID: StringRoomID, - rulesMatchingUser: ListMatches[] + draupnir: Draupnir, + membershipChange: MembershipChange, + roomID: StringRoomID, + rulesMatchingUser: ListMatches[] ): Promise { - const reactionMap = new Map(Object.entries({ 'unban from all': 'unban from all'})); - // shouldn't we warn them that the unban will be futile? - const promptEventId = (await renderMatrixAndSend( - - The user {renderMentionPill(membershipChange.userID, membershipChange.content.displayname ?? membershipChange.userID)} was unbanned - from the room {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} by {membershipChange.sender} for {membershipChange.content.reason ?? ''}.
              - However there are rules in Draupnir's watched lists matching this user: -
                - { - rulesMatchingUser.map(match =>
              • {renderListRules(match)}
              • ) - } -
              - Would you like to remove these rules and unban the user from all protected rooms? -
              , - draupnir.managementRoomID, - undefined, - draupnir.client, - draupnir.reactionHandler.createAnnotation( - UNBAN_PROPAGATION_PROMPT_LISTENER, - reactionMap, - { - target: membershipChange.userID, - reason: membershipChange.content.reason, - } - ) - )).at(0) as string; - await draupnir.reactionHandler.addReactionsToEvent(draupnir.client, draupnir.managementRoomID, promptEventId, reactionMap); + const reactionMap = new Map( + Object.entries({ "unban from all": "unban from all" }) + ); + // shouldn't we warn them that the unban will be futile? + const promptEventId = ( + await renderMatrixAndSend( + + The user{" "} + {renderMentionPill( + membershipChange.userID, + membershipChange.content.displayname ?? membershipChange.userID + )}{" "} + was unbanned from the room{" "} + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} by{" "} + {membershipChange.sender} for{" "} + {membershipChange.content.reason ?? ""} + .
              + However there are rules in Draupnir's watched lists matching this user: +
                + {rulesMatchingUser.map((match) => ( +
              • {renderListRules(match)}
              • + ))} +
              + Would you like to remove these rules and unban the user from all + protected rooms? +
              , + draupnir.managementRoomID, + undefined, + draupnir.client, + draupnir.reactionHandler.createAnnotation( + UNBAN_PROPAGATION_PROMPT_LISTENER, + reactionMap, + { + target: membershipChange.userID, + reason: membershipChange.content.reason, + } + ) + ) + ).at(0) as string; + await draupnir.reactionHandler.addReactionsToEvent( + draupnir.client, + draupnir.managementRoomID, + promptEventId, + reactionMap + ); } export type BanPropagationProtectionCapabilities = { - userConsequences: UserConsequences + userConsequences: UserConsequences; }; -export type BanPropagationProtectionCapabilitiesDescription = ProtectionDescription< +export type BanPropagationProtectionCapabilitiesDescription = + ProtectionDescription< Draupnir, UnknownSettings, BanPropagationProtectionCapabilities ->; + >; export class BanPropagationProtection - extends AbstractProtection - implements DraupnirProtection { + extends AbstractProtection + implements + DraupnirProtection +{ + private readonly userConsequences: UserConsequences; - private readonly userConsequences: UserConsequences; + private readonly banPropagationPromptListener = + this.banReactionListener.bind(this); + private readonly unbanPropagationPromptListener = + this.unbanUserReactionListener.bind(this); + constructor( + description: BanPropagationProtectionCapabilitiesDescription, + capabilities: BanPropagationProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.userConsequences = capabilities.userConsequences; + this.draupnir.reactionHandler.on( + BAN_PROPAGATION_PROMPT_LISTENER, + this.banPropagationPromptListener + ); + this.draupnir.reactionHandler.on( + UNBAN_PROPAGATION_PROMPT_LISTENER, + this.unbanPropagationPromptListener + ); + } - private readonly banPropagationPromptListener = this.banReactionListener.bind(this); - private readonly unbanPropagationPromptListener = this.unbanUserReactionListener.bind(this); - constructor( - description: BanPropagationProtectionCapabilitiesDescription, - capabilities: BanPropagationProtectionCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - ) { - super(description, capabilities, protectedRoomsSet, {}); - this.userConsequences = capabilities.userConsequences; - this.draupnir.reactionHandler.on(BAN_PROPAGATION_PROMPT_LISTENER, this.banPropagationPromptListener); - this.draupnir.reactionHandler.on(UNBAN_PROPAGATION_PROMPT_LISTENER, this.unbanPropagationPromptListener); - } + handleProtectionDisable(): void { + this.draupnir.reactionHandler.off( + BAN_PROPAGATION_PROMPT_LISTENER, + this.banPropagationPromptListener + ); + this.draupnir.reactionHandler.off( + UNBAN_PROPAGATION_PROMPT_LISTENER, + this.unbanPropagationPromptListener + ); + } - handleProtectionDisable(): void { - this.draupnir.reactionHandler.off(BAN_PROPAGATION_PROMPT_LISTENER, this.banPropagationPromptListener); - this.draupnir.reactionHandler.off(UNBAN_PROPAGATION_PROMPT_LISTENER, this.unbanPropagationPromptListener) + public async handleMembershipChange( + revision: RoomMembershipRevision, + changes: MembershipChange[] + ): Promise> { + // use Membership and not MembershipChangeType so that we can detect edits to ban reasons. + const bans = changes.filter( + (change) => + change.membership === Membership.Ban && + change.sender !== this.protectedRoomsSet.userID + ); + const unbans = changes.filter( + (change) => + change.membershipChangeType === MembershipChangeType.Unbanned && + change.sender !== this.protectedRoomsSet.userID + ); + for (const ban of bans) { + this.handleBan(ban); + } + for (const unban of unbans) { + void Task(this.handleUnban(unban, this.draupnir)); } + return Ok(undefined); + } - public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { - // use Membership and not MembershipChangeType so that we can detect edits to ban reasons. - const bans = changes.filter(change => change.membership === Membership.Ban && change.sender !== this.protectedRoomsSet.userID); - const unbans = changes.filter(change => change.membershipChangeType === MembershipChangeType.Unbanned && change.sender !== this.protectedRoomsSet.userID); - for (const ban of bans) { - this.handleBan(ban); - } - for (const unban of unbans) { - void Task(this.handleUnban(unban, this.draupnir)); - } - return Ok(undefined); + private handleBan(change: MembershipChange): void { + const policyRevision = + this.protectedRoomsSet.issuerManager.policyListRevisionIssuer + .currentRevision; + const rulesMatchingUser = policyRevision.allRulesMatchingEntity( + change.userID, + PolicyRuleType.User, + Recommendation.Ban + ); + if (rulesMatchingUser.length > 0) { + return; // user is already banned. } + void Task(promptBanPropagation(this.draupnir, change)); + } - private handleBan(change: MembershipChange): void { - const policyRevision = this.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision; - const rulesMatchingUser = policyRevision.allRulesMatchingEntity(change.userID, PolicyRuleType.User, Recommendation.Ban); - if (rulesMatchingUser.length > 0) { - return; // user is already banned. + private async handleUnban( + change: MembershipChange, + draupnir: Draupnir + ): Promise { + const policyRevision = + this.protectedRoomsSet.issuerManager.policyListRevisionIssuer + .currentRevision; + const rulesMatchingUser = policyRevision.allRulesMatchingEntity( + change.userID, + PolicyRuleType.User, + Recommendation.Ban + ); + const policyRoomInfo = await listInfo(draupnir); + if (rulesMatchingUser.length === 0) { + return; // user is already unbanned. + } + const addRule = ( + map: Map, + rule: PolicyRule + ) => { + const listRoomID = rule.sourceEvent.room_id; + const entry = + map.get(listRoomID) ?? + ((newEntry) => (map.set(listRoomID, newEntry), newEntry))([]); + entry.push(rule); + return map; + }; + const rulesByPolicyRoom = rulesMatchingUser.reduce( + (map, rule) => addRule(map, rule), + new Map() + ); + await promptUnbanPropagation( + this.draupnir, + change, + change.roomID, + [...rulesByPolicyRoom.entries()].map(([policyRoomID, rules]) => { + const info = policyRoomInfo.find( + (i) => i.revision.room.toRoomIDOrAlias() === policyRoomID + ); + if (info === undefined) { + throw new TypeError( + `Shouldn't be possible to have a rule from an unwatched list.` + ); } - void Task(promptBanPropagation(this.draupnir, change)); + return { + room: info.revision.room, + roomID: policyRoomID, + matches: rules, + profile: info.watchedListProfile, + }; + }) + ); + } + + private async banReactionListener( + key: string, + item: unknown, + context: BanPropagationMessageContext + ) { + if (typeof item === "string") { + const policyRoomRef = MatrixRoomReference.fromPermalink(item); + if (isError(policyRoomRef)) { + log.error( + `Could not parse the room reference for the policy list to ban a user within ${item}`, + policyRoomRef.error, + context + ); + return; + } + const roomID = await resolveRoomReferenceSafe( + this.draupnir.client, + policyRoomRef.ok + ); + if (isError(roomID)) { + log.error( + `Could not resolve the room reference for the policy list to ban a user within ${policyRoomRef.ok.toPermalink()}`, + roomID.error + ); + return; + } + const listResult = + await this.draupnir.policyRoomManager.getPolicyRoomEditor(roomID.ok); + if (isError(listResult)) { + log.error( + `Could not find a policy list for the policy room ${policyRoomRef.ok.toPermalink()}`, + listResult.error + ); + return; + } + const banResult = await listResult.ok.banEntity( + PolicyRuleType.User, + context.target, + context.reason + ); + if (isError(banResult)) { + log.error( + `Could not ban a user ${context.target} from the list ${policyRoomRef.ok.toPermalink()}`, + banResult.error + ); + } + } else { + log.error(`The Ban Result map has been malformed somehow item:`, item); } + } - private async handleUnban(change: MembershipChange, draupnir: Draupnir): Promise { - const policyRevision = this.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision; - const rulesMatchingUser = policyRevision.allRulesMatchingEntity(change.userID, PolicyRuleType.User, Recommendation.Ban); - const policyRoomInfo = await listInfo(draupnir) - if (rulesMatchingUser.length === 0) { - return; // user is already unbanned. + private async unbanUserReactionListener( + key: string, + item: unknown, + context: BanPropagationMessageContext + ): Promise { + if (item === "unban from all") { + // FIXME: + // the unban from lists code should be moved to a standard consequence. + const errors: RoomUpdateError[] = []; + const policyRevision = + this.protectedRoomsSet.issuerManager.policyListRevisionIssuer + .currentRevision; + const rulesMatchingUser = policyRevision.allRulesMatchingEntity( + context.target, + PolicyRuleType.User, + Recommendation.Ban + ); + const listsWithRules = new Set( + rulesMatchingUser.map((rule) => rule.sourceEvent.room_id) + ); + const editablePolicyRooms = + this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( + this.draupnir.clientUserID, + PolicyRuleType.User + ); + for (const roomIDWithPolicy of listsWithRules) { + const editablePolicyRoom = editablePolicyRooms.find( + (room) => room.toRoomIDOrAlias() === roomIDWithPolicy + ); + if (editablePolicyRoom === undefined) { + const roomID = MatrixRoomReference.fromRoomID(roomIDWithPolicy, [ + serverName(this.draupnir.clientUserID), + ]); + errors.push( + new PermissionError( + roomID, + `${this.draupnir.clientUserID} doesn't have the power level to remove the policy banning ${context.target} within ${roomID.toPermalink()}` + ) + ); + continue; } - const addRule = (map: Map, rule: PolicyRule) => { - const listRoomID = rule.sourceEvent.room_id; - const entry = map.get(listRoomID) ?? ((newEntry) => (map.set(listRoomID, newEntry), newEntry))([]); - entry.push(rule); - return map; + const editorResult = + await this.draupnir.policyRoomManager.getPolicyRoomEditor( + editablePolicyRoom + ); + if (isError(editorResult)) { + errors.push( + RoomActionError.fromActionError( + editablePolicyRoom, + editorResult.error + ) + ); + continue; } - const rulesByPolicyRoom = rulesMatchingUser.reduce((map, rule) => addRule(map, rule), new Map()); - await promptUnbanPropagation( - this.draupnir, - change, - change.roomID, - [...rulesByPolicyRoom.entries()].map(([policyRoomID, rules]) => { - const info = policyRoomInfo.find(i => i.revision.room.toRoomIDOrAlias() === policyRoomID); - if (info === undefined) { - throw new TypeError(`Shouldn't be possible to have a rule from an unwatched list.`) - } - return { - room: info.revision.room, - roomID: policyRoomID, - matches: rules, - profile: info.watchedListProfile - } - }) + const editor = editorResult.ok; + const unbanResult = await editor.unbanEntity( + PolicyRuleType.User, + context.target ); - } - - private async banReactionListener(key: string, item: unknown, context: BanPropagationMessageContext) { - if (typeof item === 'string') { - const policyRoomRef = MatrixRoomReference.fromPermalink(item); - if (isError(policyRoomRef)) { - log.error(`Could not parse the room reference for the policy list to ban a user within ${item}`, policyRoomRef.error, context); - return; - } - const roomID = await resolveRoomReferenceSafe(this.draupnir.client, policyRoomRef.ok); - if (isError(roomID)) { - log.error(`Could not resolve the room reference for the policy list to ban a user within ${policyRoomRef.ok.toPermalink()}`, roomID.error); - return; - } - const listResult = await this.draupnir.policyRoomManager.getPolicyRoomEditor(roomID.ok) - if (isError(listResult)) { - log.error(`Could not find a policy list for the policy room ${policyRoomRef.ok.toPermalink()}`, listResult.error); - return; - } - const banResult = await listResult.ok.banEntity(PolicyRuleType.User, context.target, context.reason); - if (isError(banResult)) { - log.error(`Could not ban a user ${context.target} from the list ${policyRoomRef.ok.toPermalink()}`, banResult.error); - } - } else { - log.error(`The Ban Result map has been malformed somehow item:`, item); + if (isError(unbanResult)) { + errors.push( + RoomActionError.fromActionError( + editablePolicyRoom, + unbanResult.error + ) + ); + continue; } - } - - private async unbanUserReactionListener(key: string, item: unknown, context: BanPropagationMessageContext): Promise { - if (item === 'unban from all') { - // FIXME: - // the unban from lists code should be moved to a standard consequence. - const errors: RoomUpdateError[] = []; - const policyRevision = this.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision; - const rulesMatchingUser = policyRevision.allRulesMatchingEntity(context.target, PolicyRuleType.User, Recommendation.Ban); - const listsWithRules = new Set(rulesMatchingUser.map((rule) => rule.sourceEvent.room_id)); - const editablePolicyRooms = this.draupnir.policyRoomManager.getEditablePolicyRoomIDs(this.draupnir.clientUserID, PolicyRuleType.User); - for (const roomIDWithPolicy of listsWithRules) { - const editablePolicyRoom = editablePolicyRooms.find((room) => room.toRoomIDOrAlias() === roomIDWithPolicy); - if (editablePolicyRoom === undefined) { - const roomID = MatrixRoomReference.fromRoomID(roomIDWithPolicy, [serverName(this.draupnir.clientUserID)]); - errors.push(new PermissionError(roomID, `${this.draupnir.clientUserID} doesn't have the power level to remove the policy banning ${context.target} within ${roomID.toPermalink()}`)); - continue; - } - const editorResult = await this.draupnir.policyRoomManager.getPolicyRoomEditor(editablePolicyRoom); - if (isError(editorResult)) { - errors.push(RoomActionError.fromActionError(editablePolicyRoom, editorResult.error)); - continue; - } - const editor = editorResult.ok; - const unbanResult = await editor.unbanEntity(PolicyRuleType.User, context.target); - if (isError(unbanResult)) { - errors.push(RoomActionError.fromActionError(editablePolicyRoom, unbanResult.error)); - continue; - } - } - if (errors.length > 0) { - void Task(printActionResult( - this.draupnir.client, - this.draupnir.managementRoomID, - errors, - { title: `There were errors unbanning ${context.target} from all lists.`} - )); - } else { - void Task((async () => { - await this.userConsequences.unbanUserFromRoomSet( - context.target as StringUserID, - '' - )})()) + } + if (errors.length > 0) { + void Task( + printActionResult( + this.draupnir.client, + this.draupnir.managementRoomID, + errors, + { + title: `There were errors unbanning ${context.target} from all lists.`, } - } else { - log.error(`unban reaction map is malformed got item for key ${key}:`, item); - } + ) + ); + } else { + void Task( + (async () => { + await this.userConsequences.unbanUserFromRoomSet( + context.target as StringUserID, + "" + ); + })() + ); + } + } else { + log.error( + `unban reaction map is malformed got item for key ${key}:`, + item + ); } + } } describeProtection({ - name: 'BanPropagationProtection', - description: + name: "BanPropagationProtection", + description: "When you ban a user in any protected room with a client, this protection\ will turn the room level ban into a policy for a policy list of your choice.\ This will then allow the bot to ban the user from all of your rooms.", - capabilityInterfaces: { - userConsequences: 'UserConsequences' - }, - defaultCapabilities: { - userConsequences: 'StandardUserConsequences', - }, - factory: (decription, protectedRoomsSet, draupnir, capabilities, _settings) => - Ok( - new BanPropagationProtection( - decription, - capabilities, - protectedRoomsSet, - draupnir - ) - ), + capabilityInterfaces: { + userConsequences: "UserConsequences", + }, + defaultCapabilities: { + userConsequences: "StandardUserConsequences", + }, + factory: (decription, protectedRoomsSet, draupnir, capabilities, _settings) => + Ok( + new BanPropagationProtection( + decription, + capabilities, + protectedRoomsSet, + draupnir + ) + ), }); diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 6b3fe261..819c7891 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -28,165 +28,245 @@ limitations under the License. import { Draupnir } from "../Draupnir"; import { DraupnirProtection } from "./Protection"; import { LogLevel } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, EventConsequences, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringRoomID, StringUserID, UserConsequences, describeProtection, isError } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + EventConsequences, + Logger, + MatrixRoomID, + Ok, + ProtectedRoomsSet, + ProtectionDescription, + RoomEvent, + SafeIntegerProtectionSetting, + StandardProtectionSettings, + StringEventID, + StringRoomID, + StringUserID, + UserConsequences, + describeProtection, + isError, +} from "matrix-protection-suite"; -const log = new Logger('BasicFloodingProtection'); +const log = new Logger("BasicFloodingProtection"); type BasicFloodingProtectionSettings = { - maxPerMinute: number, -} + maxPerMinute: number; +}; // if this is exceeded, we'll ban the user for spam and redact their messages export const DEFAULT_MAX_PER_MINUTE = 10; const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase export type BasicFloodingProtectionCapabilities = { - userConsequences: UserConsequences; - eventConsequences: EventConsequences; + userConsequences: UserConsequences; + eventConsequences: EventConsequences; }; -export type BasicFloodingProtectionDescription = ProtectionDescription; +export type BasicFloodingProtectionDescription = ProtectionDescription< + Draupnir, + BasicFloodingProtectionSettings, + BasicFloodingProtectionCapabilities +>; -describeProtection({ - name: 'BasicFloodingProtection', - description: - `If a user posts more than ${DEFAULT_MAX_PER_MINUTE} messages in 60s they'll be +describeProtection< + BasicFloodingProtectionCapabilities, + Draupnir, + BasicFloodingProtectionSettings +>({ + name: "BasicFloodingProtection", + description: `If a user posts more than ${DEFAULT_MAX_PER_MINUTE} messages in 60s they'll be banned for spam. This does not publish the ban to any of your ban lists. This is a legacy protection from Mjolnir and contains bugs.`, - capabilityInterfaces: { - userConsequences: 'UserConsequences', - eventConsequences: 'EventConsequences', - }, - defaultCapabilities: { - userConsequences: 'StandardUserConsequences', - eventConsequences: 'StandardEventConsequences', - }, - factory: (description, protectedRoomsSet, draupnir, capabilities, rawSettings) => { - const parsedSettings = description.protectionSettings.parseSettings(rawSettings); - if (isError(parsedSettings)) { - return parsedSettings; - } - return Ok( - new BasicFloodingProtection( - description, - capabilities, - protectedRoomsSet, - draupnir, - parsedSettings.ok - ) - ) - }, - protectionSettings: new StandardProtectionSettings({ - maxPerMinute: new SafeIntegerProtectionSetting( - 'maxPerMinute' - )}, - { - maxPerMinute: DEFAULT_MAX_PER_MINUTE - }) + capabilityInterfaces: { + userConsequences: "UserConsequences", + eventConsequences: "EventConsequences", + }, + defaultCapabilities: { + userConsequences: "StandardUserConsequences", + eventConsequences: "StandardEventConsequences", + }, + factory: ( + description, + protectedRoomsSet, + draupnir, + capabilities, + rawSettings + ) => { + const parsedSettings = + description.protectionSettings.parseSettings(rawSettings); + if (isError(parsedSettings)) { + return parsedSettings; + } + return Ok( + new BasicFloodingProtection( + description, + capabilities, + protectedRoomsSet, + draupnir, + parsedSettings.ok + ) + ); + }, + protectionSettings: + new StandardProtectionSettings( + { + maxPerMinute: new SafeIntegerProtectionSetting("maxPerMinute"), + }, + { + maxPerMinute: DEFAULT_MAX_PER_MINUTE, + } + ), }); -type LastEvents = { originServerTs: number, eventID: StringEventID }[]; -type LastEventsByUser = Map +type LastEvents = { originServerTs: number; eventID: StringEventID }[]; +type LastEventsByUser = Map; type LastEventsByRoom = Map; -function lastEventsRoomEntry(lastEvents: LastEventsByRoom, roomID: StringRoomID): LastEventsByUser { - const roomEntry = lastEvents.get(roomID); - if (roomEntry) { - return roomEntry; - } else { - const nextEntry = new Map(); - lastEvents.set(roomID, nextEntry); - return nextEntry; - } +function lastEventsRoomEntry( + lastEvents: LastEventsByRoom, + roomID: StringRoomID +): LastEventsByUser { + const roomEntry = lastEvents.get(roomID); + if (roomEntry) { + return roomEntry; + } else { + const nextEntry = new Map(); + lastEvents.set(roomID, nextEntry); + return nextEntry; + } } -function lastEventsUserEntry(eventsByUser: LastEventsByUser, userID: StringUserID): LastEvents { - const userEntry = eventsByUser.get(userID); - if (userEntry === undefined) { - const events: LastEvents = [] - eventsByUser.set(userID, events); - return events; - } - return userEntry; +function lastEventsUserEntry( + eventsByUser: LastEventsByUser, + userID: StringUserID +): LastEvents { + const userEntry = eventsByUser.get(userID); + if (userEntry === undefined) { + const events: LastEvents = []; + eventsByUser.set(userID, events); + return events; + } + return userEntry; } -function lastEventsForUser(lastEventsByRoom: LastEventsByRoom, roomID: StringRoomID, userID: StringUserID): LastEvents { - const roomEntry = lastEventsRoomEntry(lastEventsByRoom, roomID); - const userEvents = lastEventsUserEntry(roomEntry, userID); - return userEvents; +function lastEventsForUser( + lastEventsByRoom: LastEventsByRoom, + roomID: StringRoomID, + userID: StringUserID +): LastEvents { + const roomEntry = lastEventsRoomEntry(lastEventsByRoom, roomID); + const userEvents = lastEventsUserEntry(roomEntry, userID); + return userEvents; } -export class BasicFloodingProtection extends AbstractProtection implements DraupnirProtection { +export class BasicFloodingProtection + extends AbstractProtection + implements DraupnirProtection +{ + private lastEvents: LastEventsByRoom = new Map(); + private recentlyBanned: string[] = []; - private lastEvents: LastEventsByRoom = new Map(); - private recentlyBanned: string[] = []; + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; + public constructor( + description: BasicFloodingProtectionDescription, + capabilities: BasicFloodingProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + private readonly settings: BasicFloodingProtectionSettings + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.userConsequences = capabilities.userConsequences; + } - private readonly userConsequences: UserConsequences; - private readonly eventConsequences: EventConsequences; - public constructor( - description: BasicFloodingProtectionDescription, - capabilities: BasicFloodingProtectionCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - private readonly settings: BasicFloodingProtectionSettings, + public async handleTimelineEvent( + room: MatrixRoomID, + event: RoomEvent + ): Promise> { + const forUser = lastEventsForUser( + this.lastEvents, + event.room_id, + event.sender + ); + + if ( + new Date().getTime() - event["origin_server_ts"] > + TIMESTAMP_THRESHOLD ) { - super( - description, - capabilities, - protectedRoomsSet, - {} - ) - this.userConsequences = capabilities.userConsequences; + log.warn( + "BasicFlooding", + `${event["event_id"]} is more than ${TIMESTAMP_THRESHOLD}ms out of phase - rewriting event time to be 'now'` + ); + event["origin_server_ts"] = new Date().getTime(); } - public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { - const forUser = lastEventsForUser(this.lastEvents, event.room_id, event.sender); + forUser.push({ + originServerTs: event["origin_server_ts"], + eventID: event["event_id"], + }); - if ((new Date()).getTime() - event['origin_server_ts'] > TIMESTAMP_THRESHOLD) { - log.warn("BasicFlooding", `${event['event_id']} is more than ${TIMESTAMP_THRESHOLD}ms out of phase - rewriting event time to be 'now'`); - event['origin_server_ts'] = (new Date()).getTime(); - } + // Do some math to see if the user is spamming + let messageCount = 0; + for (const prevEvent of forUser) { + if (new Date().getTime() - prevEvent.originServerTs > 60000) continue; // not important + messageCount++; + } - forUser.push({originServerTs: event['origin_server_ts'], eventID: event['event_id']}); + if (messageCount >= this.settings.maxPerMinute) { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "BasicFlooding", + `Banning ${event["sender"]} in ${room.toRoomIDOrAlias()} for flooding (${messageCount} messages in the last minute)`, + room.toRoomIDOrAlias() + ); + if (!this.draupnir.config.noop) { + await this.userConsequences.consequenceForUserInRoom( + room.toRoomIDOrAlias(), + event["sender"], + "spam" + ); + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "BasicFlooding", + `Tried to ban ${event["sender"]} in ${room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, + room.toRoomIDOrAlias() + ); + } - // Do some math to see if the user is spamming - let messageCount = 0; - for (const prevEvent of forUser) { - if ((new Date()).getTime() - prevEvent.originServerTs > 60000) continue; // not important - messageCount++; - } + if (this.recentlyBanned.includes(event["sender"])) { + return Ok(undefined); + } // already handled (will be redacted) + this.draupnir.unlistedUserRedactionQueue.addUser(event["sender"]); + this.recentlyBanned.push(event["sender"]); // flag to reduce spam - if (messageCount >= this.settings.maxPerMinute) { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${room.toRoomIDOrAlias()} for flooding (${messageCount} messages in the last minute)`, room.toRoomIDOrAlias()); - if (!this.draupnir.config.noop) { - await this.userConsequences.consequenceForUserInRoom(room.toRoomIDOrAlias(), event['sender'], 'spam'); - } else { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, room.toRoomIDOrAlias()); - } - - if (this.recentlyBanned.includes(event['sender'])) { - return Ok(undefined); - } // already handled (will be redacted) - this.draupnir.unlistedUserRedactionQueue.addUser(event['sender']); - this.recentlyBanned.push(event['sender']); // flag to reduce spam - - // Redact all the things the user said too - if (!this.draupnir.config.noop) { - for (const eventID of forUser.map(e => e.eventID)) { - await this.eventConsequences.consequenceForEvent(room.toRoomIDOrAlias(), eventID, 'spam'); - } - } else { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, room.toRoomIDOrAlias()); - } - - // Free up some memory now that we're ready to handle it elsewhere - forUser.splice(0, forUser.length); + // Redact all the things the user said too + if (!this.draupnir.config.noop) { + for (const eventID of forUser.map((e) => e.eventID)) { + await this.eventConsequences.consequenceForEvent( + room.toRoomIDOrAlias(), + eventID, + "spam" + ); } + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "BasicFlooding", + `Tried to redact messages for ${event["sender"]} in ${room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, + room.toRoomIDOrAlias() + ); + } - // Trim the oldest messages off the user's history if it's getting large - if (forUser.length > this.settings.maxPerMinute * 2) { - forUser.splice(0, forUser.length - (this.settings.maxPerMinute * 2) - 1); - } - return Ok(undefined); + // Free up some memory now that we're ready to handle it elsewhere + forUser.splice(0, forUser.length); + } + + // Trim the oldest messages off the user's history if it's getting large + if (forUser.length > this.settings.maxPerMinute * 2) { + forUser.splice(0, forUser.length - this.settings.maxPerMinute * 2 - 1); } + return Ok(undefined); + } } diff --git a/src/protections/ConfigHooks.ts b/src/protections/ConfigHooks.ts index 3e2b376f..65518b18 100644 --- a/src/protections/ConfigHooks.ts +++ b/src/protections/ConfigHooks.ts @@ -2,32 +2,46 @@ // // SPDX-License-Identifier: AFL-3.0 -import { ActionResult, Ok, ProtectionsConfig, ServerBanSynchronisationProtection, isError } from "matrix-protection-suite"; +import { + ActionResult, + Ok, + ProtectionsConfig, + ServerBanSynchronisationProtection, + isError, +} from "matrix-protection-suite"; import { IConfig } from "../config"; -type ConfigHook = (config: IConfig, protectionsConfig: ProtectionsConfig) => Promise>; +type ConfigHook = ( + config: IConfig, + protectionsConfig: ProtectionsConfig +) => Promise>; const hooks: ConfigHook[] = [ - async function disableServerACL (config, protectionsConfig) { - if (config.disableServerACL) { - return await protectionsConfig.disableProtection(ServerBanSynchronisationProtection.name); - } else { - return Ok(undefined); - } + async function disableServerACL(config, protectionsConfig) { + if (config.disableServerACL) { + return await protectionsConfig.disableProtection( + ServerBanSynchronisationProtection.name + ); + } else { + return Ok(undefined); } -] + }, +]; /** * Introduced to allow the legacy option `config.disableServerACL` to map onto * MPS's ServerBanSynchronisationProtection. I think we need to deprecate the * option and offer something else. */ -export async function runProtectionConfigHooks(config: IConfig, protectionsConfig: ProtectionsConfig): Promise> { - for (const hook of hooks) { - const result = await hook(config, protectionsConfig); - if (isError(result)) { - return result; - } +export async function runProtectionConfigHooks( + config: IConfig, + protectionsConfig: ProtectionsConfig +): Promise> { + for (const hook of hooks) { + const result = await hook(config, protectionsConfig); + if (isError(result)) { + return result; } - return Ok(undefined); + } + return Ok(undefined); } diff --git a/src/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts index 098d0cce..6dbfbc86 100644 --- a/src/protections/DefaultEnabledProtectionsMigration.ts +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -3,119 +3,145 @@ * All rights reserved. */ -import { ActionError,ActionException,ActionExceptionKind,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value, findProtection } from "matrix-protection-suite"; +import { + ActionError, + ActionException, + ActionExceptionKind, + DRAUPNIR_SCHEMA_VERSION_KEY, + MjolnirEnabledProtectionsEvent, + MjolnirEnabledProtectionsEventType, + Ok, + SchemedDataManager, + Value, + findProtection, +} from "matrix-protection-suite"; import { RedactionSynchronisationProtection } from "./RedactionSynchronisation"; import { PolicyChangeNotification } from "./PolicyChangeNotification"; import { JoinRoomsOnInviteProtection } from "./invitation/JoinRoomsOnInviteProtection"; -export const DefaultEnabledProtectionsMigration = new SchemedDataManager([ +export const DefaultEnabledProtectionsMigration = + new SchemedDataManager([ async function enableBanPropagationByDefault(input) { - if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { - return ActionError.Result( - `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` - ); - } - const banPropagationProtection = findProtection('BanPropagationProtection'); - if (banPropagationProtection === undefined) { - const message = `Cannot find the BanPropagationProtection`; - return ActionException.Result(message, { - exception: new TypeError(message), - exceptionKind: ActionExceptionKind.Unknown, - }) - } - const enabled = new Set(input.enabled); - enabled.add(banPropagationProtection.name); - return Ok({ - enabled: [...enabled], - [DRAUPNIR_SCHEMA_VERSION_KEY]: 1, + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const banPropagationProtection = findProtection( + "BanPropagationProtection" + ); + if (banPropagationProtection === undefined) { + const message = `Cannot find the BanPropagationProtection`; + return ActionException.Result(message, { + exception: new TypeError(message), + exceptionKind: ActionExceptionKind.Unknown, }); + } + const enabled = new Set(input.enabled); + enabled.add(banPropagationProtection.name); + return Ok({ + enabled: [...enabled], + [DRAUPNIR_SCHEMA_VERSION_KEY]: 1, + }); }, async function enableMemberAndServerSynchronisationByDefault(input) { - if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { - return ActionError.Result( - `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` - ); - } - const enabledProtections = new Set(input.enabled); - // we go through the process of finding them just so we can be sure that we spell their names correctly. - const memberBanSynchronisationProtection = findProtection('MemberBanSynchronisationProtection'); - const serverBanSynchronisationProtection = findProtection('ServerBanSynchronisationProtection'); - if (memberBanSynchronisationProtection === undefined || serverBanSynchronisationProtection === undefined) { - const message = `Cannot find the member ban or server ban synchronisation protections`; - return ActionException.Result(message, { - exception: new TypeError(message), - exceptionKind: ActionExceptionKind.Unknown - }); - } - for (const protection of [memberBanSynchronisationProtection, serverBanSynchronisationProtection]) { - enabledProtections.add(protection.name); - } - return Ok({ - enabled: [...enabledProtections], - [DRAUPNIR_SCHEMA_VERSION_KEY]: 2, + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const enabledProtections = new Set(input.enabled); + // we go through the process of finding them just so we can be sure that we spell their names correctly. + const memberBanSynchronisationProtection = findProtection( + "MemberBanSynchronisationProtection" + ); + const serverBanSynchronisationProtection = findProtection( + "ServerBanSynchronisationProtection" + ); + if ( + memberBanSynchronisationProtection === undefined || + serverBanSynchronisationProtection === undefined + ) { + const message = `Cannot find the member ban or server ban synchronisation protections`; + return ActionException.Result(message, { + exception: new TypeError(message), + exceptionKind: ActionExceptionKind.Unknown, }); + } + for (const protection of [ + memberBanSynchronisationProtection, + serverBanSynchronisationProtection, + ]) { + enabledProtections.add(protection.name); + } + return Ok({ + enabled: [...enabledProtections], + [DRAUPNIR_SCHEMA_VERSION_KEY]: 2, + }); }, async function enableRedactionSynchronisationProtectionByDefault(input) { - if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { - return ActionError.Result( - `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` - ); - } - const enabledProtections = new Set(input.enabled); - const protection = findProtection(RedactionSynchronisationProtection.name); - if (protection === undefined) { - const message = `Cannot find the ${RedactionSynchronisationProtection.name} protection`; - return ActionException.Result(message, { - exception: new TypeError(message), - exceptionKind: ActionExceptionKind.Unknown - }); - } - enabledProtections.add(protection.name); - return Ok({ - enabled: [...enabledProtections], - [DRAUPNIR_SCHEMA_VERSION_KEY]: 3, + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const enabledProtections = new Set(input.enabled); + const protection = findProtection( + RedactionSynchronisationProtection.name + ); + if (protection === undefined) { + const message = `Cannot find the ${RedactionSynchronisationProtection.name} protection`; + return ActionException.Result(message, { + exception: new TypeError(message), + exceptionKind: ActionExceptionKind.Unknown, }); + } + enabledProtections.add(protection.name); + return Ok({ + enabled: [...enabledProtections], + [DRAUPNIR_SCHEMA_VERSION_KEY]: 3, + }); }, async function enablePolicyChangeNotification(input) { - if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { - return ActionError.Result( - `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` - ); - } - const enabledProtections = new Set(input.enabled); - const protection = findProtection(PolicyChangeNotification.name); - if (protection === undefined) { - const message = `Cannot find the ${PolicyChangeNotification.name} protection`; - return ActionException.Result(message, { - exception: new TypeError(message), - exceptionKind: ActionExceptionKind.Unknown - }); - } - enabledProtections.add(protection.name); - return Ok({ - enabled: [...enabledProtections], - [DRAUPNIR_SCHEMA_VERSION_KEY]: 4, + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const enabledProtections = new Set(input.enabled); + const protection = findProtection(PolicyChangeNotification.name); + if (protection === undefined) { + const message = `Cannot find the ${PolicyChangeNotification.name} protection`; + return ActionException.Result(message, { + exception: new TypeError(message), + exceptionKind: ActionExceptionKind.Unknown, }); + } + enabledProtections.add(protection.name); + return Ok({ + enabled: [...enabledProtections], + [DRAUPNIR_SCHEMA_VERSION_KEY]: 4, + }); }, async function enableJoinRoomsOnInviteProtection(input) { - if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { - return ActionError.Result( - `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` - ); - } - const enabledProtections = new Set(input.enabled); - const protection = findProtection(JoinRoomsOnInviteProtection.name); - if (protection === undefined) { - const message = `Cannot find the ${JoinRoomsOnInviteProtection.name} protection`; - return ActionException.Result(message, { - exception: new TypeError(message), - exceptionKind: ActionExceptionKind.Unknown - }); - } - enabledProtections.add(protection.name); - return Ok({ - enabled: [...enabledProtections], - [DRAUPNIR_SCHEMA_VERSION_KEY]: 5, + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const enabledProtections = new Set(input.enabled); + const protection = findProtection(JoinRoomsOnInviteProtection.name); + if (protection === undefined) { + const message = `Cannot find the ${JoinRoomsOnInviteProtection.name} protection`; + return ActionException.Result(message, { + exception: new TypeError(message), + exceptionKind: ActionExceptionKind.Unknown, }); - } -]); + } + enabledProtections.add(protection.name); + return Ok({ + enabled: [...enabledProtections], + [DRAUPNIR_SCHEMA_VERSION_KEY]: 5, + }); + }, + ]); diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts index 9c399e3c..70e1c81d 100644 --- a/src/protections/DraupnirProtectionsIndex.ts +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -5,16 +5,16 @@ */ // keep alphabetical please. -import './BanPropagation'; -import './BasicFlooding'; -import './FirstMessageIsImage'; -import './JoinWaveShortCircuit'; -import './RedactionSynchronisation'; -import './MessageIsMedia'; -import './MessageIsVoice'; -import './PolicyChangeNotification'; -import './TrustedReporters'; -import './WordList'; +import "./BanPropagation"; +import "./BasicFlooding"; +import "./FirstMessageIsImage"; +import "./JoinWaveShortCircuit"; +import "./RedactionSynchronisation"; +import "./MessageIsMedia"; +import "./MessageIsVoice"; +import "./PolicyChangeNotification"; +import "./TrustedReporters"; +import "./WordList"; // import capability renderers and glue too. import "../capabilities/capabilityIndex"; diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index ad4c0598..8156e48c 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -26,114 +26,183 @@ limitations under the License. */ import { LogLevel, LogService } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, EventConsequences, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, UserConsequences, Value, describeProtection } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + EventConsequences, + MatrixRoomID, + MembershipChange, + MembershipChangeType, + Ok, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + RoomEvent, + RoomMembershipRevision, + RoomMessage, + StringRoomID, + StringUserID, + UserConsequences, + Value, + describeProtection, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; type FirstMessageIsImageProtectionSettings = Record; -export type FirstMessageIsImageProtectionCapabilities ={ - userConsequences: UserConsequences; - eventConsequences: EventConsequences; +export type FirstMessageIsImageProtectionCapabilities = { + userConsequences: UserConsequences; + eventConsequences: EventConsequences; }; -export type FirstMessageIsImageProtectionDescription = ProtectionDescription - -describeProtection({ - name: 'FirstMessageIsImageProtection', - description: "If the first thing a user does after joining is to post an image or video, \ +export type FirstMessageIsImageProtectionDescription = ProtectionDescription< + Draupnir, + FirstMessageIsImageProtectionSettings, + FirstMessageIsImageProtectionCapabilities +>; + +describeProtection< + FirstMessageIsImageProtectionCapabilities, + Draupnir, + FirstMessageIsImageProtectionSettings +>({ + name: "FirstMessageIsImageProtection", + description: + "If the first thing a user does after joining is to post an image or video, \ they'll be banned for spam. This does not publish the ban to any of your ban lists.", - capabilityInterfaces: { - userConsequences: 'UserConsequences', - eventConsequences: 'EventConsequences', - }, - defaultCapabilities: { - userConsequences: 'StandardUserConsequences', - eventConsequences: 'StandardEventConsequences', - }, - factory: function (description, protectedRoomsSet, draupnir, capabilities, _settings) { - return Ok( - new FirstMessageIsImageProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ) + capabilityInterfaces: { + userConsequences: "UserConsequences", + eventConsequences: "EventConsequences", + }, + defaultCapabilities: { + userConsequences: "StandardUserConsequences", + eventConsequences: "StandardEventConsequences", + }, + factory: function ( + description, + protectedRoomsSet, + draupnir, + capabilities, + _settings + ) { + return Ok( + new FirstMessageIsImageProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, +}); + +export class FirstMessageIsImageProtection + extends AbstractProtection + implements Protection +{ + private justJoined: { [roomID: StringRoomID]: StringUserID[] | undefined } = + {}; + private recentlyBanned: StringUserID[] = []; + + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; + constructor( + description: FirstMessageIsImageProtectionDescription, + capabilities: FirstMessageIsImageProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.userConsequences = capabilities.userConsequences; + this.eventConsequences = capabilities.eventConsequences; + } + + public async handleMembershipChange( + revision: RoomMembershipRevision, + changes: MembershipChange[] + ): Promise> { + const roomID = revision.room.toRoomIDOrAlias(); + if (!this.justJoined[roomID]) this.justJoined[roomID] = []; + for (const change of changes) { + if (change.membershipChangeType === MembershipChangeType.Joined) { + this.justJoined[roomID].push(change.userID); + } } -}) - -export class FirstMessageIsImageProtection extends AbstractProtection implements Protection { - - private justJoined: { [roomID: StringRoomID]: StringUserID[] | undefined } = {}; - private recentlyBanned: StringUserID[] = []; - - private readonly userConsequences: UserConsequences; - private readonly eventConsequences: EventConsequences; - constructor( - description: FirstMessageIsImageProtectionDescription, - capabilities: FirstMessageIsImageProtectionCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - ) { - super( - description, - capabilities, - protectedRoomsSet, - {} + return Ok(undefined); + } + + public async handleTimelineEvent( + room: MatrixRoomID, + event: RoomEvent + ): Promise> { + const roomID = room.toRoomIDOrAlias(); + if (!this.justJoined[roomID]) this.justJoined[roomID] = []; + if (Value.Check(RoomMessage, event)) { + if (!("msgtype" in event.content)) { + return Ok(undefined); + } + const msgtype = event.content["msgtype"]; + const formattedBody = + "formatted_body" in event.content + ? event.content["formatted_body"] || "" + : ""; + const isMedia = + msgtype === "m.image" || + msgtype === "m.video" || + formattedBody.toLowerCase().includes("> { - const roomID = revision.room.toRoomIDOrAlias(); - if (!this.justJoined[roomID]) this.justJoined[roomID] = []; - for (const change of changes) { - if (change.membershipChangeType === MembershipChangeType.Joined) { - this.justJoined[roomID].push(change.userID); - } + if (!this.draupnir.config.noop) { + await this.userConsequences.consequenceForUserInRoom( + roomID, + event["sender"], + "spam" + ); + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "FirstMessageIsImage", + `Tried to ban ${event["sender"]} in ${roomID} but Mjolnir is running in no-op mode`, + roomID + ); } - return Ok(undefined); - } - public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { - const roomID = room.toRoomIDOrAlias(); - if (!this.justJoined[roomID]) this.justJoined[roomID] = []; - if (Value.Check(RoomMessage, event)) { - if (!('msgtype' in event.content)) { - return Ok(undefined); - } - const msgtype = event.content['msgtype']; - const formattedBody = 'formatted_body' in event.content ? event.content['formatted_body'] || '' : ''; - const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('= 0) { - LogService.info("FirstMessageIsImage", `${event['sender']} is no longer considered suspect`); - this.justJoined[roomID].splice(idx, 1); + this.draupnir.unlistedUserRedactionQueue.addUser(event["sender"]); + this.recentlyBanned.push(event["sender"]); // flag to reduce spam + + // Redact the event + if (!this.draupnir.config.noop) { + await this.eventConsequences.consequenceForEvent( + roomID, + event["event_id"], + "spam" + ); + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "FirstMessageIsImage", + `Tried to redact ${event["event_id"]} in ${roomID} but Mjolnir is running in no-op mode`, + roomID + ); } - return Ok(undefined); + } + } + + const idx = this.justJoined[roomID].indexOf(event["sender"]); + if (idx >= 0) { + LogService.info( + "FirstMessageIsImage", + `${event["sender"]} is no longer considered suspect` + ); + this.justJoined[roomID].splice(idx, 1); } + return Ok(undefined); + } } diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx index 260e6c4e..47d4c9dd 100644 --- a/src/protections/JoinWaveShortCircuit.tsx +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -25,126 +25,182 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, CapabilitySet, Logger, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, ProtectionDescription, RoomMembershipRevision, SafeIntegerProtectionSetting, StandardProtectionSettings, StringRoomID, describeProtection, isError } from "matrix-protection-suite"; -import {LogLevel} from "matrix-bot-sdk"; +import { + AbstractProtection, + ActionResult, + CapabilitySet, + Logger, + MembershipChange, + MembershipChangeType, + Ok, + ProtectedRoomsSet, + ProtectionDescription, + RoomMembershipRevision, + SafeIntegerProtectionSetting, + StandardProtectionSettings, + StringRoomID, + describeProtection, + isError, +} from "matrix-protection-suite"; +import { LogLevel } from "matrix-bot-sdk"; import { Draupnir } from "../Draupnir"; import { DraupnirProtection } from "./Protection"; -const log = new Logger('JoinWaveShortCircuitProtection'); +const log = new Logger("JoinWaveShortCircuitProtection"); const DEFAULT_MAX_PER_TIMESCALE = 50; const DEFAULT_TIMESCALE_MINUTES = 60; const ONE_MINUTE = 60_000; // 1min in ms type JoinWaveShortCircuitProtectionSettings = { - maxPer: number, - timescaleMinutes: number, -} + maxPer: number; + timescaleMinutes: number; +}; // TODO: Add join rule capability. type JoinWaveShortCircuitProtectionCapabilities = Record; -type JoinWaveShortCircuitProtectionDescription = ProtectionDescription; - -describeProtection({ - name: 'JoinWaveShortCircuitProtection', - description: "If X amount of users join in Y time, set the room to invite-only.", - capabilityInterfaces: {}, - defaultCapabilities: {}, - factory: function(description, protectedRoomsSet, draupnir, capabilities, settings) { - const parsedSettings = description.protectionSettings.parseSettings(settings); - if (isError(parsedSettings)) { - return parsedSettings - } - return Ok( - new JoinWaveShortCircuitProtection( - description, - capabilities, - protectedRoomsSet, - draupnir, - parsedSettings.ok - ) - ) - }, - protectionSettings: new StandardProtectionSettings({ - maxPer: new SafeIntegerProtectionSetting( - 'maxPer' - ), - timescaleMinutes: new SafeIntegerProtectionSetting( - 'timescaleMinutes' - ) +type JoinWaveShortCircuitProtectionDescription = ProtectionDescription< + Draupnir, + JoinWaveShortCircuitProtectionSettings, + JoinWaveShortCircuitProtectionCapabilities +>; + +describeProtection< + JoinWaveShortCircuitProtectionCapabilities, + Draupnir, + JoinWaveShortCircuitProtectionSettings +>({ + name: "JoinWaveShortCircuitProtection", + description: + "If X amount of users join in Y time, set the room to invite-only.", + capabilityInterfaces: {}, + defaultCapabilities: {}, + factory: function ( + description, + protectedRoomsSet, + draupnir, + capabilities, + settings + ) { + const parsedSettings = + description.protectionSettings.parseSettings(settings); + if (isError(parsedSettings)) { + return parsedSettings; + } + return Ok( + new JoinWaveShortCircuitProtection( + description, + capabilities, + protectedRoomsSet, + draupnir, + parsedSettings.ok + ) + ); + }, + protectionSettings: new StandardProtectionSettings( + { + maxPer: new SafeIntegerProtectionSetting("maxPer"), + timescaleMinutes: new SafeIntegerProtectionSetting("timescaleMinutes"), }, { - maxPer: DEFAULT_MAX_PER_TIMESCALE, - timescaleMinutes: DEFAULT_TIMESCALE_MINUTES, - }) -}) - -export class JoinWaveShortCircuitProtection extends AbstractProtection implements DraupnirProtection { - private joinBuckets: { - [roomID: StringRoomID]: { - lastBucketStart: Date, - numberOfJoins: number, - } | undefined - } = {}; - - constructor( - description: JoinWaveShortCircuitProtectionDescription, - capabilities: CapabilitySet, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - public readonly settings: JoinWaveShortCircuitProtectionSettings - ) { - super( - description, - capabilities, - protectedRoomsSet, - { - requiredStatePermissions: ["m.room.join_rules"] - } - ); + maxPer: DEFAULT_MAX_PER_TIMESCALE, + timescaleMinutes: DEFAULT_TIMESCALE_MINUTES, } - public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { - const roomID = revision.room.toRoomIDOrAlias(); - for (const change of changes) { - await this.handleMembership(roomID, change).catch((e: unknown) => { log.error(`Unexpected error handling memebership change`, e); }); + ), +}); + +export class JoinWaveShortCircuitProtection + extends AbstractProtection + implements DraupnirProtection +{ + private joinBuckets: { + [roomID: StringRoomID]: + | { + lastBucketStart: Date; + numberOfJoins: number; } - return Ok(undefined); + | undefined; + } = {}; + + constructor( + description: JoinWaveShortCircuitProtectionDescription, + capabilities: CapabilitySet, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + public readonly settings: JoinWaveShortCircuitProtectionSettings + ) { + super(description, capabilities, protectedRoomsSet, { + requiredStatePermissions: ["m.room.join_rules"], + }); + } + public async handleMembershipChange( + revision: RoomMembershipRevision, + changes: MembershipChange[] + ): Promise> { + const roomID = revision.room.toRoomIDOrAlias(); + for (const change of changes) { + await this.handleMembership(roomID, change).catch((e: unknown) => { + log.error(`Unexpected error handling memebership change`, e); + }); } - - public async handleMembership(roomID: StringRoomID, change: MembershipChange): Promise { - if (change.membershipChangeType !== MembershipChangeType.Joined) { - return; - } - - // If either the roomId bucket didn't exist, or the bucket has expired, create a new one - if (!this.joinBuckets[roomID] || this.hasExpired(this.joinBuckets[roomID].lastBucketStart)) { - this.joinBuckets[roomID] = { - lastBucketStart: new Date(), - numberOfJoins: 0 - } - } - - if (++this.joinBuckets[roomID].numberOfJoins >= this.settings.maxPer) { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomID} to invite-only as more than ${this.settings.maxPer} users have joined over the last ${this.settings.timescaleMinutes} minutes (since ${this.joinBuckets[roomID].lastBucketStart.toString()})`, roomID); - - if (!this.draupnir.config.noop) { - await this.draupnir.client.sendStateEvent(roomID, "m.room.join_rules", "", {"join_rule": "invite"}) - } else { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomID} to invite-only, but Mjolnir is running in no-op mode`, roomID); - } - } + return Ok(undefined); + } + + public async handleMembership( + roomID: StringRoomID, + change: MembershipChange + ): Promise { + if (change.membershipChangeType !== MembershipChangeType.Joined) { + return; } - private hasExpired(at: Date): boolean { - return ((new Date()).getTime() - at.getTime()) > this.timescaleMilliseconds() + // If either the roomId bucket didn't exist, or the bucket has expired, create a new one + if ( + !this.joinBuckets[roomID] || + this.hasExpired(this.joinBuckets[roomID].lastBucketStart) + ) { + this.joinBuckets[roomID] = { + lastBucketStart: new Date(), + numberOfJoins: 0, + }; } - private timescaleMilliseconds(): number { - return (this.settings.timescaleMinutes * ONE_MINUTE) + if (++this.joinBuckets[roomID].numberOfJoins >= this.settings.maxPer) { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "JoinWaveShortCircuit", + `Setting ${roomID} to invite-only as more than ${this.settings.maxPer} users have joined over the last ${this.settings.timescaleMinutes} minutes (since ${this.joinBuckets[roomID].lastBucketStart.toString()})`, + roomID + ); + + if (!this.draupnir.config.noop) { + await this.draupnir.client.sendStateEvent( + roomID, + "m.room.join_rules", + "", + { join_rule: "invite" } + ); + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "JoinWaveShortCircuit", + `Tried to set ${roomID} to invite-only, but Mjolnir is running in no-op mode`, + roomID + ); + } } + } + + private hasExpired(at: Date): boolean { + return new Date().getTime() - at.getTime() > this.timescaleMilliseconds(); + } + + private timescaleMilliseconds(): number { + return this.settings.timescaleMinutes * ONE_MINUTE; + } - /** + /** * Yeah i know this is evil but * We need to figure this out once we allow protections to have their own * command tables somehow. diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index b93344b7..58f9b5cc 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -26,74 +26,124 @@ limitations under the License. */ import { LogLevel } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, EventConsequences, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + EventConsequences, + MatrixRoomID, + Ok, + Permalinks, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + RoomEvent, + RoomMessage, + Value, + describeProtection, + serverName, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; type MessageIsMediaProtectionSettings = Record; type MessageIsMediaCapabilities = { - eventConsequences: EventConsequences; -} + eventConsequences: EventConsequences; +}; -type MessageIsMediaProtectionDescription = ProtectionDescription; +type MessageIsMediaProtectionDescription = ProtectionDescription< + Draupnir, + MessageIsMediaProtectionSettings, + MessageIsMediaCapabilities +>; -describeProtection({ - name: 'MessageIsMediaProtection', - description: "If a user posts an image or video, that message will be redacted. No bans are issued.", - capabilityInterfaces: { - eventConsequences: 'EventConsequences', - }, - defaultCapabilities: { - eventConsequences: 'StandardEventConsequences', - }, - factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { - return Ok( - new MessageIsMediaProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ) - } -}) +describeProtection< + MessageIsMediaCapabilities, + Draupnir, + MessageIsMediaProtectionSettings +>({ + name: "MessageIsMediaProtection", + description: + "If a user posts an image or video, that message will be redacted. No bans are issued.", + capabilityInterfaces: { + eventConsequences: "EventConsequences", + }, + defaultCapabilities: { + eventConsequences: "StandardEventConsequences", + }, + factory: function ( + description, + protectedRoomsSet, + draupnir, + capabilities, + _settings + ) { + return Ok( + new MessageIsMediaProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, +}); -export class MessageIsMediaProtection extends AbstractProtection implements Protection { - private readonly eventConsequences: EventConsequences; - constructor( - description: MessageIsMediaProtectionDescription, - capabilities: MessageIsMediaCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir - ) { - super( - description, - capabilities, - protectedRoomsSet, - {} - ); - this.eventConsequences = capabilities.eventConsequences; - } +export class MessageIsMediaProtection + extends AbstractProtection + implements Protection +{ + private readonly eventConsequences: EventConsequences; + constructor( + description: MessageIsMediaProtectionDescription, + capabilities: MessageIsMediaCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.eventConsequences = capabilities.eventConsequences; + } - public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { - if (Value.Check(RoomMessage, event)) { - if (!('msgtype' in event.content)) { - return Ok(undefined); - } - const msgtype = event.content['msgtype']; - const formattedBody = 'formatted_body' in event.content ? event.content['formatted_body'] || '' : ''; - const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('> { + if (Value.Check(RoomMessage, event)) { + if (!("msgtype" in event.content)) { return Ok(undefined); + } + const msgtype = event.content["msgtype"]; + const formattedBody = + "formatted_body" in event.content + ? event.content["formatted_body"] || "" + : ""; + const isMedia = + msgtype === "m.image" || + msgtype === "m.video" || + formattedBody.toLowerCase().includes("; -type MessageIsVoiceDescription = ProtectionDescription; +type MessageIsVoiceDescription = ProtectionDescription< + Draupnir, + MessageIsVoiceSettings, + MessageIsVoiceCapabilities +>; -describeProtection({ - name: 'MessageIsVoiceProtection', - description: 'If a user posts a voice message, that message will be redacted', - capabilityInterfaces: { - eventConsequences: 'EventConsequences', - }, - defaultCapabilities: { - eventConsequences: 'StandardEventConsequences' - }, - factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { - return Ok( - new MessageIsVoiceProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ); - } -}) +describeProtection< + MessageIsVoiceCapabilities, + Draupnir, + MessageIsVoiceSettings +>({ + name: "MessageIsVoiceProtection", + description: "If a user posts a voice message, that message will be redacted", + capabilityInterfaces: { + eventConsequences: "EventConsequences", + }, + defaultCapabilities: { + eventConsequences: "StandardEventConsequences", + }, + factory: function ( + description, + protectedRoomsSet, + draupnir, + capabilities, + _settings + ) { + return Ok( + new MessageIsVoiceProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, +}); -export class MessageIsVoiceProtection extends AbstractProtection implements Protection { - private readonly eventConsequences: EventConsequences; - constructor( - description: MessageIsVoiceDescription, - capabilities: CapabilitySet, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - ) { - super( - description, - capabilities, - protectedRoomsSet, - {} - ); - } +export class MessageIsVoiceProtection + extends AbstractProtection + implements Protection +{ + private readonly eventConsequences: EventConsequences; + constructor( + description: MessageIsVoiceDescription, + capabilities: CapabilitySet, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + } - public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { - const roomID = room.toRoomIDOrAlias(); - if (Value.Check(RoomMessage, event)) { - if (!('msgtype' in event.content) || event.content.msgtype !== 'm.audio') { - return Ok(undefined); - } - await this.draupnir.managementRoomOutput.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomID, event['event_id'], [serverName(this.draupnir.clientUserID)])}`); - // Redact the event - if (!this.draupnir.config.noop) { - return await this.eventConsequences.consequenceForEvent(roomID, event['event_id'], "Voice messages are not permitted here"); - } else { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomID} but Mjolnir is running in no-op mode`, roomID); - return Ok(undefined); - } - } + public async handleTimelineEvent( + room: MatrixRoomID, + event: RoomEvent + ): Promise> { + const roomID = room.toRoomIDOrAlias(); + if (Value.Check(RoomMessage, event)) { + if ( + !("msgtype" in event.content) || + event.content.msgtype !== "m.audio" + ) { + return Ok(undefined); + } + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "MessageIsVoice", + `Redacting event from ${event["sender"]} for posting a voice message. ${Permalinks.forEvent(roomID, event["event_id"], [serverName(this.draupnir.clientUserID)])}` + ); + // Redact the event + if (!this.draupnir.config.noop) { + return await this.eventConsequences.consequenceForEvent( + roomID, + event["event_id"], + "Voice messages are not permitted here" + ); + } else { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "MessageIsVoice", + `Tried to redact ${event["event_id"]} in ${roomID} but Mjolnir is running in no-op mode`, + roomID + ); return Ok(undefined); + } } + return Ok(undefined); + } } diff --git a/src/protections/MissingProtectionPermissions.tsx b/src/protections/MissingProtectionPermissions.tsx index 72ac0b4e..e95eac0e 100644 --- a/src/protections/MissingProtectionPermissions.tsx +++ b/src/protections/MissingProtectionPermissions.tsx @@ -2,7 +2,13 @@ // // SPDX-License-Identifier: AFL-3.0 -import { HandleMissingProtectionPermissions, MatrixRoomReference, ProtectionPermissionsChange, StringRoomID, Task } from "matrix-protection-suite"; +import { + HandleMissingProtectionPermissions, + MatrixRoomReference, + ProtectionPermissionsChange, + StringRoomID, + Task, +} from "matrix-protection-suite"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; @@ -10,75 +16,92 @@ import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; function renderPermissions( - title: DocumentNode, - permissions: string[] + title: DocumentNode, + permissions: string[] ): DocumentNode { - return permissions.length === 0 - ? - : - {title} -
                - {permissions.map(permission =>
              • {permission}
              • )} -
              -
              + return permissions.length === 0 ? ( + + ) : ( + + {title} +
                + {permissions.map((permission) => ( +
              • + {permission} +
              • + ))} +
              +
              + ); } function missingPermissionsTotal(change: ProtectionPermissionsChange): number { - return change.permissionsChange.missingEventPermissions.length - + change.permissionsChange.missingPermissions.length - + change.permissionsChange.missingStatePermissions.length + return ( + change.permissionsChange.missingEventPermissions.length + + change.permissionsChange.missingPermissions.length + + change.permissionsChange.missingStatePermissions.length + ); } function renderMissingProtectionPermissions( - protectionPermissions: ProtectionPermissionsChange + protectionPermissions: ProtectionPermissionsChange ): DocumentNode { - return
              - The {protectionPermissions.protection.description.name} is missing the following permissions ({missingPermissionsTotal(protectionPermissions)}): - {renderPermissions( - Missing permissions:, - protectionPermissions.permissionsChange.missingPermissions - )} - {renderPermissions( - Missing state permissions:, - protectionPermissions.permissionsChange.missingStatePermissions - )} - {renderPermissions( - Missing event permissions:, - protectionPermissions.permissionsChange.missingEventPermissions - )} + return ( +
              + + The {protectionPermissions.protection.description.name} is + missing the following permissions ( + {missingPermissionsTotal(protectionPermissions)}): + + {renderPermissions( + Missing permissions:, + protectionPermissions.permissionsChange.missingPermissions + )} + {renderPermissions( + Missing state permissions:, + protectionPermissions.permissionsChange.missingStatePermissions + )} + {renderPermissions( + Missing event permissions:, + protectionPermissions.permissionsChange.missingEventPermissions + )}
              + ); } function renderMissingProtectionsPermissions( - roomID: StringRoomID, - protectionPermissions: ProtectionPermissionsChange[] + roomID: StringRoomID, + protectionPermissions: ProtectionPermissionsChange[] ): DocumentNode { - return - There are protections with missing permissions within the room {renderRoomPill(MatrixRoomReference.fromRoomID(roomID, []))}. -
                - {protectionPermissions.map(details =>
              • {renderMissingProtectionPermissions(details)}
              • )} -
              + return ( + + There are protections with missing permissions within the room{" "} + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID, []))}. +
                + {protectionPermissions.map((details) => ( +
              • {renderMissingProtectionPermissions(details)}
              • + ))} +
              + ); } export function makeHandleMissingProtectionPermissions( - client: MatrixSendClient, - managementRoomID: StringRoomID + client: MatrixSendClient, + managementRoomID: StringRoomID ): HandleMissingProtectionPermissions { - return function( - roomID, - protectionPermissions - ) { - void Task((async () => { - await renderMatrixAndSend( - {renderMissingProtectionsPermissions( - roomID, - protectionPermissions - )}, - managementRoomID, - undefined, - client - ) - })()) - } + return function (roomID, protectionPermissions) { + void Task( + (async () => { + await renderMatrixAndSend( + + {renderMissingProtectionsPermissions(roomID, protectionPermissions)} + , + managementRoomID, + undefined, + client + ); + })() + ); + }; } diff --git a/src/protections/PolicyChangeNotification.tsx b/src/protections/PolicyChangeNotification.tsx index af5f7882..4a640870 100644 --- a/src/protections/PolicyChangeNotification.tsx +++ b/src/protections/PolicyChangeNotification.tsx @@ -8,128 +8,163 @@ // https://github.com/matrix-org/mjolnir // -import { AbstractProtection, ActionResult, Logger, MatrixRoomReference, Ok, PolicyListRevision, PolicyRoomManager, PolicyRoomRevisionIssuer, PolicyRuleChange, ProtectedRoomsSet, ProtectionDescription, StringRoomID, UnknownSettings, describeProtection, isError } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + Logger, + MatrixRoomReference, + Ok, + PolicyListRevision, + PolicyRoomManager, + PolicyRoomRevisionIssuer, + PolicyRuleChange, + ProtectedRoomsSet, + ProtectionDescription, + StringRoomID, + UnknownSettings, + describeProtection, + isError, +} from "matrix-protection-suite"; import { DraupnirProtection } from "./Protection"; import { Draupnir } from "../Draupnir"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; -import { renderMentionPill, renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; +import { + renderMentionPill, + renderRoomPill, +} from "../commands/interface-manager/MatrixHelpRenderer"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; -const log = new Logger('PolicyChangeNotification'); +const log = new Logger("PolicyChangeNotification"); export type PolicyChangeNotificationCapabilitites = Record; -export type PolicyChangeNotificationProtectionDescription = ProtectionDescription< +export type PolicyChangeNotificationProtectionDescription = + ProtectionDescription< Draupnir, UnknownSettings, PolicyChangeNotificationCapabilitites ->; + >; type ChangesByRoomID = Map; export class PolicyChangeNotification - extends AbstractProtection - implements DraupnirProtection { + extends AbstractProtection + implements DraupnirProtection +{ + constructor( + description: PolicyChangeNotificationProtectionDescription, + capabilities: PolicyChangeNotificationCapabilitites, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + } - constructor( - description: PolicyChangeNotificationProtectionDescription, - capabilities: PolicyChangeNotificationCapabilitites, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - ) { - super(description, capabilities, protectedRoomsSet, {}); + public async handlePolicyChange( + revision: PolicyListRevision, + changes: PolicyRuleChange[] + ): Promise> { + if (changes.length === 0) { + return Ok(undefined); } - - public async handlePolicyChange(revision: PolicyListRevision, changes: PolicyRuleChange[]): Promise> { - if (changes.length === 0) { - return Ok(undefined); - } - const changesByList: ChangesByRoomID = new Map(); - for (const change of changes) { - const entry = changesByList.get(change.event.room_id); - if (entry === undefined) { - changesByList.set(change.event.room_id, [change]); - } else { - entry.push(change); - } - } - const groupedChanges = await groupRulesByIssuer(this.draupnir.policyRoomManager, changesByList); - if (isError(groupedChanges)) { - return groupedChanges; - } - try { - await renderMatrixAndSend( - {renderGroupedChanges(groupedChanges.ok)}, - this.draupnir.managementRoomID, - undefined, - this.draupnir.client - ); - } catch (e) { - log.error(`couldn't send change to management room`, e); - } - return Ok(undefined); + const changesByList: ChangesByRoomID = new Map(); + for (const change of changes) { + const entry = changesByList.get(change.event.room_id); + if (entry === undefined) { + changesByList.set(change.event.room_id, [change]); + } else { + entry.push(change); + } + } + const groupedChanges = await groupRulesByIssuer( + this.draupnir.policyRoomManager, + changesByList + ); + if (isError(groupedChanges)) { + return groupedChanges; + } + try { + await renderMatrixAndSend( + {renderGroupedChanges(groupedChanges.ok)}, + this.draupnir.managementRoomID, + undefined, + this.draupnir.client + ); + } catch (e) { + log.error(`couldn't send change to management room`, e); } + return Ok(undefined); + } } type GroupedChange = { - issuer: PolicyRoomRevisionIssuer, - changes: PolicyRuleChange[], -} + issuer: PolicyRoomRevisionIssuer; + changes: PolicyRuleChange[]; +}; -async function groupRulesByIssuer(policyRoomManager: PolicyRoomManager, changesByList: ChangesByRoomID): Promise> { - const groupedChanges: GroupedChange[] = [] - for (const [roomID, changes] of changesByList) { - const issuer = await policyRoomManager.getPolicyRoomRevisionIssuer(MatrixRoomReference.fromRoomID(roomID)); - if (isError(issuer)) { - return issuer; - } else { - groupedChanges.push({ - issuer: issuer.ok, - changes: changes - }) - } +async function groupRulesByIssuer( + policyRoomManager: PolicyRoomManager, + changesByList: ChangesByRoomID +): Promise> { + const groupedChanges: GroupedChange[] = []; + for (const [roomID, changes] of changesByList) { + const issuer = await policyRoomManager.getPolicyRoomRevisionIssuer( + MatrixRoomReference.fromRoomID(roomID) + ); + if (isError(issuer)) { + return issuer; + } else { + groupedChanges.push({ + issuer: issuer.ok, + changes: changes, + }); } - return Ok(groupedChanges); + } + return Ok(groupedChanges); } function renderListChange(change: PolicyRuleChange): DocumentNode { - return -
            4. - {renderMentionPill(change.sender, change.sender)} {change.changeType} - {change.rule.kind} ({change.rule.recommendation}) - {change.rule.entity} ({change.rule.reason}) -
            5. + return ( + +
            6. + {renderMentionPill(change.sender, change.sender)}{" "} + {change.changeType} + {change.rule.kind} ({change.rule.recommendation}) + {change.rule.entity} ({change.rule.reason}) +
            7. + ); } function renderListChanges({ issuer, changes }: GroupedChange): DocumentNode { - return - {renderRoomPill(issuer.room)} (shortcode: {issuer.currentRevision.shortcode ?? 'no shortcode'}) - updated with {changes.length} {changes.length === 1 ? 'change' : 'changes'}: -
                {changes.map(renderListChange)}
              + return ( + + {renderRoomPill(issuer.room)} (shortcode:{" "} + {issuer.currentRevision.shortcode ?? "no shortcode"}) updated with{" "} + {changes.length} {changes.length === 1 ? "change" : "changes"}: +
                {changes.map(renderListChange)}
              + ); } function renderGroupedChanges(groupedChanges: GroupedChange[]): DocumentNode { - return - {groupedChanges.map(renderListChanges)} - + return {groupedChanges.map(renderListChanges)}; } describeProtection({ - name: PolicyChangeNotification.name, - description: 'Provides notification of policy changes from watched lists.', - capabilityInterfaces: {}, - defaultCapabilities: {}, - factory(description, protectedRoomsSet, draupnir, capabilities, _settings) { - return Ok( - new PolicyChangeNotification( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ); - } + name: PolicyChangeNotification.name, + description: "Provides notification of policy changes from watched lists.", + capabilityInterfaces: {}, + defaultCapabilities: {}, + factory(description, protectedRoomsSet, draupnir, capabilities, _settings) { + return Ok( + new PolicyChangeNotification( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, }); diff --git a/src/protections/ProtectedRoomsSetRenderers.tsx b/src/protections/ProtectedRoomsSetRenderers.tsx index 752c0bae..6d08a95d 100644 --- a/src/protections/ProtectedRoomsSetRenderers.tsx +++ b/src/protections/ProtectedRoomsSetRenderers.tsx @@ -8,25 +8,31 @@ // https://github.com/matrix-org/mjolnir // -import { ActionError, ProtectionDescription, StringRoomID } from "matrix-protection-suite"; +import { + ActionError, + ProtectionDescription, + StringRoomID, +} from "matrix-protection-suite"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; import { DeadDocumentJSX } from "../commands/interface-manager/JSXFactory"; export async function renderProtectionFailedToStart( - client: MatrixSendClient, - managementRoomID: StringRoomID, - error: ActionError, - protectionName: string, - _protectionDescription?: ProtectionDescription + client: MatrixSendClient, + managementRoomID: StringRoomID, + error: ActionError, + protectionName: string, + _protectionDescription?: ProtectionDescription ): Promise { - await renderMatrixAndSend( - - A protection {protectionName} failed to start for the following reason: - {error.message} - , - managementRoomID, - undefined, - client - ) + await renderMatrixAndSend( + + + A protection {protectionName} failed to start for the following reason: + + {error.message} + , + managementRoomID, + undefined, + client + ); } diff --git a/src/protections/Protection.ts b/src/protections/Protection.ts index c2e91ee1..8fff97c6 100644 --- a/src/protections/Protection.ts +++ b/src/protections/Protection.ts @@ -30,8 +30,12 @@ import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { ParsedKeywords } from "../commands/interface-manager/ParameterParsing"; import { ReadItem } from "../commands/interface-manager/CommandReader"; -export interface DraupnirProtection extends Protection { - // FIXME: Protections need their own command tables - // https://github.com/Gnuxie/Draupnir/issues/21/ - status?(keywords: ParsedKeywords, ...items: ReadItem[]): Promise +export interface DraupnirProtection + extends Protection { + // FIXME: Protections need their own command tables + // https://github.com/Gnuxie/Draupnir/issues/21/ + status?( + keywords: ParsedKeywords, + ...items: ReadItem[] + ): Promise; } diff --git a/src/protections/RedactionSynchronisation.ts b/src/protections/RedactionSynchronisation.ts index 7f257daf..7d8c6239 100644 --- a/src/protections/RedactionSynchronisation.ts +++ b/src/protections/RedactionSynchronisation.ts @@ -7,98 +7,169 @@ // policies that operate on a timeline cache, which removes the painfull process // that is currently used to repeatedly fetch `/messages`. -import { AbstractProtection, ActionResult, CapabilitySet, MatrixGlob, MembershipChange, MembershipChangeType, Ok, PolicyListRevision, PolicyRule, PolicyRuleChange, PolicyRuleType, PowerLevelPermission, ProtectedRoomsSet, Protection, ProtectionDescription, Recommendation, RoomMembershipRevision, SimpleChangeType, StringRoomID, StringUserID, Task, describeProtection } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + CapabilitySet, + MatrixGlob, + MembershipChange, + MembershipChangeType, + Ok, + PolicyListRevision, + PolicyRule, + PolicyRuleChange, + PolicyRuleType, + PowerLevelPermission, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + Recommendation, + RoomMembershipRevision, + SimpleChangeType, + StringRoomID, + StringUserID, + Task, + describeProtection, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { redactUserMessagesIn } from "../utils"; -type RedactionSynchronisationProtectionDescription = ProtectionDescription; +type RedactionSynchronisationProtectionDescription = + ProtectionDescription; -export class RedactionSynchronisationProtection extends AbstractProtection implements Protection { - private automaticRedactionReasons: MatrixGlob[] = []; - public constructor( - description: RedactionSynchronisationProtectionDescription, - capabilities: CapabilitySet, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir - ) { - super( - description, - capabilities, - protectedRoomsSet, - { - requiredPermissions: [PowerLevelPermission.Redact] - } +export class RedactionSynchronisationProtection + extends AbstractProtection + implements Protection +{ + private automaticRedactionReasons: MatrixGlob[] = []; + public constructor( + description: RedactionSynchronisationProtectionDescription, + capabilities: CapabilitySet, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, { + requiredPermissions: [PowerLevelPermission.Redact], + }); + for (const reason of draupnir.config.automaticallyRedactForReasons) { + this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); + } + } + public redactForNewUserPolicy(policy: PolicyRule): void { + const rooms: StringRoomID[] = []; + if (policy.isGlob()) { + this.protectedRoomsSet.allProtectedRooms.forEach((room) => + rooms.push(room.toRoomIDOrAlias()) + ); + } else { + for (const roomMembership of this.protectedRoomsSet.setMembership + .allRooms) { + const membership = roomMembership.membershipForUser( + policy.entity as StringUserID ); - for (const reason of draupnir.config.automaticallyRedactForReasons) { - this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); + if (membership !== undefined) { + rooms.push(roomMembership.room.toRoomIDOrAlias()); } + } } - public redactForNewUserPolicy(policy: PolicyRule): void { - const rooms: StringRoomID[] = []; - if (policy.isGlob()) { - this.protectedRoomsSet.allProtectedRooms.forEach(room => rooms.push(room.toRoomIDOrAlias())); - } else { - for (const roomMembership of this.protectedRoomsSet.setMembership.allRooms) { - const membership = roomMembership.membershipForUser(policy.entity as StringUserID); - if (membership !== undefined) { - rooms.push(roomMembership.room.toRoomIDOrAlias()); - } - } - } - void Task(redactUserMessagesIn(this.draupnir.client, this.draupnir.managementRoomOutput, policy.entity, rooms)); + void Task( + redactUserMessagesIn( + this.draupnir.client, + this.draupnir.managementRoomOutput, + policy.entity, + rooms + ) + ); + } + public async handlePolicyChange( + revision: PolicyListRevision, + changes: PolicyRuleChange[] + ): Promise> { + const relevantChanges = changes.filter( + (change) => + change.changeType === SimpleChangeType.Added && + change.rule.kind === PolicyRuleType.User && + this.automaticRedactionReasons.some((reason) => + reason.test(change.rule.reason) + ) + ); + // Can't see this fucking up at all when watching a new list :skull:. + // So instead, we employ a genius big brain move. + // Basically, this stops us from overwhelming draupnir with redaction + // requests if the user watches a new list. Very unideal. + // however, please see the comment at the top of the file which explains + // how this protection **should** work, if it wasn't a stop gap. + if (relevantChanges.length > 5) { + return Ok(undefined); + } else if (relevantChanges.length === 0) { + return Ok(undefined); + } else { + relevantChanges.forEach((change) => { + this.redactForNewUserPolicy(change.rule); + }); + return Ok(undefined); } - public async handlePolicyChange(revision: PolicyListRevision, changes: PolicyRuleChange[]): Promise> { - const relevantChanges = changes.filter((change) => - change.changeType === SimpleChangeType.Added - && change.rule.kind === PolicyRuleType.User - && this.automaticRedactionReasons.some((reason => reason.test(change.rule.reason))) + } + public async handleMembershipChange( + revision: RoomMembershipRevision, + changes: MembershipChange[] + ): Promise> { + const isUserJoiningWithPolicyRequiringRedaction = ( + change: MembershipChange + ) => { + if ( + change.membershipChangeType === MembershipChangeType.Joined || + change.membershipChangeType === MembershipChangeType.Rejoined + ) { + const policyRevision = + this.protectedRoomsSet.issuerManager.policyListRevisionIssuer + .currentRevision; + const matchingPolicy = policyRevision.findRuleMatchingEntity( + change.userID, + PolicyRuleType.User, + Recommendation.Ban ); - // Can't see this fucking up at all when watching a new list :skull:. - // So instead, we employ a genius big brain move. - // Basically, this stops us from overwhelming draupnir with redaction - // requests if the user watches a new list. Very unideal. - // however, please see the comment at the top of the file which explains - // how this protection **should** work, if it wasn't a stop gap. - if (relevantChanges.length > 5) { - return Ok(undefined); - } else if (relevantChanges.length === 0) { - return Ok(undefined); - } else { - relevantChanges.forEach(change => { this.redactForNewUserPolicy(change.rule); }); - return Ok(undefined); - } - } - public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { - const isUserJoiningWithPolicyRequiringRedaction = (change: MembershipChange) => { - if (change.membershipChangeType === MembershipChangeType.Joined - || change.membershipChangeType === MembershipChangeType.Rejoined - ) { - const policyRevision = this.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision; - const matchingPolicy = policyRevision.findRuleMatchingEntity(change.userID, PolicyRuleType.User, Recommendation.Ban); - return matchingPolicy !== undefined && this.automaticRedactionReasons.some(reason => reason.test(matchingPolicy.reason)) - } else { - return false; - } - } - const relevantChanges = changes.filter(isUserJoiningWithPolicyRequiringRedaction); - for (const change of relevantChanges) { - void Task(redactUserMessagesIn(this.draupnir.client, this.draupnir.managementRoomOutput, change.userID, [revision.room.toRoomIDOrAlias()])); - } - return Ok(undefined); + return ( + matchingPolicy !== undefined && + this.automaticRedactionReasons.some((reason) => + reason.test(matchingPolicy.reason) + ) + ); + } else { + return false; + } + }; + const relevantChanges = changes.filter( + isUserJoiningWithPolicyRequiringRedaction + ); + for (const change of relevantChanges) { + void Task( + redactUserMessagesIn( + this.draupnir.client, + this.draupnir.managementRoomOutput, + change.userID, + [revision.room.toRoomIDOrAlias()] + ) + ); } + return Ok(undefined); + } } describeProtection, Draupnir>({ - name: RedactionSynchronisationProtection.name, - description: 'Redacts messages when a new ban policy has been issued that matches config.automaticallyRedactForReasons. Work in progress.', - capabilityInterfaces: {}, - defaultCapabilities: {}, - factory(description, protectedRoomsSet, draupnir, capabilities) { - return Ok(new RedactionSynchronisationProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - )) - } + name: RedactionSynchronisationProtection.name, + description: + "Redacts messages when a new ban policy has been issued that matches config.automaticallyRedactForReasons. Work in progress.", + capabilityInterfaces: {}, + defaultCapabilities: {}, + factory(description, protectedRoomsSet, draupnir, capabilities) { + return Ok( + new RedactionSynchronisationProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, }); diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 0b08e2d3..7a291a94 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -25,138 +25,186 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, EventConsequences, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringUserID, StringUserIDSetProtectionSettings, UserConsequences, describeProtection, isError } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + EventConsequences, + EventReport, + Ok, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + SafeIntegerProtectionSetting, + StandardProtectionSettings, + StringEventID, + StringUserID, + StringUserIDSetProtectionSettings, + UserConsequences, + describeProtection, + isError, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; const MAX_REPORTED_EVENT_BACKLOG = 20; type TrustedReportersProtectionSettings = { - mxids: Set, - alertThreshold: number, - redactThreshold: number, - banThreshold: number, -} + mxids: Set; + alertThreshold: number; + redactThreshold: number; + banThreshold: number; +}; type TrustedReportersCapabilities = { - userConsequences: UserConsequences; - eventConsequences: EventConsequences; -} - -type TrustedReportersDescription = ProtectionDescription; - -describeProtection({ - name: 'TrustedReporters', - description: "Count reports from trusted reporters and take a configured action", - capabilityInterfaces: { - userConsequences: 'UserConsequences', - eventConsequences: 'EventConsequences', - }, - defaultCapabilities: { - userConsequences: 'StandardUserConsequences', - eventConsequences: 'StandardEventConsequences', - }, - factory: function(description, protectedRoomsSet, draupnir, capabilities, rawSettings) { - const parsedSettings = description.protectionSettings.parseSettings(rawSettings); - if (isError(parsedSettings)) { - return parsedSettings; - } - return Ok( - new TrustedReporters( - description, - capabilities, - protectedRoomsSet, - draupnir, - parsedSettings.ok - ) - ); - }, - protectionSettings: new StandardProtectionSettings( - { - mxids: new StringUserIDSetProtectionSettings('mxids'), - alertThreshold: new SafeIntegerProtectionSetting('alertThreshold'), - redactThreshold: new SafeIntegerProtectionSetting('redactThreshold'), - banThreshold: new SafeIntegerProtectionSetting('banThreshold'), - }, - { - mxids: new Set(), - alertThreshold: 3, - // -1 means 'disabled' - redactThreshold: -1, - banThreshold: -1, - } - ) -}) + userConsequences: UserConsequences; + eventConsequences: EventConsequences; +}; + +type TrustedReportersDescription = ProtectionDescription< + Draupnir, + TrustedReportersProtectionSettings, + TrustedReportersCapabilities +>; + +describeProtection< + TrustedReportersCapabilities, + Draupnir, + TrustedReportersProtectionSettings +>({ + name: "TrustedReporters", + description: + "Count reports from trusted reporters and take a configured action", + capabilityInterfaces: { + userConsequences: "UserConsequences", + eventConsequences: "EventConsequences", + }, + defaultCapabilities: { + userConsequences: "StandardUserConsequences", + eventConsequences: "StandardEventConsequences", + }, + factory: function ( + description, + protectedRoomsSet, + draupnir, + capabilities, + rawSettings + ) { + const parsedSettings = + description.protectionSettings.parseSettings(rawSettings); + if (isError(parsedSettings)) { + return parsedSettings; + } + return Ok( + new TrustedReporters( + description, + capabilities, + protectedRoomsSet, + draupnir, + parsedSettings.ok + ) + ); + }, + protectionSettings: + new StandardProtectionSettings( + { + mxids: new StringUserIDSetProtectionSettings("mxids"), + alertThreshold: new SafeIntegerProtectionSetting("alertThreshold"), + redactThreshold: new SafeIntegerProtectionSetting("redactThreshold"), + banThreshold: new SafeIntegerProtectionSetting("banThreshold"), + }, + { + mxids: new Set(), + alertThreshold: 3, + // -1 means 'disabled' + redactThreshold: -1, + banThreshold: -1, + } + ), +}); /* * Hold a list of users trusted to make reports, and enact consequences on * events that surpass configured report count thresholds */ -export class TrustedReporters extends AbstractProtection implements Protection { - private recentReported = new Map>(); - - private readonly userConsequences: UserConsequences; - private readonly eventConsequences: EventConsequences; - public constructor( - description: TrustedReportersDescription, - capabilities: TrustedReportersCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - public readonly settings: TrustedReportersProtectionSettings - ) { - super( - description, - capabilities, - protectedRoomsSet, - {} - ); - this.userConsequences = capabilities.userConsequences; - this.eventConsequences = capabilities.eventConsequences; +export class TrustedReporters + extends AbstractProtection + implements Protection +{ + private recentReported = new Map>(); + + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; + public constructor( + description: TrustedReportersDescription, + capabilities: TrustedReportersCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + public readonly settings: TrustedReportersProtectionSettings + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.userConsequences = capabilities.userConsequences; + this.eventConsequences = capabilities.eventConsequences; + } + + public async handleEventReport( + report: EventReport + ): Promise> { + if (!this.settings.mxids.has(report.sender)) { + // not a trusted user, we're not interested + return Ok(undefined); } - public async handleEventReport(report: EventReport): Promise> { - if (!this.settings.mxids.has(report.sender)) { - // not a trusted user, we're not interested - return Ok(undefined); - } - - let reporters = this.recentReported.get(report.event_id); - if (reporters === undefined) { - // first report we've seen recently for this event - reporters = new Set(); - this.recentReported.set(report.event_id, reporters); - if (this.recentReported.size > MAX_REPORTED_EVENT_BACKLOG) { - // queue too big. push the oldest reported event off the queue - const oldest = Array.from(this.recentReported.keys())[0]; - if (oldest === undefined) { - throw new TypeError(`There should be a key in this queue if it's at max size`); - } - this.recentReported.delete(oldest); - } + let reporters = this.recentReported.get(report.event_id); + if (reporters === undefined) { + // first report we've seen recently for this event + reporters = new Set(); + this.recentReported.set(report.event_id, reporters); + if (this.recentReported.size > MAX_REPORTED_EVENT_BACKLOG) { + // queue too big. push the oldest reported event off the queue + const oldest = Array.from(this.recentReported.keys())[0]; + if (oldest === undefined) { + throw new TypeError( + `There should be a key in this queue if it's at max size` + ); } + this.recentReported.delete(oldest); + } + } - reporters.add(report.sender); + reporters.add(report.sender); - const met: string[] = []; - if (reporters.size === this.settings.alertThreshold) { - met.push("alert"); - // do nothing. let the `sendMessage` call further down be the alert - } - if (reporters.size === this.settings.redactThreshold) { - met.push("redact"); - await this.eventConsequences.consequenceForEvent(report.room_id, report.event_id, "abuse detected"); - } - if (reporters.size === this.settings.banThreshold) { - met.push("ban"); - await this.userConsequences.consequenceForUserInRoom(report.room_id, report.event.sender, "abuse detected"); - } + const met: string[] = []; + if (reporters.size === this.settings.alertThreshold) { + met.push("alert"); + // do nothing. let the `sendMessage` call further down be the alert + } + if (reporters.size === this.settings.redactThreshold) { + met.push("redact"); + await this.eventConsequences.consequenceForEvent( + report.room_id, + report.event_id, + "abuse detected" + ); + } + if (reporters.size === this.settings.banThreshold) { + met.push("ban"); + await this.userConsequences.consequenceForUserInRoom( + report.room_id, + report.event.sender, + "abuse detected" + ); + } - if (met.length > 0) { - await this.draupnir.client.sendMessage(this.draupnir.config.managementRoom, { - msgtype: "m.notice", - body: `message ${report.event_id} reported by ${[...reporters].join(', ')}. ` - + `actions: ${met.join(', ')}` - }); + if (met.length > 0) { + await this.draupnir.client.sendMessage( + this.draupnir.config.managementRoom, + { + msgtype: "m.notice", + body: + `message ${report.event_id} reported by ${[...reporters].join(", ")}. ` + + `actions: ${met.join(", ")}`, } - return Ok(undefined) + ); } + return Ok(undefined); + } } diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index 760743a5..637f7d8d 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -25,136 +25,196 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, EventConsequences, Logger, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, UserConsequences, Value, describeProtection } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionResult, + EventConsequences, + Logger, + MatrixRoomID, + MembershipChange, + MembershipChangeType, + Ok, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + RoomEvent, + RoomMembershipRevision, + RoomMessage, + StringRoomID, + StringUserID, + UserConsequences, + Value, + describeProtection, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -const log = new Logger('WordList'); +const log = new Logger("WordList"); type WordListCapabilities = { - userConsequences: UserConsequences; - eventConsequences: EventConsequences; + userConsequences: UserConsequences; + eventConsequences: EventConsequences; }; type WordListSettings = Record; -type WordListDescription = ProtectionDescription; +type WordListDescription = ProtectionDescription< + Draupnir, + WordListSettings, + WordListCapabilities +>; describeProtection({ - name: 'WordListProteciton', - description: "If a user posts a monitored word a set amount of time after joining, they\ + name: "WordListProteciton", + description: + "If a user posts a monitored word a set amount of time after joining, they\ will be banned from that room. This will not publish the ban to a ban list.", - capabilityInterfaces: { - userConsequences: 'UserConsequences', - eventConsequences: 'EventConsequences', - }, - defaultCapabilities: { - userConsequences: 'StandardUserConsequences', - eventConsequences: 'StandardEventConsequences', - }, - factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { - return Ok( - new WordListProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ); - } + capabilityInterfaces: { + userConsequences: "UserConsequences", + eventConsequences: "EventConsequences", + }, + defaultCapabilities: { + userConsequences: "StandardUserConsequences", + eventConsequences: "StandardEventConsequences", + }, + factory: function ( + description, + protectedRoomsSet, + draupnir, + capabilities, + _settings + ) { + return Ok( + new WordListProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, }); type JustJoinedUsers = Map; type JustJoinedByRoom = Map; -export class WordListProtection extends AbstractProtection implements Protection { - private justJoined: JustJoinedByRoom = new Map(); - private badWords?: RegExp; - - private readonly userConsequences: UserConsequences; - private readonly eventConsequences: EventConsequences; - constructor( - description: WordListDescription, - capabilities: WordListCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - ) { - super( - description, - capabilities, - protectedRoomsSet, - {} - ); - this.userConsequences = capabilities.userConsequences; - this.eventConsequences = capabilities.eventConsequences; - } - public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { - const roomID = revision.room.toRoomIDOrAlias(); - const minsBeforeTrusting = this.draupnir.config.protections.wordlist.minutesBeforeTrusting; - if (minsBeforeTrusting > 0) { - for (const change of changes) { - const entryForRoom = this.justJoined.get(roomID) - ?? ((entry) => (this.justJoined.set(roomID, entry), entry))(new Map()); - // When a new member logs in, store the time they joined. This will be useful - // when we need to check if a message was sent within 20 minutes of joining - if (change.membershipChangeType === MembershipChangeType.Joined) { - const now = new Date(); - entryForRoom.set(change.userID, now); - log.debug(`${change.userID} joined ${roomID} at ${now.toDateString()}`); - } else if (change.membershipChangeType === MembershipChangeType.Left || change.membershipChangeType === MembershipChangeType.Banned || change.membershipChangeType === MembershipChangeType.Kicked) { - entryForRoom.delete(change.userID); - } - } +export class WordListProtection + extends AbstractProtection + implements Protection +{ + private justJoined: JustJoinedByRoom = new Map(); + private badWords?: RegExp; + + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; + constructor( + description: WordListDescription, + capabilities: WordListCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.userConsequences = capabilities.userConsequences; + this.eventConsequences = capabilities.eventConsequences; + } + public async handleMembershipChange( + revision: RoomMembershipRevision, + changes: MembershipChange[] + ): Promise> { + const roomID = revision.room.toRoomIDOrAlias(); + const minsBeforeTrusting = + this.draupnir.config.protections.wordlist.minutesBeforeTrusting; + if (minsBeforeTrusting > 0) { + for (const change of changes) { + const entryForRoom = + this.justJoined.get(roomID) ?? + ((entry) => (this.justJoined.set(roomID, entry), entry))(new Map()); + // When a new member logs in, store the time they joined. This will be useful + // when we need to check if a message was sent within 20 minutes of joining + if (change.membershipChangeType === MembershipChangeType.Joined) { + const now = new Date(); + entryForRoom.set(change.userID, now); + log.debug( + `${change.userID} joined ${roomID} at ${now.toDateString()}` + ); + } else if ( + change.membershipChangeType === MembershipChangeType.Left || + change.membershipChangeType === MembershipChangeType.Banned || + change.membershipChangeType === MembershipChangeType.Kicked + ) { + entryForRoom.delete(change.userID); } - return Ok(undefined); + } } - - public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { - const minsBeforeTrusting = this.draupnir.config.protections.wordlist.minutesBeforeTrusting; - if (Value.Check(RoomMessage, event)) { - if (!('msgtype' in event.content)) { - return Ok(undefined); - } - const message = ('formatted_body' in event.content && event.content['formatted_body']) || event.content['body']; - const roomID = room.toRoomIDOrAlias(); - - // Check conditions first - if (minsBeforeTrusting > 0) { - const roomEntry = this.justJoined.get(roomID); - const joinTime = roomEntry?.get(event['sender']); - if (joinTime !== undefined) { // Disregard if the user isn't recently joined - - // Check if they did join recently, was it within the timeframe - const now = new Date(); - if (now.valueOf() - joinTime.valueOf() > minsBeforeTrusting * 60 * 1000) { - roomEntry?.delete(event['sender']) // Remove the user - log.info(`${event['sender']} is no longer considered suspect`); - return Ok(undefined); - } - - } else { - // The user isn't in the recently joined users list, no need to keep - // looking - return Ok(undefined); - } - } - if (!this.badWords) { - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - const escapeRegExp = (string: string) => { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }; - - // Create a mega-regex from all the tiny words. - const words = this.draupnir.config.protections.wordlist.words.filter(word => word.length !== 0).map(escapeRegExp); - this.badWords = new RegExp(words.join("|"), "i"); - } - - const match = this.badWords.exec(message); - if (match) { - const reason = `bad word: ${match[0]}`; - await this.userConsequences.consequenceForUserInRoom(roomID, event.sender, reason); - await this.eventConsequences.consequenceForEvent(roomID, event.event_id, reason); - } - } + return Ok(undefined); + } + + public async handleTimelineEvent( + room: MatrixRoomID, + event: RoomEvent + ): Promise> { + const minsBeforeTrusting = + this.draupnir.config.protections.wordlist.minutesBeforeTrusting; + if (Value.Check(RoomMessage, event)) { + if (!("msgtype" in event.content)) { return Ok(undefined); + } + const message = + ("formatted_body" in event.content && + event.content["formatted_body"]) || + event.content["body"]; + const roomID = room.toRoomIDOrAlias(); + + // Check conditions first + if (minsBeforeTrusting > 0) { + const roomEntry = this.justJoined.get(roomID); + const joinTime = roomEntry?.get(event["sender"]); + if (joinTime !== undefined) { + // Disregard if the user isn't recently joined + + // Check if they did join recently, was it within the timeframe + const now = new Date(); + if ( + now.valueOf() - joinTime.valueOf() > + minsBeforeTrusting * 60 * 1000 + ) { + roomEntry?.delete(event["sender"]); // Remove the user + log.info(`${event["sender"]} is no longer considered suspect`); + return Ok(undefined); + } + } else { + // The user isn't in the recently joined users list, no need to keep + // looking + return Ok(undefined); + } + } + if (!this.badWords) { + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + const escapeRegExp = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }; + + // Create a mega-regex from all the tiny words. + const words = this.draupnir.config.protections.wordlist.words + .filter((word) => word.length !== 0) + .map(escapeRegExp); + this.badWords = new RegExp(words.join("|"), "i"); + } + + const match = this.badWords.exec(message); + if (match) { + const reason = `bad word: ${match[0]}`; + await this.userConsequences.consequenceForUserInRoom( + roomID, + event.sender, + reason + ); + await this.eventConsequences.consequenceForEvent( + roomID, + event.event_id, + reason + ); + } } + return Ok(undefined); + } } diff --git a/src/protections/invitation/JoinRoomsOnInviteProtection.tsx b/src/protections/invitation/JoinRoomsOnInviteProtection.tsx index 88f5dd92..f21937be 100644 --- a/src/protections/invitation/JoinRoomsOnInviteProtection.tsx +++ b/src/protections/invitation/JoinRoomsOnInviteProtection.tsx @@ -8,14 +8,32 @@ // https://github.com/matrix-org/mjolnir // -import { AbstractProtection, ActionError, ActionResult, MatrixRoomReference, MembershipEvent, Ok, ProtectedRoomsSet, ProtectionDescription, StandardDeduplicator, StringRoomID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { + AbstractProtection, + ActionError, + ActionResult, + MatrixRoomReference, + MembershipEvent, + Ok, + ProtectedRoomsSet, + ProtectionDescription, + StandardDeduplicator, + StringRoomID, + Task, + describeProtection, + isError, + serverName, +} from "matrix-protection-suite"; import { Draupnir } from "../../Draupnir"; import { DraupnirProtection } from "../Protection"; import { isInvitationForUser, isSenderJoinedInRevision } from "./inviteCore"; import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; import { DocumentNode } from "../../commands/interface-manager/DeadDocument"; import { DeadDocumentJSX } from "../../commands/interface-manager/JSXFactory"; -import { renderMentionPill, renderRoomPill } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { + renderMentionPill, + renderRoomPill, +} from "../../commands/interface-manager/MatrixHelpRenderer"; import { renderFailedSingularConsequence } from "../../capabilities/CommonRenderers"; import { ProtectroomsOnInvite } from "./ProtectRoomsOnInvite"; import { WatchRoomsOnInvite } from "./WatchRoomsOnInvite"; @@ -23,130 +41,165 @@ import { WatchRoomsOnInvite } from "./WatchRoomsOnInvite"; export type JoinRoomsOnInviteProtectionCapabilities = Record; export type JoinRoomsOnInviteProtectionSettings = Record; -export type JoinRoomsOnInviteProtectionDescription = ProtectionDescription; +export type JoinRoomsOnInviteProtectionDescription = ProtectionDescription< + Draupnir, + JoinRoomsOnInviteProtectionSettings, + JoinRoomsOnInviteProtectionCapabilities +>; export class JoinRoomsOnInviteProtection - extends AbstractProtection - implements DraupnirProtection< - JoinRoomsOnInviteProtectionDescription -> { - private readonly promptedToProtectedDeduplicator = new StandardDeduplicator(); - private readonly protectRoomsOnInvite = new ProtectroomsOnInvite( - this.draupnir, - this.protectedRoomsSet - ); - private readonly watchRoomsOnInvite = new WatchRoomsOnInvite( - this.draupnir, - this.protectedRoomsSet - ); - public constructor( - description: JoinRoomsOnInviteProtectionDescription, - capabilities: JoinRoomsOnInviteProtectionCapabilities, - protectedRoomsSet: ProtectedRoomsSet, - private readonly draupnir: Draupnir, - ) { - super( - description, - capabilities, - protectedRoomsSet, - {} - ) - } + extends AbstractProtection + implements DraupnirProtection +{ + private readonly promptedToProtectedDeduplicator = + new StandardDeduplicator(); + private readonly protectRoomsOnInvite = new ProtectroomsOnInvite( + this.draupnir, + this.protectedRoomsSet + ); + private readonly watchRoomsOnInvite = new WatchRoomsOnInvite( + this.draupnir, + this.protectedRoomsSet + ); + public constructor( + description: JoinRoomsOnInviteProtectionDescription, + capabilities: JoinRoomsOnInviteProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super(description, capabilities, protectedRoomsSet, {}); + } - handleProtectionDisable(): void { - this.protectRoomsOnInvite.handleProtectionDisable(); - this.watchRoomsOnInvite.handleProtectionDisable(); - } - - handleExternalInvite(roomID: StringRoomID, event: MembershipEvent): void { - if (!isInvitationForUser(event, this.protectedRoomsSet.userID)) { - return; - } - // The event handler gets called again when we join the room we were invited to. - // As sometimes we get the invitation a second time from the join section of sync. - if (this.promptedToProtectedDeduplicator.isDuplicate(roomID)) { - return; - } - void Task(this.checkAgainstRequiredMembershipRoom(event)); - } + handleProtectionDisable(): void { + this.protectRoomsOnInvite.handleProtectionDisable(); + this.watchRoomsOnInvite.handleProtectionDisable(); + } - private async checkAgainstRequiredMembershipRoom(event: MembershipEvent): Promise> { - const revision = this.draupnir.acceptInvitesFromRoomIssuer.currentRevision; - if (isSenderJoinedInRevision(event.sender, revision)) { - return await this.joinAndIssuePrompts(event); - } else { - this.reportUnknownInvite(event, revision.room); - return Ok(undefined); - } + handleExternalInvite(roomID: StringRoomID, event: MembershipEvent): void { + if (!isInvitationForUser(event, this.protectedRoomsSet.userID)) { + return; } - - private reportUnknownInvite(event: MembershipEvent, requiredMembershipRoom: MatrixRoomReference): void { - const renderUnknownInvite = (): DocumentNode => { - return - {renderMentionPill(event.sender, event.sender)} has invited me to - {renderRoomPill(MatrixRoomReference.fromRoomID(event.room_id))} - but they are not joined to {renderRoomPill(requiredMembershipRoom)}, which prevents me from accepting their invitation.
              - If you would like this room protected, use !draupnir rooms add {event.room_id} -
              - } - void Task((async () => { - await renderMatrixAndSend( - renderUnknownInvite(), - this.draupnir.managementRoomID, - undefined, - this.draupnir.client - ); - return Ok(undefined); - })()); + // The event handler gets called again when we join the room we were invited to. + // As sometimes we get the invitation a second time from the join section of sync. + if (this.promptedToProtectedDeduplicator.isDuplicate(roomID)) { + return; } + void Task(this.checkAgainstRequiredMembershipRoom(event)); + } - private async joinInvitedRoom(event: MembershipEvent, room: MatrixRoomReference): Promise> { - const renderFailedTojoin = (error: ActionError) => { - const title = Unfortunatley I was unable to accept the invitation from {renderMentionPill(event.sender, event.sender)} to the room {renderRoomPill(room)}.; - return - {renderFailedSingularConsequence(this.description, title, error)} - - }; - const joinResult = await this.draupnir.clientPlatform.toRoomJoiner().joinRoom(room); - if (isError(joinResult)) { - await renderMatrixAndSend( - renderFailedTojoin(joinResult.error), - this.draupnir.managementRoomID, - undefined, - this.draupnir.client - ) - } - return joinResult; + private async checkAgainstRequiredMembershipRoom( + event: MembershipEvent + ): Promise> { + const revision = this.draupnir.acceptInvitesFromRoomIssuer.currentRevision; + if (isSenderJoinedInRevision(event.sender, revision)) { + return await this.joinAndIssuePrompts(event); + } else { + this.reportUnknownInvite(event, revision.room); + return Ok(undefined); } + } - private async joinAndIssuePrompts(event: MembershipEvent): Promise> { - const invitedRoomReference = MatrixRoomReference.fromRoomID(event.room_id, [serverName(event.sender), serverName(event.state_key)]); - const joinResult = await this.joinInvitedRoom(event, invitedRoomReference); - if (isError(joinResult)) { - return joinResult; - } - this.watchRoomsOnInvite.promptIfPossiblePolicyRoom(invitedRoomReference, event); - if (!this.draupnir.config.protectAllJoinedRooms && !this.protectedRoomsSet.isProtectedRoom(event.room_id)) { - this.protectRoomsOnInvite.promptToProtect(invitedRoomReference, event); - } + private reportUnknownInvite( + event: MembershipEvent, + requiredMembershipRoom: MatrixRoomReference + ): void { + const renderUnknownInvite = (): DocumentNode => { + return ( + + {renderMentionPill(event.sender, event.sender)} has invited me to + {renderRoomPill(MatrixRoomReference.fromRoomID(event.room_id))} + but they are not joined to {renderRoomPill(requiredMembershipRoom)}, + which prevents me from accepting their invitation. +
              + If you would like this room protected, use{" "} + !draupnir rooms add {event.room_id} +
              + ); + }; + void Task( + (async () => { + await renderMatrixAndSend( + renderUnknownInvite(), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client + ); return Ok(undefined); + })() + ); + } + + private async joinInvitedRoom( + event: MembershipEvent, + room: MatrixRoomReference + ): Promise> { + const renderFailedTojoin = (error: ActionError) => { + const title = ( + + Unfortunatley I was unable to accept the invitation from{" "} + {renderMentionPill(event.sender, event.sender)} to the room{" "} + {renderRoomPill(room)}. + + ); + return ( + + {renderFailedSingularConsequence(this.description, title, error)} + + ); + }; + const joinResult = await this.draupnir.clientPlatform + .toRoomJoiner() + .joinRoom(room); + if (isError(joinResult)) { + await renderMatrixAndSend( + renderFailedTojoin(joinResult.error), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client + ); } + return joinResult; + } + private async joinAndIssuePrompts( + event: MembershipEvent + ): Promise> { + const invitedRoomReference = MatrixRoomReference.fromRoomID(event.room_id, [ + serverName(event.sender), + serverName(event.state_key), + ]); + const joinResult = await this.joinInvitedRoom(event, invitedRoomReference); + if (isError(joinResult)) { + return joinResult; + } + this.watchRoomsOnInvite.promptIfPossiblePolicyRoom( + invitedRoomReference, + event + ); + if ( + !this.draupnir.config.protectAllJoinedRooms && + !this.protectedRoomsSet.isProtectedRoom(event.room_id) + ) { + this.protectRoomsOnInvite.promptToProtect(invitedRoomReference, event); + } + return Ok(undefined); + } } describeProtection({ - name: JoinRoomsOnInviteProtection.name, - description: "Automatically joins rooms when invited by members of the management room and offers to protect them", - capabilityInterfaces: {}, - defaultCapabilities: {}, - factory(description, protectedRoomsSet, draupnir, capabilities, _settings) { - return Ok( - new JoinRoomsOnInviteProtection( - description, - capabilities, - protectedRoomsSet, - draupnir - ) - ) - } -}) + name: JoinRoomsOnInviteProtection.name, + description: + "Automatically joins rooms when invited by members of the management room and offers to protect them", + capabilityInterfaces: {}, + defaultCapabilities: {}, + factory(description, protectedRoomsSet, draupnir, capabilities, _settings) { + return Ok( + new JoinRoomsOnInviteProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ); + }, +}); diff --git a/src/protections/invitation/ProtectRoomsOnInvite.tsx b/src/protections/invitation/ProtectRoomsOnInvite.tsx index 55132546..da17af06 100644 --- a/src/protections/invitation/ProtectRoomsOnInvite.tsx +++ b/src/protections/invitation/ProtectRoomsOnInvite.tsx @@ -8,99 +8,170 @@ // https://github.com/matrix-org/mjolnir // -import { Logger, MatrixRoomID, MembershipEvent, Ok, Permalink, ProtectedRoomsSet, RoomEvent, Task, Value, isError } from "matrix-protection-suite"; +import { + Logger, + MatrixRoomID, + MembershipEvent, + Ok, + Permalink, + ProtectedRoomsSet, + RoomEvent, + Task, + Value, + isError, +} from "matrix-protection-suite"; import { DocumentNode } from "../../commands/interface-manager/DeadDocument"; -import { renderActionResultToEvent, renderMentionPill, renderRoomPill } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { + renderActionResultToEvent, + renderMentionPill, + renderRoomPill, +} from "../../commands/interface-manager/MatrixHelpRenderer"; import { DeadDocumentJSX } from "../../commands/interface-manager/JSXFactory"; import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; import { StaticDecode, Type } from "@sinclair/typebox"; import { Draupnir } from "../../Draupnir"; -const log = new Logger('ProtectRoomsOnInvite'); +const log = new Logger("ProtectRoomsOnInvite"); -const PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER = 'me.marewolf.draupnir.protect_rooms_on_invite'; +const PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER = + "me.marewolf.draupnir.protect_rooms_on_invite"; // would be nice to be able to use presentation types here idk. const ProtectRoomsOnInvitePromptContext = Type.Object({ - invited_room: Permalink + invited_room: Permalink, }); // this rule is stupid. -type ProtectRoomsOnInvitePromptContext = StaticDecode; +type ProtectRoomsOnInvitePromptContext = StaticDecode< + typeof ProtectRoomsOnInvitePromptContext +>; export class ProtectroomsOnInvite { + private readonly protectPromptListener = this.protectListener.bind(this); + public constructor( + private readonly draupnir: Draupnir, + private readonly protectedRoomsSet: ProtectedRoomsSet + ) { + this.draupnir.reactionHandler.on( + PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, + this.protectPromptListener + ); + } - private readonly protectPromptListener = this.protectListener.bind(this); - public constructor( - private readonly draupnir: Draupnir, - private readonly protectedRoomsSet: ProtectedRoomsSet, - ) { - this.draupnir.reactionHandler.on(PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, this.protectPromptListener); - } + handleProtectionDisable(): void { + this.draupnir.reactionHandler.off( + PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, + this.protectPromptListener + ); + } - handleProtectionDisable(): void { - this.draupnir.reactionHandler.off(PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, this.protectPromptListener); - } + public promptToProtect( + candidateRoom: MatrixRoomID, + invitation: MembershipEvent + ): void { + void Task( + (async () => { + const renderPromptProtect = (): DocumentNode => ( + + {renderMentionPill(invitation.sender, invitation.sender)} has + invited me to + {renderRoomPill(candidateRoom)}, would you like to protect this + room? + + ); + const reactionMap = new Map( + Object.entries({ OK: "OK", Cancel: "Cancel" }) + ); + const promptEventID = ( + await renderMatrixAndSend( + renderPromptProtect(), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client, + this.draupnir.reactionHandler.createAnnotation( + PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, + reactionMap, + { + invited_room: candidateRoom.toPermalink(), + } + ) + ) + )[0]; + if (promptEventID === undefined) { + throw new TypeError( + `We should have an eventID for the event that we just sent...` + ); + } + await this.draupnir.reactionHandler.addReactionsToEvent( + this.draupnir.client, + this.draupnir.managementRoomID, + promptEventID, + reactionMap + ); + return Ok(undefined); + })() + ); + } - public promptToProtect(candidateRoom: MatrixRoomID, invitation: MembershipEvent): void { - void Task((async () => { - const renderPromptProtect = (): DocumentNode => - - {renderMentionPill(invitation.sender, invitation.sender)} has invited me to - {renderRoomPill(candidateRoom)}, - would you like to protect this room? - ; - const reactionMap = new Map(Object.entries({ 'OK': 'OK', 'Cancel': 'Cancel' })); - const promptEventID = (await renderMatrixAndSend( - renderPromptProtect(), - this.draupnir.managementRoomID, - undefined, - this.draupnir.client, - this.draupnir.reactionHandler.createAnnotation( - PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, - reactionMap, - { - invited_room: candidateRoom.toPermalink(), - } - ) - ))[0]; - if (promptEventID === undefined) { - throw new TypeError(`We should have an eventID for the event that we just sent...`); - } - await this.draupnir.reactionHandler.addReactionsToEvent(this.draupnir.client, this.draupnir.managementRoomID, promptEventID, reactionMap); - return Ok(undefined); - })()) + private protectListener( + key: string, + _item: unknown, + rawContext: unknown, + _reactionMap: Map, + promptEvent: RoomEvent + ): void { + if (key === "Cancel") { + void Task(this.draupnir.reactionHandler.cancelPrompt(promptEvent)); + return; } - - private protectListener(key: string, _item: unknown, rawContext: unknown, _reactionMap: Map, promptEvent: RoomEvent): void { - if (key === 'Cancel') { - void Task(this.draupnir.reactionHandler.cancelPrompt(promptEvent)); - return; - } - if (key !== 'OK') { - return; + if (key !== "OK") { + return; + } + const context = Value.Decode(ProtectRoomsOnInvitePromptContext, rawContext); + if (isError(context)) { + log.error(`Could not decode context from prompt event`, context.error); + renderActionResultToEvent(this.draupnir.client, promptEvent, context); + return; + } + void Task( + (async () => { + const resolvedRoom = await this.draupnir.clientPlatform + .toRoomResolver() + .resolveRoom(context.ok.invited_room); + if (isError(resolvedRoom)) { + resolvedRoom.elaborate( + `Could not resolve the room to protect from the MatrixRoomReference: ${context.ok.invited_room.toPermalink()}.` + ); + renderActionResultToEvent( + this.draupnir.client, + promptEvent, + resolvedRoom + ); + return; } - const context = Value.Decode(ProtectRoomsOnInvitePromptContext, rawContext); - if (isError(context)) { - log.error(`Could not decode context from prompt event`, context.error); - renderActionResultToEvent(this.draupnir.client, promptEvent, context); - return; + const addResult = + await this.protectedRoomsSet.protectedRoomsManager.addRoom( + resolvedRoom.ok + ); + if (isError(addResult)) { + addResult.elaborate( + `Could not protect the room: ${resolvedRoom.ok.toPermalink()}` + ); + renderActionResultToEvent( + this.draupnir.client, + promptEvent, + addResult + ); + return; } - void Task((async () => { - const resolvedRoom = await this.draupnir.clientPlatform.toRoomResolver().resolveRoom(context.ok.invited_room); - if (isError(resolvedRoom)) { - resolvedRoom.elaborate(`Could not resolve the room to protect from the MatrixRoomReference: ${context.ok.invited_room.toPermalink()}.`); - renderActionResultToEvent(this.draupnir.client, promptEvent, resolvedRoom); - return; - } - const addResult = await this.protectedRoomsSet.protectedRoomsManager.addRoom(resolvedRoom.ok) - if (isError(addResult)) { - addResult.elaborate(`Could not protect the room: ${resolvedRoom.ok.toPermalink()}`); - renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); - return; - } - renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); - void Task(this.draupnir.reactionHandler.completePrompt(promptEvent.room_id, promptEvent.event_id)); - })()); - } + renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); + void Task( + this.draupnir.reactionHandler.completePrompt( + promptEvent.room_id, + promptEvent.event_id + ) + ); + })() + ); + } } diff --git a/src/protections/invitation/WatchRoomsOnInvite.tsx b/src/protections/invitation/WatchRoomsOnInvite.tsx index 2b00881f..7c311480 100644 --- a/src/protections/invitation/WatchRoomsOnInvite.tsx +++ b/src/protections/invitation/WatchRoomsOnInvite.tsx @@ -8,125 +8,220 @@ // https://github.com/matrix-org/mjolnir // -import { ALL_RULE_TYPES, Logger, MJOLNIR_SHORTCODE_EVENT_TYPE, MatrixRoomID, MembershipEvent, Ok, Permalink, PropagationType, ProtectedRoomsSet, RoomEvent, RoomStateRevision, Task, Value, isError } from "matrix-protection-suite"; +import { + ALL_RULE_TYPES, + Logger, + MJOLNIR_SHORTCODE_EVENT_TYPE, + MatrixRoomID, + MembershipEvent, + Ok, + Permalink, + PropagationType, + ProtectedRoomsSet, + RoomEvent, + RoomStateRevision, + Task, + Value, + isError, +} from "matrix-protection-suite"; import { Draupnir } from "../../Draupnir"; import { DocumentNode } from "../../commands/interface-manager/DeadDocument"; import { DeadDocumentJSX } from "../../commands/interface-manager/JSXFactory"; -import { renderActionResultToEvent, renderMentionPill, renderRoomPill } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { + renderActionResultToEvent, + renderMentionPill, + renderRoomPill, +} from "../../commands/interface-manager/MatrixHelpRenderer"; import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; import { StaticDecode, Type } from "@sinclair/typebox"; -const log = new Logger('WatchRoomsOnInvite'); +const log = new Logger("WatchRoomsOnInvite"); -const WATCH_LISTS_ON_INVITE_PROMPT_LISTENER = 'me.marewolf.draupnir.watch_rooms_on_invite'; +const WATCH_LISTS_ON_INVITE_PROMPT_LISTENER = + "me.marewolf.draupnir.watch_rooms_on_invite"; // would be nice to be able to use presentation types here idk. const WatchRoomsOnInvitePromptContext = Type.Object({ - invited_room: Permalink + invited_room: Permalink, }); // this rule is stupid. -type WatchRoomsOnInvitePromptContext = StaticDecode; +type WatchRoomsOnInvitePromptContext = StaticDecode< + typeof WatchRoomsOnInvitePromptContext +>; function isRevisionContainingPolicies(revision: RoomStateRevision) { - return revision.getStateEventsOfTypes(ALL_RULE_TYPES).length > 0; + return revision.getStateEventsOfTypes(ALL_RULE_TYPES).length > 0; } function isRevisionContainingShortcode(revision: RoomStateRevision) { - return revision.getStateEvent(MJOLNIR_SHORTCODE_EVENT_TYPE, '') !== undefined; + return revision.getStateEvent(MJOLNIR_SHORTCODE_EVENT_TYPE, "") !== undefined; } export function isRevisionLikelyPolicyRoom(revision: RoomStateRevision) { - return isRevisionContainingPolicies(revision) || isRevisionContainingShortcode(revision); + return ( + isRevisionContainingPolicies(revision) || + isRevisionContainingShortcode(revision) + ); } export class WatchRoomsOnInvite { - private readonly watchPromptListener = this.watchListener.bind(this); - public constructor( - private readonly draupnir: Draupnir, - private readonly protectedRoomsSet: ProtectedRoomsSet, - ) { - this.draupnir.reactionHandler.on(WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, this.watchPromptListener); - } - - handleProtectionDisable(): void { - this.draupnir.reactionHandler.off(WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, this.watchPromptListener); - } + private readonly watchPromptListener = this.watchListener.bind(this); + public constructor( + private readonly draupnir: Draupnir, + private readonly protectedRoomsSet: ProtectedRoomsSet + ) { + this.draupnir.reactionHandler.on( + WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, + this.watchPromptListener + ); + } - public promptIfPossiblePolicyRoom(candidateRoom: MatrixRoomID, invitation: MembershipEvent): void { - void Task((async () => { - const stateRevisionIssuer = await this.draupnir.roomStateManager.getRoomStateRevisionIssuer(candidateRoom); - if (isError(stateRevisionIssuer)) { - return stateRevisionIssuer.elaborate(`Unable to fetch the room state revision issuer to check if newly joined room was a policy room.`); - } - if (!isRevisionLikelyPolicyRoom(stateRevisionIssuer.ok.currentRevision)) { - return Ok(undefined); - } - const promptResult = await this.promptWatchPolicyRoom(candidateRoom, invitation); - if (isError(promptResult)) { - return promptResult.elaborate(`Unable to send prompt to ask if Draupnir should watch a policy room`); - } - return Ok(undefined); - })()); - } + handleProtectionDisable(): void { + this.draupnir.reactionHandler.off( + WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, + this.watchPromptListener + ); + } - private async promptWatchPolicyRoom(candidateRoom: MatrixRoomID, invitation: MembershipEvent) { - const renderPromptWatch = (): DocumentNode => - - {renderMentionPill(invitation.sender, invitation.sender)} has invited me to a policy room - {renderRoomPill(candidateRoom)}, - would you like Draupnir to watch this room as a policy list? - ; - const reactionMap = new Map(Object.entries({ 'OK': 'OK', 'Cancel': 'Cancel' })); - const promptEventID = (await renderMatrixAndSend( - renderPromptWatch(), - this.draupnir.managementRoomID, - undefined, - this.draupnir.client, - this.draupnir.reactionHandler.createAnnotation( - WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, - reactionMap, - { - invited_room: candidateRoom.toPermalink(), - } - ) - ))[0]; - if (promptEventID === undefined) { - throw new TypeError(`We should have an eventID for the event that we just sent...`); + public promptIfPossiblePolicyRoom( + candidateRoom: MatrixRoomID, + invitation: MembershipEvent + ): void { + void Task( + (async () => { + const stateRevisionIssuer = + await this.draupnir.roomStateManager.getRoomStateRevisionIssuer( + candidateRoom + ); + if (isError(stateRevisionIssuer)) { + return stateRevisionIssuer.elaborate( + `Unable to fetch the room state revision issuer to check if newly joined room was a policy room.` + ); + } + if ( + !isRevisionLikelyPolicyRoom(stateRevisionIssuer.ok.currentRevision) + ) { + return Ok(undefined); + } + const promptResult = await this.promptWatchPolicyRoom( + candidateRoom, + invitation + ); + if (isError(promptResult)) { + return promptResult.elaborate( + `Unable to send prompt to ask if Draupnir should watch a policy room` + ); } - await this.draupnir.reactionHandler.addReactionsToEvent(this.draupnir.client, this.draupnir.managementRoomID, promptEventID, reactionMap); return Ok(undefined); + })() + ); + } + + private async promptWatchPolicyRoom( + candidateRoom: MatrixRoomID, + invitation: MembershipEvent + ) { + const renderPromptWatch = (): DocumentNode => ( + + {renderMentionPill(invitation.sender, invitation.sender)} has invited me + to a policy room + {renderRoomPill(candidateRoom)}, would you like Draupnir to watch this + room as a policy list? + + ); + const reactionMap = new Map( + Object.entries({ OK: "OK", Cancel: "Cancel" }) + ); + const promptEventID = ( + await renderMatrixAndSend( + renderPromptWatch(), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client, + this.draupnir.reactionHandler.createAnnotation( + WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, + reactionMap, + { + invited_room: candidateRoom.toPermalink(), + } + ) + ) + )[0]; + if (promptEventID === undefined) { + throw new TypeError( + `We should have an eventID for the event that we just sent...` + ); } + await this.draupnir.reactionHandler.addReactionsToEvent( + this.draupnir.client, + this.draupnir.managementRoomID, + promptEventID, + reactionMap + ); + return Ok(undefined); + } - private watchListener(key: string, _item: unknown, rawContext: unknown, _reactionMap: Map, promptEvent: RoomEvent): void { - if (key === 'Cancel') { - void Task(this.draupnir.reactionHandler.cancelPrompt(promptEvent)); - return; - } - if (key !== 'OK') { - return; + private watchListener( + key: string, + _item: unknown, + rawContext: unknown, + _reactionMap: Map, + promptEvent: RoomEvent + ): void { + if (key === "Cancel") { + void Task(this.draupnir.reactionHandler.cancelPrompt(promptEvent)); + return; + } + if (key !== "OK") { + return; + } + const context = Value.Decode(WatchRoomsOnInvitePromptContext, rawContext); + if (isError(context)) { + log.error(`Could not decode context from prompt event`, context.error); + renderActionResultToEvent(this.draupnir.client, promptEvent, context); + return; + } + void Task( + (async () => { + const resolvedRoom = await this.draupnir.clientPlatform + .toRoomResolver() + .resolveRoom(context.ok.invited_room); + if (isError(resolvedRoom)) { + resolvedRoom.elaborate( + `Could not resolve the policy room to watch from the MatrixRoomReference: ${context.ok.invited_room.toPermalink()}.` + ); + renderActionResultToEvent( + this.draupnir.client, + promptEvent, + resolvedRoom + ); + return; } - const context = Value.Decode(WatchRoomsOnInvitePromptContext, rawContext); - if (isError(context)) { - log.error(`Could not decode context from prompt event`, context.error); - renderActionResultToEvent(this.draupnir.client, promptEvent, context); - return; + const addResult = await this.protectedRoomsSet.issuerManager.watchList( + PropagationType.Direct, + resolvedRoom.ok, + {} + ); + if (isError(addResult)) { + addResult.elaborate( + `Could not watch the policy room: ${resolvedRoom.ok.toPermalink()}` + ); + renderActionResultToEvent( + this.draupnir.client, + promptEvent, + addResult + ); + return; } - void Task((async () => { - const resolvedRoom = await this.draupnir.clientPlatform.toRoomResolver().resolveRoom(context.ok.invited_room); - if (isError(resolvedRoom)) { - resolvedRoom.elaborate(`Could not resolve the policy room to watch from the MatrixRoomReference: ${context.ok.invited_room.toPermalink()}.`); - renderActionResultToEvent(this.draupnir.client, promptEvent, resolvedRoom); - return; - } - const addResult = await this.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, resolvedRoom.ok, {}); - if (isError(addResult)) { - addResult.elaborate(`Could not watch the policy room: ${resolvedRoom.ok.toPermalink()}`); - renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); - return; - } - renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); - void Task(this.draupnir.reactionHandler.completePrompt(promptEvent.room_id, promptEvent.event_id)); - })()); - } + renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); + void Task( + this.draupnir.reactionHandler.completePrompt( + promptEvent.room_id, + promptEvent.event_id + ) + ); + })() + ); + } } diff --git a/src/protections/invitation/inviteCore.ts b/src/protections/invitation/inviteCore.ts index d6650e81..3ab626e5 100644 --- a/src/protections/invitation/inviteCore.ts +++ b/src/protections/invitation/inviteCore.ts @@ -2,20 +2,27 @@ // // SPDX-License-Identifier: AFL-3.0 -import { Membership, MembershipEvent, RoomMembershipRevision, StringUserID } from "matrix-protection-suite"; +import { + Membership, + MembershipEvent, + RoomMembershipRevision, + StringUserID, +} from "matrix-protection-suite"; export function isInvitationForUser( - event: MembershipEvent, - clientUserID: StringUserID -): event is MembershipEvent & { content: { membership: Membership.Invite }} { - return event.state_key === clientUserID - && event.content.membership === Membership.Invite -}; + event: MembershipEvent, + clientUserID: StringUserID +): event is MembershipEvent & { content: { membership: Membership.Invite } } { + return ( + event.state_key === clientUserID && + event.content.membership === Membership.Invite + ); +} export function isSenderJoinedInRevision( - senderUserID: StringUserID, - membership: RoomMembershipRevision + senderUserID: StringUserID, + membership: RoomMembershipRevision ): boolean { - const senderMembership = membership.membershipForUser(senderUserID); - return Boolean(senderMembership?.content.membership === Membership.Join); + const senderMembership = membership.membershipForUser(senderUserID); + return Boolean(senderMembership?.content.membership === Membership.Join); } diff --git a/src/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index f3e0dfd0..256186aa 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -24,129 +24,158 @@ limitations under the License. * However, this file is modified and the modifications in this file * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { LogLevel } from "matrix-bot-sdk" +import { LogLevel } from "matrix-bot-sdk"; import { redactUserMessagesIn } from "../utils"; import ManagementRoomOutput from "../ManagementRoomOutput"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionExceptionKind, MatrixRoomReference, RoomUpdateError, RoomUpdateException, StringRoomID, StringUserID } from "matrix-protection-suite"; +import { + ActionExceptionKind, + MatrixRoomReference, + RoomUpdateError, + RoomUpdateException, + StringRoomID, + StringUserID, +} from "matrix-protection-suite"; export interface QueuedRedaction { - /** The room which the redaction will take place in. */ - readonly roomID: StringRoomID; - /** - * Carry out the redaction. - * Called by the EventRedactionQueue. - * @param client A MatrixClient to use to carry out the redaction. - */ - redact(client: MatrixSendClient, managementRoom: ManagementRoomOutput): Promise - /** - * Used to test whether the redaction is the equivalent to another redaction. - * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. - */ - redactionEqual(redaction: QueuedRedaction): boolean + /** The room which the redaction will take place in. */ + readonly roomID: StringRoomID; + /** + * Carry out the redaction. + * Called by the EventRedactionQueue. + * @param client A MatrixClient to use to carry out the redaction. + */ + redact( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput + ): Promise; + /** + * Used to test whether the redaction is the equivalent to another redaction. + * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. + */ + redactionEqual(redaction: QueuedRedaction): boolean; } /** * Redacts all of the messages a user has sent to one room. */ export class RedactUserInRoom implements QueuedRedaction { - constructor( - public readonly userID: StringUserID, - public readonly roomID: StringRoomID, - ) { - } + constructor( + public readonly userID: StringUserID, + public readonly roomID: StringRoomID + ) {} - public async redact(client: MatrixSendClient, managementRoom: ManagementRoomOutput) { - await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userID} in room ${this.roomID}.`); - await redactUserMessagesIn(client, managementRoom, this.userID, [this.roomID]); - } + public async redact( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput + ) { + await managementRoom.logMessage( + LogLevel.DEBUG, + "Mjolnir", + `Redacting events from ${this.userID} in room ${this.roomID}.` + ); + await redactUserMessagesIn(client, managementRoom, this.userID, [ + this.roomID, + ]); + } - public redactionEqual(redaction: QueuedRedaction): boolean { - if (redaction instanceof RedactUserInRoom) { - return redaction.userID === this.userID && redaction.roomID === this.roomID; - } else { - return false; - } + public redactionEqual(redaction: QueuedRedaction): boolean { + if (redaction instanceof RedactUserInRoom) { + return ( + redaction.userID === this.userID && redaction.roomID === this.roomID + ); + } else { + return false; } + } } /** * This is a queue for events so that other protections can happen first (e.g. applying room bans to every room). */ export class EventRedactionQueue { - /** - * This map is indexed by roomId and its values are a list of redactions waiting to be processed for that room. - */ - private toRedact: Map = new Map(); + /** + * This map is indexed by roomId and its values are a list of redactions waiting to be processed for that room. + */ + private toRedact: Map = new Map< + string, + QueuedRedaction[] + >(); - /** - * Test whether the redaction is already present in the queue. - * @param redaction a QueuedRedaction. - * @returns True if the queue already has the redaction, false otherwise. - */ - public has(redaction: QueuedRedaction): boolean { - return !!this.toRedact.get(redaction.roomID)?.find(r => r.redactionEqual(redaction)); - } + /** + * Test whether the redaction is already present in the queue. + * @param redaction a QueuedRedaction. + * @returns True if the queue already has the redaction, false otherwise. + */ + public has(redaction: QueuedRedaction): boolean { + return !!this.toRedact + .get(redaction.roomID) + ?.find((r) => r.redactionEqual(redaction)); + } - /** - * Adds a `QueuedRedaction` to the queue. It will be processed when `process` is called. - * @param redaction A `QueuedRedaction` to await processing - * @returns `true` if the redaction was added to the queue, `false` if it is a duplicate of a redaction already present in the queue. - */ - public add(redaction: QueuedRedaction): boolean { - if (this.has(redaction)) { - return false; - } else { - const entry = this.toRedact.get(redaction.roomID); - if (entry) { - entry.push(redaction); - } else { - this.toRedact.set(redaction.roomID, [redaction]); - } - return true; - } + /** + * Adds a `QueuedRedaction` to the queue. It will be processed when `process` is called. + * @param redaction A `QueuedRedaction` to await processing + * @returns `true` if the redaction was added to the queue, `false` if it is a duplicate of a redaction already present in the queue. + */ + public add(redaction: QueuedRedaction): boolean { + if (this.has(redaction)) { + return false; + } else { + const entry = this.toRedact.get(redaction.roomID); + if (entry) { + entry.push(redaction); + } else { + this.toRedact.set(redaction.roomID, [redaction]); + } + return true; } + } - /** - * Process the redaction queue, carrying out the action of each `QueuedRedaction` in sequence. - * If a redaction cannot be processed, the redaction is skipped and removed from the queue. - * We then carry on processing the next redactions. - * The reason we skip is at the moment is that we would have to think about all of the situations - * where we would not want failures to try again (e.g. messages were already redacted) and handle them explicitly. - * @param client The matrix client to use for processing redactions. - * @param limitToRoomId If the roomId is provided, only redactions for that room will be processed. - * @returns A description of any errors encountered by each QueuedRedaction that was processed. - */ - public async process(client: MatrixSendClient, managementRoom: ManagementRoomOutput, limitToRoomID?: StringRoomID): Promise { - const errors: RoomUpdateError[] = []; - const redact = async (currentBatch: QueuedRedaction[]) => { - for (const redaction of currentBatch) { - try { - await redaction.redact(client, managementRoom); - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - const error = new RoomUpdateException( - MatrixRoomReference.fromRoomID(redaction.roomID), - ActionExceptionKind.Unknown, - e, - message - ); - errors.push(error); - } - } - } - if (limitToRoomID) { - // There might not actually be any queued redactions for this room. - const queuedRedactions = this.toRedact.get(limitToRoomID); - if (queuedRedactions) { - this.toRedact.delete(limitToRoomID); - await redact(queuedRedactions); - } - } else { - for (const [roomId, redactions] of this.toRedact) { - this.toRedact.delete(roomId); - await redact(redactions); - } + /** + * Process the redaction queue, carrying out the action of each `QueuedRedaction` in sequence. + * If a redaction cannot be processed, the redaction is skipped and removed from the queue. + * We then carry on processing the next redactions. + * The reason we skip is at the moment is that we would have to think about all of the situations + * where we would not want failures to try again (e.g. messages were already redacted) and handle them explicitly. + * @param client The matrix client to use for processing redactions. + * @param limitToRoomId If the roomId is provided, only redactions for that room will be processed. + * @returns A description of any errors encountered by each QueuedRedaction that was processed. + */ + public async process( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput, + limitToRoomID?: StringRoomID + ): Promise { + const errors: RoomUpdateError[] = []; + const redact = async (currentBatch: QueuedRedaction[]) => { + for (const redaction of currentBatch) { + try { + await redaction.redact(client, managementRoom); + } catch (e) { + const message = e.message || (e.body ? e.body.error : ""); + const error = new RoomUpdateException( + MatrixRoomReference.fromRoomID(redaction.roomID), + ActionExceptionKind.Unknown, + e, + message + ); + errors.push(error); } - return errors; + } + }; + if (limitToRoomID) { + // There might not actually be any queued redactions for this room. + const queuedRedactions = this.toRedact.get(limitToRoomID); + if (queuedRedactions) { + this.toRedact.delete(limitToRoomID); + await redact(queuedRedactions); + } + } else { + for (const [roomId, redactions] of this.toRedact) { + this.toRedact.delete(roomId); + await redact(redactions); + } } + return errors; + } } diff --git a/src/queues/ProtectedRoomActivityTracker.ts b/src/queues/ProtectedRoomActivityTracker.ts index d4d85bd3..0828b3b5 100644 --- a/src/queues/ProtectedRoomActivityTracker.ts +++ b/src/queues/ProtectedRoomActivityTracker.ts @@ -36,55 +36,61 @@ import { RoomEvent, StringRoomID } from "matrix-protection-suite"; * */ export class ProtectedRoomActivityTracker { - private protectedRoomActivities = new Map(); - /** - * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first. - */ - private activeRoomsCache: null|string[] = null + private protectedRoomActivities = new Map< + string /*room id*/, + number /*last event timestamp*/ + >(); + /** + * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first. + */ + private activeRoomsCache: null | string[] = null; - /** - * Inform the tracker that a new room is being protected by Mjolnir. - * @param roomId The room Mjolnir is now protecting. - */ - public addProtectedRoom(roomId: string): void { - this.protectedRoomActivities.set(roomId, /* epoch */ 0); - this.activeRoomsCache = null; - } + /** + * Inform the tracker that a new room is being protected by Mjolnir. + * @param roomId The room Mjolnir is now protecting. + */ + public addProtectedRoom(roomId: string): void { + this.protectedRoomActivities.set(roomId, /* epoch */ 0); + this.activeRoomsCache = null; + } - /** - * Inform the trakcer that a room is no longer being protected by Mjolnir. - * @param roomId The roomId that is no longer being protected by Mjolnir. - */ - public removeProtectedRoom(roomId: string): void { - this.protectedRoomActivities.delete(roomId); - this.activeRoomsCache = null; - } + /** + * Inform the trakcer that a room is no longer being protected by Mjolnir. + * @param roomId The roomId that is no longer being protected by Mjolnir. + */ + public removeProtectedRoom(roomId: string): void { + this.protectedRoomActivities.delete(roomId); + this.activeRoomsCache = null; + } - /** - * Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated - * @param roomId The room the new event is in. - * @param event The new event. - * - */ - public handleEvent(roomID: StringRoomID, event: RoomEvent): void { - const last_origin_server_ts = this.protectedRoomActivities.get(roomID); - if (last_origin_server_ts !== undefined && Number.isInteger(event.origin_server_ts)) { - if (event.origin_server_ts > last_origin_server_ts) { - this.activeRoomsCache = null; - this.protectedRoomActivities.set(roomID, event.origin_server_ts); - } - } + /** + * Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated + * @param roomId The room the new event is in. + * @param event The new event. + * + */ + public handleEvent(roomID: StringRoomID, event: RoomEvent): void { + const last_origin_server_ts = this.protectedRoomActivities.get(roomID); + if ( + last_origin_server_ts !== undefined && + Number.isInteger(event.origin_server_ts) + ) { + if (event.origin_server_ts > last_origin_server_ts) { + this.activeRoomsCache = null; + this.protectedRoomActivities.set(roomID, event.origin_server_ts); + } } + } - /** - * @returns A list of protected rooms ids ordered by activity. - */ - public protectedRoomsByActivity(): string[] { - if (!this.activeRoomsCache) { - this.activeRoomsCache = [...this.protectedRoomActivities] - .sort((a, b) => b[1] - a[1]) - .map(pair => pair[0]); - } - return this.activeRoomsCache; + /** + * @returns A list of protected rooms ids ordered by activity. + */ + public protectedRoomsByActivity(): string[] { + if (!this.activeRoomsCache) { + this.activeRoomsCache = [...this.protectedRoomActivities] + .sort((a, b) => b[1] - a[1]) + .map((pair) => pair[0]); } + return this.activeRoomsCache; + } } diff --git a/src/queues/ThrottlingQueue.ts b/src/queues/ThrottlingQueue.ts index a52c4451..74d3901c 100644 --- a/src/queues/ThrottlingQueue.ts +++ b/src/queues/ThrottlingQueue.ts @@ -34,179 +34,192 @@ export type Task = (queue: ThrottlingQueue) => Promise; * A queue for backgrounding tasks without hammering servers too much. */ export class ThrottlingQueue { - /** - * The pending tasks. - */ - private _tasks: (() => Promise)[] | null; - - /** - * A timeout for the next task to execute. - */ - private timeout: ReturnType | null; - - /** - * How long we should wait between the completion of a tasks and the start of the next task. - * Any >=0 number is good. - */ - private _delayMS: number; - - /** - * Construct an empty queue. - * - * This queue will start executing whenever `push()` is called and stop - * whenever it runs out of tasks to execute. - * - * @param delayMS The default delay between executing two tasks, in ms. - */ - constructor(private managementRoomOutput: ManagementRoomOutput, delayMS: number) { - this.timeout = null; - this.delayMS = delayMS; - this._tasks = []; - } - - /** - * Stop the queue, make sure we can never use it again. - */ - public dispose() { - this.stop(); - this._tasks = null; - } - - /** - * The number of tasks waiting to be executed. - */ - get length(): number { - return this.tasks.length; - } - - /** - * Push a new task onto the queue. - * - * @param task Some code to execute. - * @return A promise resolved/rejected once `task` is complete. - */ - public push(task: Task): Promise { - // Wrap `task` into a `Promise` to inform enqueuer when - // the task is complete. - return new Promise((resolve, reject) => { - const wrapper = async () => { - try { - const result: T = await task(this); - resolve(result); - } catch (ex) { - if (ex instanceof Error) { - reject(ex); - } - throw new TypeError(`Some garbage is throwing things that are not Error ${ex}`); - }; - }; - this.tasks.push(wrapper); - this.start(); - }); - } - - /** - * Block a queue for a number of milliseconds. - * - * This method is meant typically to be used by a `Task` that receives a 429 (Too Many Requests) to reschedule - * itself for later, after giving the server a little room to breathe. If you need this, do not forget to - * re-`push()` with the failing `Task`. You may call `block()` and `push()` in either order. - * - * @param durationMS A number of milliseconds to wait until resuming operations. - */ - public block(durationMS: number) { - this.stop(); - this.timeout = setTimeout(() => { void this.step() }, durationMS); - } - - /** - * Start the loop to execute pending tasks. - * - * Does nothing if the loop is already started. - */ - private start() { - if (this.timeout) { - // Already started. - return; - } - if (!this.tasks.length) { - // Nothing to do. - return; + /** + * The pending tasks. + */ + private _tasks: (() => Promise)[] | null; + + /** + * A timeout for the next task to execute. + */ + private timeout: ReturnType | null; + + /** + * How long we should wait between the completion of a tasks and the start of the next task. + * Any >=0 number is good. + */ + private _delayMS: number; + + /** + * Construct an empty queue. + * + * This queue will start executing whenever `push()` is called and stop + * whenever it runs out of tasks to execute. + * + * @param delayMS The default delay between executing two tasks, in ms. + */ + constructor( + private managementRoomOutput: ManagementRoomOutput, + delayMS: number + ) { + this.timeout = null; + this.delayMS = delayMS; + this._tasks = []; + } + + /** + * Stop the queue, make sure we can never use it again. + */ + public dispose() { + this.stop(); + this._tasks = null; + } + + /** + * The number of tasks waiting to be executed. + */ + get length(): number { + return this.tasks.length; + } + + /** + * Push a new task onto the queue. + * + * @param task Some code to execute. + * @return A promise resolved/rejected once `task` is complete. + */ + public push(task: Task): Promise { + // Wrap `task` into a `Promise` to inform enqueuer when + // the task is complete. + return new Promise((resolve, reject) => { + const wrapper = async () => { + try { + const result: T = await task(this); + resolve(result); + } catch (ex) { + if (ex instanceof Error) { + reject(ex); + } + throw new TypeError( + `Some garbage is throwing things that are not Error ${ex}` + ); } - this.timeout = setTimeout(() => { void this.step() }, this._delayMS); + }; + this.tasks.push(wrapper); + this.start(); + }); + } + + /** + * Block a queue for a number of milliseconds. + * + * This method is meant typically to be used by a `Task` that receives a 429 (Too Many Requests) to reschedule + * itself for later, after giving the server a little room to breathe. If you need this, do not forget to + * re-`push()` with the failing `Task`. You may call `block()` and `push()` in either order. + * + * @param durationMS A number of milliseconds to wait until resuming operations. + */ + public block(durationMS: number) { + this.stop(); + this.timeout = setTimeout(() => { + void this.step(); + }, durationMS); + } + + /** + * Start the loop to execute pending tasks. + * + * Does nothing if the loop is already started. + */ + private start() { + if (this.timeout) { + // Already started. + return; } - - /** - * Stop the loop to execute pending tasks. - * - * Does nothing if the loop is already stopped. A loop stopped with `stop()` may be - * resumed by calling `push()` or `start()`. - */ - private stop() { - if (!this.timeout) { - // Already stopped. - return; - } - clearTimeout(this.timeout); - this.timeout = null; + if (!this.tasks.length) { + // Nothing to do. + return; } - - /** - * Change the delay between completion of an event and the start of the next event. - * - * This will be used next time a task is completed. - */ - set delayMS(delayMS: number) { - if (delayMS < 0) { - throw new TypeError(`Invalid delay ${delayMS}. Need a non-negative number of ms.`); - } - this._delayMS = delayMS; + this.timeout = setTimeout(() => { + void this.step(); + }, this._delayMS); + } + + /** + * Stop the loop to execute pending tasks. + * + * Does nothing if the loop is already stopped. A loop stopped with `stop()` may be + * resumed by calling `push()` or `start()`. + */ + private stop() { + if (!this.timeout) { + // Already stopped. + return; } - - /** - * Return the delay between completion of an event and the start of the next event. - */ - get delayMS(): number { - return this._delayMS; + clearTimeout(this.timeout); + this.timeout = null; + } + + /** + * Change the delay between completion of an event and the start of the next event. + * + * This will be used next time a task is completed. + */ + set delayMS(delayMS: number) { + if (delayMS < 0) { + throw new TypeError( + `Invalid delay ${delayMS}. Need a non-negative number of ms.` + ); } - - /** - * Execute one step of the loop, then prepare the following step. - * - * 1. If there is no task, do nothing and stop. - * 2. Otherwise, execute task. - * 3. Once task is complete (whether succeeded or failed), retrigger the loop. - */ - private async step() { - // Pull task. - const task = this.tasks.shift(); - if (!task) { - // Nothing to do. - // Stop the loop until we have something to do. - this.stop(); - return; - } - try { - await task(); - } catch (ex) { - await this.managementRoomOutput.logMessage( - LogLevel.WARN, - 'Error while executing task', - ex - ); - } finally { - this.stop(); - this.start(); - } + this._delayMS = delayMS; + } + + /** + * Return the delay between completion of an event and the start of the next event. + */ + get delayMS(): number { + return this._delayMS; + } + + /** + * Execute one step of the loop, then prepare the following step. + * + * 1. If there is no task, do nothing and stop. + * 2. Otherwise, execute task. + * 3. Once task is complete (whether succeeded or failed), retrigger the loop. + */ + private async step() { + // Pull task. + const task = this.tasks.shift(); + if (!task) { + // Nothing to do. + // Stop the loop until we have something to do. + this.stop(); + return; } - - /** - * Return `tasks`, unless the queue has been disposed of. - */ - private get tasks(): (() => Promise)[] { - if (this._tasks === null) { - throw new TypeError("This Throttling Queue has been disposed of and shouldn't be used anymore"); - } - return this._tasks; + try { + await task(); + } catch (ex) { + await this.managementRoomOutput.logMessage( + LogLevel.WARN, + "Error while executing task", + ex + ); + } finally { + this.stop(); + this.start(); + } + } + + /** + * Return `tasks`, unless the queue has been disposed of. + */ + private get tasks(): (() => Promise)[] { + if (this._tasks === null) { + throw new TypeError( + "This Throttling Queue has been disposed of and shouldn't be used anymore" + ); } + return this._tasks; + } } diff --git a/src/queues/UnlistedUserRedactionQueue.ts b/src/queues/UnlistedUserRedactionQueue.ts index 66dce4cb..c73da147 100644 --- a/src/queues/UnlistedUserRedactionQueue.ts +++ b/src/queues/UnlistedUserRedactionQueue.ts @@ -25,7 +25,12 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ import { LogLevel, LogService } from "matrix-bot-sdk"; -import { Permalinks, RoomEvent, StringRoomID, StringUserID } from "matrix-protection-suite"; +import { + Permalinks, + RoomEvent, + StringRoomID, + StringUserID, +} from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; /** @@ -36,34 +41,49 @@ import { Draupnir } from "../Draupnir"; * to view a room until a moderator can investigate. */ export class UnlistedUserRedactionQueue { - private usersToRedact = new Set(); + private usersToRedact = new Set(); - public addUser(userID: StringUserID) { - this.usersToRedact.add(userID); - } + public addUser(userID: StringUserID) { + this.usersToRedact.add(userID); + } - public removeUser(userID: StringUserID) { - this.usersToRedact.delete(userID); - } + public removeUser(userID: StringUserID) { + this.usersToRedact.delete(userID); + } - public isUserQueued(userID: StringUserID): boolean { - return this.usersToRedact.has(userID); - } + public isUserQueued(userID: StringUserID): boolean { + return this.usersToRedact.has(userID); + } - public async handleEvent(roomID: StringRoomID, event: RoomEvent, draupnir: Draupnir) { - if (this.isUserQueued(event['sender'])) { - const permalink = Permalinks.forEvent(roomID, event['event_id']); - try { - LogService.info("AutomaticRedactionQueue", `Redacting event because the user is listed as bad: ${permalink}`) - if (!draupnir.config.noop) { - await draupnir.client.redactEvent(roomID, event['event_id']); - } else { - await draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); - } - } catch (e) { - await draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); - LogService.warn("AutomaticRedactionQueue", e); - } + public async handleEvent( + roomID: StringRoomID, + event: RoomEvent, + draupnir: Draupnir + ) { + if (this.isUserQueued(event["sender"])) { + const permalink = Permalinks.forEvent(roomID, event["event_id"]); + try { + LogService.info( + "AutomaticRedactionQueue", + `Redacting event because the user is listed as bad: ${permalink}` + ); + if (!draupnir.config.noop) { + await draupnir.client.redactEvent(roomID, event["event_id"]); + } else { + await draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "AutomaticRedactionQueue", + `Tried to redact ${permalink} but Mjolnir is running in no-op mode` + ); } + } catch (e) { + await draupnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "AutomaticRedactionQueue", + `Unable to redact message: ${permalink}` + ); + LogService.warn("AutomaticRedactionQueue", e); + } } + } } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index bd7ae5c2..400a1391 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -29,9 +29,22 @@ import { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction"; import { LogService, UserID } from "matrix-bot-sdk"; import { htmlToText } from "html-to-text"; import { htmlEscape } from "../utils"; -import { JSDOM } from 'jsdom'; +import { JSDOM } from "jsdom"; import { Draupnir } from "../Draupnir"; -import { MatrixRoomReference, ReactionContent, RoomEvent, RoomMessage, StringEventID, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, serverName } from "matrix-protection-suite"; +import { + MatrixRoomReference, + ReactionContent, + RoomEvent, + RoomMessage, + StringEventID, + StringRoomID, + StringUserID, + Task, + TextMessageContent, + Value, + isError, + serverName, +} from "matrix-protection-suite"; /// Regexp, used to extract the action label from an action reaction /// such as `⚽ Kick user @foobar:localhost from room [kick-user]`. @@ -52,135 +65,183 @@ export const ABUSE_REPORT_KEY = "org.matrix.mjolnir.abuse.report"; /// Custom field embedded as part of confirmation reactions to embed abuse /// reports (see `IReportWithAction` for the content). -export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.abuse.action.confirmation"; +export const ABUSE_ACTION_CONFIRMATION_KEY = + "org.matrix.mjolnir.abuse.action.confirmation"; const NATURE_DESCRIPTIONS_LIST: [string, string][] = [ - ["org.matrix.msc3215.abuse.nature.disagreement", "disagreement"], - ["org.matrix.msc3215.abuse.nature.harassment", "harassment/bullying"], - ["org.matrix.msc3215.abuse.nature.csam", "child sexual abuse material [likely illegal, consider warning authorities]"], - ["org.matrix.msc3215.abuse.nature.hate_speech", "spam"], - ["org.matrix.msc3215.abuse.nature.spam", "impersonation"], - ["org.matrix.msc3215.abuse.nature.impersonation", "impersonation"], - ["org.matrix.msc3215.abuse.nature.doxxing", "non-consensual sharing of identifiable private information of a third party (doxxing)"], - ["org.matrix.msc3215.abuse.nature.violence", "threats of violence or death, either to self or others"], - ["org.matrix.msc3215.abuse.nature.terrorism", "terrorism [likely illegal, consider warning authorities]"], - ["org.matrix.msc3215.abuse.nature.unwanted_sexual_advances", "unwanted sexual advances, sextortion, ... [possibly illegal, consider warning authorities]"], - ["org.matrix.msc3215.abuse.nature.ncii", "non consensual intimate imagery, including revenge porn"], - ["org.matrix.msc3215.abuse.nature.nsfw", "NSFW content (pornography, gore...) in a SFW room"], - ["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"], + ["org.matrix.msc3215.abuse.nature.disagreement", "disagreement"], + ["org.matrix.msc3215.abuse.nature.harassment", "harassment/bullying"], + [ + "org.matrix.msc3215.abuse.nature.csam", + "child sexual abuse material [likely illegal, consider warning authorities]", + ], + ["org.matrix.msc3215.abuse.nature.hate_speech", "spam"], + ["org.matrix.msc3215.abuse.nature.spam", "impersonation"], + ["org.matrix.msc3215.abuse.nature.impersonation", "impersonation"], + [ + "org.matrix.msc3215.abuse.nature.doxxing", + "non-consensual sharing of identifiable private information of a third party (doxxing)", + ], + [ + "org.matrix.msc3215.abuse.nature.violence", + "threats of violence or death, either to self or others", + ], + [ + "org.matrix.msc3215.abuse.nature.terrorism", + "terrorism [likely illegal, consider warning authorities]", + ], + [ + "org.matrix.msc3215.abuse.nature.unwanted_sexual_advances", + "unwanted sexual advances, sextortion, ... [possibly illegal, consider warning authorities]", + ], + [ + "org.matrix.msc3215.abuse.nature.ncii", + "non consensual intimate imagery, including revenge porn", + ], + [ + "org.matrix.msc3215.abuse.nature.nsfw", + "NSFW content (pornography, gore...) in a SFW room", + ], + ["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"], ]; const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST); enum Kind { - //! A MSC3215-style moderation request - MODERATION_REQUEST, - //! An abuse report, as per https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-rooms-roomid-report-eventid - SERVER_ABUSE_REPORT, - //! Mjölnir encountered a problem while attempting to handle a moderation request or abuse report - ERROR, - //! A moderation request or server abuse report escalated by the server/room moderators. - ESCALATED_REPORT, + //! A MSC3215-style moderation request + MODERATION_REQUEST, + //! An abuse report, as per https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-rooms-roomid-report-eventid + SERVER_ABUSE_REPORT, + //! Mjölnir encountered a problem while attempting to handle a moderation request or abuse report + ERROR, + //! A moderation request or server abuse report escalated by the server/room moderators. + ESCALATED_REPORT, } /** * A class designed to respond to abuse reports. */ export class ReportManager { - private displayManager: DisplayManager; - constructor(public draupnir: Draupnir) { - this.displayManager = new DisplayManager(this); + private displayManager: DisplayManager; + constructor(public draupnir: Draupnir) { + this.displayManager = new DisplayManager(this); + } + + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + if ( + roomID === this.draupnir.managementRoomID && + event.type === "m.reaction" + ) { + void Task(this.handleReaction({ roomID, event })); } - - public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { - if (roomID === this.draupnir.managementRoomID && event.type === 'm.reaction') { - void Task(this.handleReaction({ roomID, event })); - } + } + + /** + * Display an incoming abuse report received, e.g. from the /report Matrix API. + * + * # Pre-requisites + * + * The following MUST hold true: + * - the reporter's id is `reporterId`; + * - the reporter is a member of `roomID`; + * - `event` did take place in room `roomID`; + * - the reporter could witness event `event` in room `roomID`; + * - the event being reported is `event`; + * + * @param roomID The room in which the abuse took place. + * @param reporterId The user who reported the event. + * @param event The event being reported. + * @param reason A reason provided by the reporter. + */ + public async handleServerAbuseReport({ + roomID, + reporterId, + event, + reason, + }: { + roomID: StringRoomID; + reporterId: string; + event: RoomEvent; + reason?: string; + }) { + this.draupnir.handleEventReport({ + event_id: event.event_id, + room_id: roomID, + sender: reporterId as StringUserID, + event: event, + ...(reason === undefined ? {} : { reason }), + }); + if (this.draupnir.config.displayReports) { + return this.displayManager.displayReportAndUI({ + kind: Kind.SERVER_ABUSE_REPORT, + event, + reporterId, + moderationroomID: this.draupnir.managementRoomID, + ...(reason === undefined ? {} : { reason }), + }); } - - /** - * Display an incoming abuse report received, e.g. from the /report Matrix API. - * - * # Pre-requisites - * - * The following MUST hold true: - * - the reporter's id is `reporterId`; - * - the reporter is a member of `roomID`; - * - `event` did take place in room `roomID`; - * - the reporter could witness event `event` in room `roomID`; - * - the event being reported is `event`; - * - * @param roomID The room in which the abuse took place. - * @param reporterId The user who reported the event. - * @param event The event being reported. - * @param reason A reason provided by the reporter. - */ - public async handleServerAbuseReport({ roomID, reporterId, event, reason }: { roomID: StringRoomID, reporterId: string, event: RoomEvent, reason?: string }) { - this.draupnir.handleEventReport({ - event_id: event.event_id, - room_id: roomID, - sender: reporterId as StringUserID, - event: event, - ...reason === undefined ? {} : { reason } - }) - if (this.draupnir.config.displayReports) { - return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, moderationroomID: this.draupnir.managementRoomID, ...reason === undefined ? {} : { reason } }); - } + } + + /** + * Handle a reaction to an abuse report. + * + * @param roomID The room in which the reaction took place. + * @param event The reaction. + */ + public async handleReaction({ + roomID, + event, + }: { + roomID: StringRoomID; + event: RoomEvent; + }) { + if (event.sender === this.draupnir.clientUserID) { + // Let's not react to our own reactions. + return; } - /** - * Handle a reaction to an abuse report. - * - * @param roomID The room in which the reaction took place. - * @param event The reaction. - */ - public async handleReaction({ roomID, event }: { roomID: StringRoomID, event: RoomEvent }) { - if (event.sender === this.draupnir.clientUserID) { - // Let's not react to our own reactions. - return; - } - - if (roomID !== this.draupnir.managementRoomID) { - // Let's not accept commands in rooms other than the management room. - return; - } - const reactionContent = Value.Decode( - ReactionContent, - event.content - ); - if (isError(reactionContent)) { - return; - } - const relation = reactionContent.ok["m.relates_to"]; - if (relation === undefined) { - return; - } + if (roomID !== this.draupnir.managementRoomID) { + // Let's not accept commands in rooms other than the management room. + return; + } + const reactionContent = Value.Decode(ReactionContent, event.content); + if (isError(reactionContent)) { + return; + } + const relation = reactionContent.ok["m.relates_to"]; + if (relation === undefined) { + return; + } - // Get the original event. - let initialNoticeReport: IReport | undefined, confirmationReport: IReportWithAction | undefined; - try { - const originalEvent = await this.draupnir.client.getEvent(roomID, relation.event_id); - if (originalEvent.sender !== this.draupnir.clientUserID) { - // Let's not handle reactions to events we didn't send as - // some setups have two or more Mjolnir's in the same management room. - return; - } - if (!("content" in originalEvent)) { - return; - } - const content = originalEvent["content"]; - if (ABUSE_REPORT_KEY in content) { - initialNoticeReport = content[ABUSE_REPORT_KEY]; - } else if (ABUSE_ACTION_CONFIRMATION_KEY in content) { - confirmationReport = content[ABUSE_ACTION_CONFIRMATION_KEY]; - } - } catch (ex) { - return; - } - if (!initialNoticeReport && !confirmationReport) { - return; - } + // Get the original event. + let initialNoticeReport: IReport | undefined, + confirmationReport: IReportWithAction | undefined; + try { + const originalEvent = await this.draupnir.client.getEvent( + roomID, + relation.event_id + ); + if (originalEvent.sender !== this.draupnir.clientUserID) { + // Let's not handle reactions to events we didn't send as + // some setups have two or more Mjolnir's in the same management room. + return; + } + if (!("content" in originalEvent)) { + return; + } + const content = originalEvent["content"]; + if (ABUSE_REPORT_KEY in content) { + initialNoticeReport = content[ABUSE_REPORT_KEY]; + } else if (ABUSE_ACTION_CONFIRMATION_KEY in content) { + confirmationReport = content[ABUSE_ACTION_CONFIRMATION_KEY]; + } + } catch (ex) { + return; + } + if (!initialNoticeReport && !confirmationReport) { + return; + } - /* + /* At this point, we know that: - We're in the management room; @@ -189,196 +250,265 @@ export class ReportManager { - `confirmationReport` is defined and we're reacting to a confirmation request. */ - if (confirmationReport) { - // Extract the action and the decision. - const matches = relation.key?.match(REACTION_CONFIRMATION); - if (!matches) { - // Invalid key. - return; - } - - // Is it a yes or a no? - let decision; - switch (matches[2]) { - case CONFIRM: - decision = true; - break; - case CANCEL: - decision = false; - break; - default: - LogService.debug("ReportManager::handleReaction", "Unknown decision", matches[2]); - return; - } - const label = matches[1]; - if (label === undefined) { - LogService.error("ReportManager::handleReaction", "Unable to find the label for an event", event); - return; - } - if (decision) { - LogService.info("ReportManager::handleReaction", "User", event["sender"], "confirmed action", matches[1]); - await this.executeAction({ - label, - report: confirmationReport, - successEventId: confirmationReport.notification_event_id as StringEventID, - failureEventId: relation.event_id, - onSuccessRemoveEventId: relation.event_id, - moderationRoomId: roomID, - }) - } else { - LogService.info("ReportManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]); - await this.draupnir.client.redactEvent(this.draupnir.managementRoomID, relation.event_id, "Action cancelled"); - } - - return; - } else if (initialNoticeReport) { - const matches = relation.key?.match(REACTION_ACTION); - if (!matches) { - // Invalid key. - return; - } - - const label = matches[1]; - if (label === undefined) { - return; - } - const action: IUIAction | undefined = ACTIONS.get(label); - if (!action) { - return; - } - confirmationReport = { - action: label, - notification_event_id: relation.event_id, - ...initialNoticeReport - }; - LogService.info("ReportManager::handleReaction", "User", event["sender"], "picked action", label, initialNoticeReport); - if (action.needsConfirmation) { - // Send a confirmation request. - const confirmation = { - msgtype: "m.notice", - body: `${action.emoji} ${await action.title(this, initialNoticeReport)}?`, - "m.relationship": { - "rel_type": "m.reference", - "event_id": relation.event_id, - }, - [ABUSE_ACTION_CONFIRMATION_KEY]: confirmationReport - }; - - const requestConfirmationEventId = await this.draupnir.client.sendMessage(this.draupnir.managementRoomID, confirmation); - await this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": requestConfirmationEventId, - "key": `🆗 ${action.emoji} ${await action.title(this, initialNoticeReport)} [${action.label}][${CONFIRM}]` - } - }); - await this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": requestConfirmationEventId, - "key": `⬛ Cancel [${action.label}][${CANCEL}]` - } - }); - // FIXME: We've clobbered the roomID parts on all of these events. - } else { - // Execute immediately. - LogService.info("ReportManager::handleReaction", "User", event["sender"], "executed (no confirmation needed) action", matches[1]); - void this.executeAction({ - label, - report: confirmationReport, - successEventId: relation.event_id, - failureEventId: relation.event_id, - moderationRoomId: roomID - }) - } - } - } + if (confirmationReport) { + // Extract the action and the decision. + const matches = relation.key?.match(REACTION_CONFIRMATION); + if (!matches) { + // Invalid key. + return; + } + + // Is it a yes or a no? + let decision; + switch (matches[2]) { + case CONFIRM: + decision = true; + break; + case CANCEL: + decision = false; + break; + default: + LogService.debug( + "ReportManager::handleReaction", + "Unknown decision", + matches[2] + ); + return; + } + const label = matches[1]; + if (label === undefined) { + LogService.error( + "ReportManager::handleReaction", + "Unable to find the label for an event", + event + ); + return; + } + if (decision) { + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "confirmed action", + matches[1] + ); + await this.executeAction({ + label, + report: confirmationReport, + successEventId: + confirmationReport.notification_event_id as StringEventID, + failureEventId: relation.event_id, + onSuccessRemoveEventId: relation.event_id, + moderationRoomId: roomID, + }); + } else { + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "cancelled action", + matches[1] + ); + await this.draupnir.client.redactEvent( + this.draupnir.managementRoomID, + relation.event_id, + "Action cancelled" + ); + } + return; + } else if (initialNoticeReport) { + const matches = relation.key?.match(REACTION_ACTION); + if (!matches) { + // Invalid key. + return; + } - /** - * Execute a report-specific action. - * - * This is executed when the user clicks on an action to execute (if the action - * does not need confirmation) or when the user clicks on "confirm" in a confirmation - * (otherwise). - * - * @param label The type of action to execute, e.g. `kick-user`. - * @param report The abuse report on which to take action. - * @param successEventId The event to annotate with a "OK" in case of success. - * @param failureEventId The event to annotate with a "FAIL" in case of failure. - * @param onSuccessRemoveEventId Optionally, an event to remove in case of success (e.g. the confirmation dialog). - */ - private async executeAction({ + const label = matches[1]; + if (label === undefined) { + return; + } + const action: IUIAction | undefined = ACTIONS.get(label); + if (!action) { + return; + } + confirmationReport = { + action: label, + notification_event_id: relation.event_id, + ...initialNoticeReport, + }; + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "picked action", label, - report, - successEventId, - failureEventId, - onSuccessRemoveEventId, - moderationRoomId - }: { - label: string, - report: IReportWithAction, - successEventId: StringEventID, - failureEventId: StringEventID, - onSuccessRemoveEventId?: StringEventID, - moderationRoomId: StringRoomID - }) { - const action: IUIAction | undefined = ACTIONS.get(label); - if (!action) { - return; - } - try { - // Check security. - if (moderationRoomId === this.draupnir.managementRoomID) { - // Always accept actions executed from the management room. - } else { - throw new Error("Security error: Cannot execute this action."); - } - const response = await action.execute(this, report, moderationRoomId, this.displayManager); - await this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": successEventId, - "key": `${action.emoji} ✅` - } - }); - if (onSuccessRemoveEventId) { - await this.draupnir.client.redactEvent(this.draupnir.managementRoomID, onSuccessRemoveEventId, "Action complete"); - } - if (response) { - await this.draupnir.client.sendMessage(this.draupnir.managementRoomID, { - msgtype: "m.notice", - "formatted_body": response, - format: "org.matrix.custom.html", - "body": htmlToText(response), - "m.relationship": { - "rel_type": "m.reference", - "event_id": successEventId - } - }) - } - } catch (ex) { - if (ex instanceof Error) { - await this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": failureEventId, - "key": `${action.emoji} ❌` - } - }); - await this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.notice", { - "body": ex.message || "", - "m.relationship": { - "rel_type": "m.reference", - "event_id": failureEventId, - } - }) - } else { - throw new TypeError(`Something is throwing absoloute garbage ${ex}`); - } + initialNoticeReport + ); + if (action.needsConfirmation) { + // Send a confirmation request. + const confirmation = { + msgtype: "m.notice", + body: `${action.emoji} ${await action.title(this, initialNoticeReport)}?`, + "m.relationship": { + rel_type: "m.reference", + event_id: relation.event_id, + }, + [ABUSE_ACTION_CONFIRMATION_KEY]: confirmationReport, + }; + const requestConfirmationEventId = + await this.draupnir.client.sendMessage( + this.draupnir.managementRoomID, + confirmation + ); + await this.draupnir.client.sendEvent( + this.draupnir.managementRoomID, + "m.reaction", + { + "m.relates_to": { + rel_type: "m.annotation", + event_id: requestConfirmationEventId, + key: `🆗 ${action.emoji} ${await action.title(this, initialNoticeReport)} [${action.label}][${CONFIRM}]`, + }, + } + ); + await this.draupnir.client.sendEvent( + this.draupnir.managementRoomID, + "m.reaction", + { + "m.relates_to": { + rel_type: "m.annotation", + event_id: requestConfirmationEventId, + key: `⬛ Cancel [${action.label}][${CANCEL}]`, + }, + } + ); + // FIXME: We've clobbered the roomID parts on all of these events. + } else { + // Execute immediately. + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "executed (no confirmation needed) action", + matches[1] + ); + void this.executeAction({ + label, + report: confirmationReport, + successEventId: relation.event_id, + failureEventId: relation.event_id, + moderationRoomId: roomID, + }); + } + } + } + + /** + * Execute a report-specific action. + * + * This is executed when the user clicks on an action to execute (if the action + * does not need confirmation) or when the user clicks on "confirm" in a confirmation + * (otherwise). + * + * @param label The type of action to execute, e.g. `kick-user`. + * @param report The abuse report on which to take action. + * @param successEventId The event to annotate with a "OK" in case of success. + * @param failureEventId The event to annotate with a "FAIL" in case of failure. + * @param onSuccessRemoveEventId Optionally, an event to remove in case of success (e.g. the confirmation dialog). + */ + private async executeAction({ + label, + report, + successEventId, + failureEventId, + onSuccessRemoveEventId, + moderationRoomId, + }: { + label: string; + report: IReportWithAction; + successEventId: StringEventID; + failureEventId: StringEventID; + onSuccessRemoveEventId?: StringEventID; + moderationRoomId: StringRoomID; + }) { + const action: IUIAction | undefined = ACTIONS.get(label); + if (!action) { + return; + } + try { + // Check security. + if (moderationRoomId === this.draupnir.managementRoomID) { + // Always accept actions executed from the management room. + } else { + throw new Error("Security error: Cannot execute this action."); + } + const response = await action.execute( + this, + report, + moderationRoomId, + this.displayManager + ); + await this.draupnir.client.sendEvent( + this.draupnir.managementRoomID, + "m.reaction", + { + "m.relates_to": { + rel_type: "m.annotation", + event_id: successEventId, + key: `${action.emoji} ✅`, + }, } + ); + if (onSuccessRemoveEventId) { + await this.draupnir.client.redactEvent( + this.draupnir.managementRoomID, + onSuccessRemoveEventId, + "Action complete" + ); + } + if (response) { + await this.draupnir.client.sendMessage(this.draupnir.managementRoomID, { + msgtype: "m.notice", + formatted_body: response, + format: "org.matrix.custom.html", + body: htmlToText(response), + "m.relationship": { + rel_type: "m.reference", + event_id: successEventId, + }, + }); + } + } catch (ex) { + if (ex instanceof Error) { + await this.draupnir.client.sendEvent( + this.draupnir.managementRoomID, + "m.reaction", + { + "m.relates_to": { + rel_type: "m.annotation", + event_id: failureEventId, + key: `${action.emoji} ❌`, + }, + } + ); + await this.draupnir.client.sendEvent( + this.draupnir.managementRoomID, + "m.notice", + { + body: ex.message || "", + "m.relationship": { + rel_type: "m.reference", + event_id: failureEventId, + }, + } + ); + } else { + throw new TypeError(`Something is throwing absoloute garbage ${ex}`); + } } + } } /** @@ -388,26 +518,26 @@ export class ReportManager { * so we're using Matrix naming conventions rather than JS/TS naming conventions. */ export interface IReport { - /** - * The user who sent the abuse report. - */ - readonly accused_id: string, - - /** - * The user who sent the message reported as abuse. - */ - readonly reporter_id: string, - - /** - * The room in which `eventId` took place. - */ - readonly room_id: string, - readonly room_alias_or_id: string, - - /** - * The event reported as abuse. - */ - readonly event_id: string, + /** + * The user who sent the abuse report. + */ + readonly accused_id: string; + + /** + * The user who sent the message reported as abuse. + */ + readonly reporter_id: string; + + /** + * The room in which `eventId` took place. + */ + readonly room_id: string; + readonly room_alias_or_id: string; + + /** + * The event reported as abuse. + */ + readonly event_id: string; } /** @@ -415,390 +545,554 @@ export interface IReport { * * Note: These reports end up embedded in Matrix messages, behind key `ABUSE_ACTION_CONFIRMATION_KEY`, * so we're using Matrix naming conventions rather than JS/TS naming conventions. -*/ + */ interface IReportWithAction extends IReport { - /** - * The label of the action we're confirming, e.g. `kick-user`. - */ - readonly action: string, - - /** - * The event in which we originally notified of the abuse. - */ - readonly notification_event_id: string, + /** + * The label of the action we're confirming, e.g. `kick-user`. + */ + readonly action: string; + + /** + * The event in which we originally notified of the abuse. + */ + readonly notification_event_id: string; } /** * A user action displayed in the UI as a Matrix reaction. */ interface IUIAction { - /** - * A unique label. - * - * Used by Mjölnir to differentiate the actions, e.g. `kick-user`. - */ - readonly label: string; - - /** - * A unique Emoji. - * - * Used to help users avoid making errors when clicking on a button. - */ - readonly emoji: string; - - /** - * If `true`, this is an action that needs confirmation. Otherwise, the - * action may be executed immediately. - */ - readonly needsConfirmation: boolean; - - /** - * Detect whether the action may be executed, e.g. whether Mjölnir has - * sufficient powerlevel to execute this action. - * - * **Security caveat** This assumes that the security policy on whether - * the operation can be executed is: - * - * > *Anyone* in the moderation room and who isn't muted can execute - * > an operation iff Mjölnir has the rights to execute it. - * - * @param report Details on the abuse report. - */ - canExecute(manager: ReportManager, report: IReport, moderationroomID: string): Promise; - - /** - * A human-readable title to display for the end-user. - * - * @param report Details on the abuse report. - */ - title(manager: ReportManager, report: IReport): Promise; - - /** - * A human-readable help message to display for the end-user. - * - * @param report Details on the abuse report. - */ - help(manager: ReportManager, report: IReport): Promise; - - /** - * Attempt to execute the action. - */ - execute(manager: ReportManager, report: IReport, moderationroomID: string, displayManager: DisplayManager): Promise; + /** + * A unique label. + * + * Used by Mjölnir to differentiate the actions, e.g. `kick-user`. + */ + readonly label: string; + + /** + * A unique Emoji. + * + * Used to help users avoid making errors when clicking on a button. + */ + readonly emoji: string; + + /** + * If `true`, this is an action that needs confirmation. Otherwise, the + * action may be executed immediately. + */ + readonly needsConfirmation: boolean; + + /** + * Detect whether the action may be executed, e.g. whether Mjölnir has + * sufficient powerlevel to execute this action. + * + * **Security caveat** This assumes that the security policy on whether + * the operation can be executed is: + * + * > *Anyone* in the moderation room and who isn't muted can execute + * > an operation iff Mjölnir has the rights to execute it. + * + * @param report Details on the abuse report. + */ + canExecute( + manager: ReportManager, + report: IReport, + moderationroomID: string + ): Promise; + + /** + * A human-readable title to display for the end-user. + * + * @param report Details on the abuse report. + */ + title(manager: ReportManager, report: IReport): Promise; + + /** + * A human-readable help message to display for the end-user. + * + * @param report Details on the abuse report. + */ + help(manager: ReportManager, report: IReport): Promise; + + /** + * Attempt to execute the action. + */ + execute( + manager: ReportManager, + report: IReport, + moderationroomID: string, + displayManager: DisplayManager + ): Promise; } /** * UI action: Ignore bad report */ class IgnoreBadReport implements IUIAction { - public label = "bad-report"; - public emoji = "🚯"; - public needsConfirmation = true; - public async canExecute(_manager: ReportManager, _report: IReport): Promise { - return true; - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Ignore"; - } - public async help(_manager: ReportManager, _report: IReport): Promise { - return "Ignore bad report"; - } - public async execute(manager: ReportManager, report: IReportWithAction): Promise { - await manager.draupnir.client.sendEvent(manager.draupnir.managementRoomID, "m.room.message", - { - msgtype: "m.notice", - body: "Report classified as invalid", - "m.new_content": { - "body": `Report by user ${report.reporter_id} has been classified as invalid`, - "msgtype": "m.text" - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": report.notification_event_id - } - } - ); - return; - } + public label = "bad-report"; + public emoji = "🚯"; + public needsConfirmation = true; + public async canExecute( + _manager: ReportManager, + _report: IReport + ): Promise { + return true; + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Ignore"; + } + public async help( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Ignore bad report"; + } + public async execute( + manager: ReportManager, + report: IReportWithAction + ): Promise { + await manager.draupnir.client.sendEvent( + manager.draupnir.managementRoomID, + "m.room.message", + { + msgtype: "m.notice", + body: "Report classified as invalid", + "m.new_content": { + body: `Report by user ${report.reporter_id} has been classified as invalid`, + msgtype: "m.text", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: report.notification_event_id, + }, + } + ); + return; + } } /** * UI action: Redact reported message. */ class RedactMessage implements IUIAction { - public label = "redact-message"; - public emoji = "🗍"; - public needsConfirmation = true; - public async canExecute(manager: ReportManager, report: IReport): Promise { - try { - return await manager.draupnir.client.userHasPowerLevelForAction(await manager.draupnir.client.getUserId(), report.room_id, PowerLevelAction.RedactEvents); - } catch (ex) { - return false; - } - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Redact"; - } - public async help(_manager: ReportManager, report: IReport): Promise { - return `Redact event ${report.event_id}`; - } - public async execute(manager: ReportManager, report: IReport, _moderationroomID: string): Promise { - await manager.draupnir.client.redactEvent(report.room_id, report.event_id); - return; + public label = "redact-message"; + public emoji = "🗍"; + public needsConfirmation = true; + public async canExecute( + manager: ReportManager, + report: IReport + ): Promise { + try { + return await manager.draupnir.client.userHasPowerLevelForAction( + await manager.draupnir.client.getUserId(), + report.room_id, + PowerLevelAction.RedactEvents + ); + } catch (ex) { + return false; } + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Redact"; + } + public async help(_manager: ReportManager, report: IReport): Promise { + return `Redact event ${report.event_id}`; + } + public async execute( + manager: ReportManager, + report: IReport, + _moderationroomID: string + ): Promise { + await manager.draupnir.client.redactEvent(report.room_id, report.event_id); + return; + } } /** * UI action: Kick accused user. */ class KickAccused implements IUIAction { - public label = "kick-accused"; - public emoji = "⚽"; - public needsConfirmation = true; - public async canExecute(manager: ReportManager, report: IReport): Promise { - try { - return await manager.draupnir.client.userHasPowerLevelForAction(await manager.draupnir.client.getUserId(), report.room_id, PowerLevelAction.Kick); - } catch (ex) { - return false; - } - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Kick"; - } - public async help(_manager: ReportManager, report: IReport): Promise { - return `Kick ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; - } - public async execute(manager: ReportManager, report: IReport): Promise { - await manager.draupnir.client.kickUser(report.accused_id, report.room_id); - return; + public label = "kick-accused"; + public emoji = "⚽"; + public needsConfirmation = true; + public async canExecute( + manager: ReportManager, + report: IReport + ): Promise { + try { + return await manager.draupnir.client.userHasPowerLevelForAction( + await manager.draupnir.client.getUserId(), + report.room_id, + PowerLevelAction.Kick + ); + } catch (ex) { + return false; } + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Kick"; + } + public async help(_manager: ReportManager, report: IReport): Promise { + return `Kick ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; + } + public async execute( + manager: ReportManager, + report: IReport + ): Promise { + await manager.draupnir.client.kickUser(report.accused_id, report.room_id); + return; + } } /** * UI action: Mute accused user. */ class MuteAccused implements IUIAction { - public label = "mute-accused"; - public emoji = "🤐"; - public needsConfirmation = true; - public async canExecute(manager: ReportManager, report: IReport): Promise { - try { - return await manager.draupnir.client.userHasPowerLevelFor(await manager.draupnir.client.getUserId(), report.room_id, "m.room.power_levels", true); - } catch (ex) { - return false; - } - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Mute"; - } - public async help(_manager: ReportManager, report: IReport): Promise { - return `Mute ${htmlEscape(report.accused_id)} in room ${htmlEscape(report.room_alias_or_id)}`; - } - public async execute(manager: ReportManager, report: IReport): Promise { - await manager.draupnir.client.setUserPowerLevel(report.accused_id, report.room_id, -1); - return; + public label = "mute-accused"; + public emoji = "🤐"; + public needsConfirmation = true; + public async canExecute( + manager: ReportManager, + report: IReport + ): Promise { + try { + return await manager.draupnir.client.userHasPowerLevelFor( + await manager.draupnir.client.getUserId(), + report.room_id, + "m.room.power_levels", + true + ); + } catch (ex) { + return false; } + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Mute"; + } + public async help(_manager: ReportManager, report: IReport): Promise { + return `Mute ${htmlEscape(report.accused_id)} in room ${htmlEscape(report.room_alias_or_id)}`; + } + public async execute( + manager: ReportManager, + report: IReport + ): Promise { + await manager.draupnir.client.setUserPowerLevel( + report.accused_id, + report.room_id, + -1 + ); + return; + } } /** * UI action: Ban accused. */ class BanAccused implements IUIAction { - public label = "ban-accused"; - public emoji = "🚫"; - public needsConfirmation = true; - public async canExecute(manager: ReportManager, report: IReport): Promise { - try { - return await manager.draupnir.client.userHasPowerLevelForAction(await manager.draupnir.client.getUserId(), report.room_id, PowerLevelAction.Ban); - } catch (ex) { - return false; - } - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Ban"; - } - public async help(_manager: ReportManager, report: IReport): Promise { - return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; - } - public async execute(manager: ReportManager, report: IReport): Promise { - await manager.draupnir.client.banUser(report.accused_id, report.room_id); - return; + public label = "ban-accused"; + public emoji = "🚫"; + public needsConfirmation = true; + public async canExecute( + manager: ReportManager, + report: IReport + ): Promise { + try { + return await manager.draupnir.client.userHasPowerLevelForAction( + await manager.draupnir.client.getUserId(), + report.room_id, + PowerLevelAction.Ban + ); + } catch (ex) { + return false; } + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Ban"; + } + public async help(_manager: ReportManager, report: IReport): Promise { + return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; + } + public async execute( + manager: ReportManager, + report: IReport + ): Promise { + await manager.draupnir.client.banUser(report.accused_id, report.room_id); + return; + } } /** * UI action: Help. */ class Help implements IUIAction { - public label = "help"; - public emoji = "❓"; - public needsConfirmation = false; - public async canExecute(_manager: ReportManager, _report: IReport): Promise { - return true; - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Help"; - } - public async help(_manager: ReportManager, _report: IReport): Promise { - return "This help"; - } - public async execute(manager: ReportManager, report: IReport, moderationroomID: string): Promise { - // Produce a html list of actions, in the order specified by ACTION_LIST. - const list: string[] = []; - for (const action of ACTION_LIST) { - if (await action.canExecute(manager, report, moderationroomID)) { - list.push(`
            8. ${action.emoji} ${await action.help(manager, report)}
            9. `); - } - } - if (!await ACTIONS.get("ban-accused")?.canExecute(manager, report, moderationroomID)) { - list.push(`
            10. Some actions were disabled because Mjölnir is not moderator in room ${htmlEscape(report.room_alias_or_id)}
            11. `) - } - const body = `
                ${list.join("\n")}
              `; - return body; + public label = "help"; + public emoji = "❓"; + public needsConfirmation = false; + public async canExecute( + _manager: ReportManager, + _report: IReport + ): Promise { + return true; + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Help"; + } + public async help( + _manager: ReportManager, + _report: IReport + ): Promise { + return "This help"; + } + public async execute( + manager: ReportManager, + report: IReport, + moderationroomID: string + ): Promise { + // Produce a html list of actions, in the order specified by ACTION_LIST. + const list: string[] = []; + for (const action of ACTION_LIST) { + if (await action.canExecute(manager, report, moderationroomID)) { + list.push( + `
            12. ${action.emoji} ${await action.help(manager, report)}
            13. ` + ); + } } + if ( + !(await ACTIONS.get("ban-accused")?.canExecute( + manager, + report, + moderationroomID + )) + ) { + list.push( + `
            14. Some actions were disabled because Mjölnir is not moderator in room ${htmlEscape(report.room_alias_or_id)}
            15. ` + ); + } + const body = `
                ${list.join("\n")}
              `; + return body; + } } /** * Escalate to the moderation room of this instance of Mjölnir. */ class EscalateToServerModerationRoom implements IUIAction { - public label = "escalate-to-server-moderation"; - public emoji = "⏫"; - public needsConfirmation = true; - public async canExecute(manager: ReportManager, report: IReport, moderationroomID: string): Promise { - if (moderationroomID === manager.draupnir.managementRoomID) { - // We're already at the top of the chain. - return false; - } - try { - await manager.draupnir.client.getEvent(report.room_id, report.event_id); - } catch (ex) { - // We can't fetch the event. - return false; - } - return true; - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Escalate"; - } - public async help(manager: ReportManager, _report: IReport): Promise { - return `Escalate report to ${getHomeserver(await manager.draupnir.client.getUserId())} server moderators`; - } - public async execute(manager: ReportManager, report: IReport, _moderationroomID: string, displayManager: DisplayManager): Promise { - const event = await manager.draupnir.client.getEvent(report.room_id, report.event_id); - - // Display the report and UI directly in the management room, as if it had been - // received from /report. - // - // Security: - // - `kind`: statically known good; - // - `moderationroomID`: statically known good; - // - `reporterId`: we trust `report`, could be forged by a moderator, low impact; - // - `event`: checked just before. - await displayManager.displayReportAndUI({ kind: Kind.ESCALATED_REPORT, reporterId: report.reporter_id, moderationroomID: manager.draupnir.managementRoomID, event }); - return; + public label = "escalate-to-server-moderation"; + public emoji = "⏫"; + public needsConfirmation = true; + public async canExecute( + manager: ReportManager, + report: IReport, + moderationroomID: string + ): Promise { + if (moderationroomID === manager.draupnir.managementRoomID) { + // We're already at the top of the chain. + return false; + } + try { + await manager.draupnir.client.getEvent(report.room_id, report.event_id); + } catch (ex) { + // We can't fetch the event. + return false; } + return true; + } + public async title( + _manager: ReportManager, + _report: IReport + ): Promise { + return "Escalate"; + } + public async help(manager: ReportManager, _report: IReport): Promise { + return `Escalate report to ${getHomeserver(await manager.draupnir.client.getUserId())} server moderators`; + } + public async execute( + manager: ReportManager, + report: IReport, + _moderationroomID: string, + displayManager: DisplayManager + ): Promise { + const event = await manager.draupnir.client.getEvent( + report.room_id, + report.event_id + ); + + // Display the report and UI directly in the management room, as if it had been + // received from /report. + // + // Security: + // - `kind`: statically known good; + // - `moderationroomID`: statically known good; + // - `reporterId`: we trust `report`, could be forged by a moderator, low impact; + // - `event`: checked just before. + await displayManager.displayReportAndUI({ + kind: Kind.ESCALATED_REPORT, + reporterId: report.reporter_id, + moderationroomID: manager.draupnir.managementRoomID, + event, + }); + return; + } } class DisplayManager { - - constructor(private owner: ReportManager) { - - } - - /** - * Display the report and any UI button. - * - * - * # Security - * - * This method DOES NOT PERFORM ANY SECURITY CHECKS. - * - * @param kind The kind of report (server-wide abuse report / room moderation request). Low security. - * @param event The offending event. The fact that it's the offending event MUST be checked. No assumptions are made on the content. - * @param reporterId The user who reported the event. MUST be checked. - * @param reason A user-provided comment. Low-security. - * @param moderationroomID The room in which the report and ui will be displayed. MUST be checked. - */ - public async displayReportAndUI(args: { kind: Kind, event: RoomEvent, reporterId: string, reason?: string, nature?: string, moderationroomID: string, error?: string }) { - const { kind, event, reporterId, reason, nature, moderationroomID, error } = args; - - const roomID = event["room_id"]; - const room = MatrixRoomReference.fromRoomID(roomID, [serverName(this.owner.draupnir.clientUserID)]) - const eventId = event["event_id"]; - const MAX_EVENT_CONTENT_LENGTH = 2048; - const MAX_NEWLINES = 64; - let eventContent: { msg: string} | { html: string } | { text: string }; - const unknownEvent = () => { - return { text: this.limitLength(JSON.stringify(event["content"], null, 2), MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; - }; - try { - if (event["type"] === "m.room.encrypted") { - eventContent = { msg: "" }; - } else if (Value.Check(RoomMessage, event)) { - if (Value.Check(TextMessageContent, event.content) && event.content.formatted_body !== undefined) { - eventContent = { html: this.limitLength(event.content.formatted_body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; - } else if ("body" in event.content && typeof event.content.body === 'string') { - eventContent = { text: this.limitLength(event.content.body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; - } else { - eventContent = unknownEvent(); - } - } else { - eventContent = unknownEvent(); - } - } catch (ex) { - eventContent = { msg: `.` }; + constructor(private owner: ReportManager) {} + + /** + * Display the report and any UI button. + * + * + * # Security + * + * This method DOES NOT PERFORM ANY SECURITY CHECKS. + * + * @param kind The kind of report (server-wide abuse report / room moderation request). Low security. + * @param event The offending event. The fact that it's the offending event MUST be checked. No assumptions are made on the content. + * @param reporterId The user who reported the event. MUST be checked. + * @param reason A user-provided comment. Low-security. + * @param moderationroomID The room in which the report and ui will be displayed. MUST be checked. + */ + public async displayReportAndUI(args: { + kind: Kind; + event: RoomEvent; + reporterId: string; + reason?: string; + nature?: string; + moderationroomID: string; + error?: string; + }) { + const { kind, event, reporterId, reason, nature, moderationroomID, error } = + args; + + const roomID = event["room_id"]; + const room = MatrixRoomReference.fromRoomID(roomID, [ + serverName(this.owner.draupnir.clientUserID), + ]); + const eventId = event["event_id"]; + const MAX_EVENT_CONTENT_LENGTH = 2048; + const MAX_NEWLINES = 64; + let eventContent: { msg: string } | { html: string } | { text: string }; + const unknownEvent = () => { + return { + text: this.limitLength( + JSON.stringify(event["content"], null, 2), + MAX_EVENT_CONTENT_LENGTH, + MAX_NEWLINES + ), + }; + }; + try { + if (event["type"] === "m.room.encrypted") { + eventContent = { msg: "" }; + } else if (Value.Check(RoomMessage, event)) { + if ( + Value.Check(TextMessageContent, event.content) && + event.content.formatted_body !== undefined + ) { + eventContent = { + html: this.limitLength( + event.content.formatted_body, + MAX_EVENT_CONTENT_LENGTH, + MAX_NEWLINES + ), + }; + } else if ( + "body" in event.content && + typeof event.content.body === "string" + ) { + eventContent = { + text: this.limitLength( + event.content.body, + MAX_EVENT_CONTENT_LENGTH, + MAX_NEWLINES + ), + }; + } else { + eventContent = unknownEvent(); } + } else { + eventContent = unknownEvent(); + } + } catch (ex) { + eventContent = { + msg: `.`, + }; + } - const accusedId = event["sender"]; + const accusedId = event["sender"]; - let reporterDisplayName: string, accusedDisplayName: string; - try { - reporterDisplayName = (await this.owner.draupnir.client.getUserProfile(reporterId))["displayname"] || reporterId; - } catch (ex) { - reporterDisplayName = ""; - } - try { - accusedDisplayName = (await this.owner.draupnir.client.getUserProfile(accusedId))["displayname"] || accusedId; - } catch (ex) { - accusedDisplayName = ""; - } - const eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomID)}/${encodeURIComponent(eventId)}`; + let reporterDisplayName: string, accusedDisplayName: string; + try { + reporterDisplayName = + (await this.owner.draupnir.client.getUserProfile(reporterId))[ + "displayname" + ] || reporterId; + } catch (ex) { + reporterDisplayName = ""; + } + try { + accusedDisplayName = + (await this.owner.draupnir.client.getUserProfile(accusedId))[ + "displayname" + ] || accusedId; + } catch (ex) { + accusedDisplayName = ""; + } + const eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomID)}/${encodeURIComponent(eventId)}`; - let eventTimestamp; - try { - eventTimestamp = new Date(event["origin_server_ts"]).toUTCString(); - } catch (ex) { - eventTimestamp = `.`; - } + let eventTimestamp; + try { + eventTimestamp = new Date(event["origin_server_ts"]).toUTCString(); + } catch (ex) { + eventTimestamp = `.`; + } - let title; - switch (kind) { - case Kind.MODERATION_REQUEST: - title = "Moderation request"; - break; - case Kind.SERVER_ABUSE_REPORT: - title = "Abuse report"; - break; - case Kind.ESCALATED_REPORT: - title = "Moderation request escalated by moderators"; - break; - case Kind.ERROR: - title = "Error"; - break; - } + let title; + switch (kind) { + case Kind.MODERATION_REQUEST: + title = "Moderation request"; + break; + case Kind.SERVER_ABUSE_REPORT: + title = "Abuse report"; + break; + case Kind.ESCALATED_REPORT: + title = "Moderation request escalated by moderators"; + break; + case Kind.ERROR: + title = "Error"; + break; + } - let readableNature = "unspecified"; - if (nature) { - readableNature = NATURE_DESCRIPTIONS.get(nature) || readableNature; - } + let readableNature = "unspecified"; + if (nature) { + readableNature = NATURE_DESCRIPTIONS.get(nature) || readableNature; + } - // We need to send the report as html to be able to use spoiler markings. - // We build this as dom to be absolutely certain that we're not introducing - // any kind of injection within the report. + // We need to send the report as html to be able to use spoiler markings. + // We build this as dom to be absolutely certain that we're not introducing + // any kind of injection within the report. - // Please do NOT insert any `${}` in the following backticks, to avoid - // any XSS attack. - const document = new JSDOM(` + // Please do NOT insert any `${}` in the following backticks, to avoid + // any XSS attack. + const document = new JSDOM(`
              @@ -835,130 +1129,141 @@ class DisplayManager {
              `).window.document; - // ...insert text content - for (const [key, value] of [ - ['title', title], - ['reporter-display-name', reporterDisplayName], - ['reporter-id', reporterId], - ['accused-display-name', accusedDisplayName], - ['accused-id', accusedId], - ['event-id', eventId], - ['room-alias-or-id', roomID], - ['reason-content', reason || ""], - ['nature-display', readableNature], - ['nature-source', nature || ""], - ['event-timestamp', eventTimestamp], - ['details-or-error', kind === Kind.ERROR ? error : null] - ]) { - if (key !== null && key !== undefined) { - const node = document.getElementById(key); - if (node && value) { - node.textContent = value; - } - } + // ...insert text content + for (const [key, value] of [ + ["title", title], + ["reporter-display-name", reporterDisplayName], + ["reporter-id", reporterId], + ["accused-display-name", accusedDisplayName], + ["accused-id", accusedId], + ["event-id", eventId], + ["room-alias-or-id", roomID], + ["reason-content", reason || ""], + ["nature-display", readableNature], + ["nature-source", nature || ""], + ["event-timestamp", eventTimestamp], + ["details-or-error", kind === Kind.ERROR ? error : null], + ]) { + if (key !== null && key !== undefined) { + const node = document.getElementById(key); + if (node && value) { + node.textContent = value; } - // ...insert links - for (const [key, value] of [ - ['event-shortcut', eventShortcut], - ['room-shortcut', room.toPermalink()], - ]) { - if (key !== undefined) { - const node = document.getElementById(key); - if (node !== null && value !== undefined) { - (node as HTMLAnchorElement).href = value; - } - } - } - - // ...insert HTML content - for (const {key, value} of [ - { key: 'event-content', value: eventContent }, - ]) { - const node = document.getElementById(key); - if (node) { - if ("msg" in value) { - node.textContent = value.msg; - } else if ("text" in value) { - node.textContent = value.text; - } else if ("html" in value) { - node.innerHTML = value.html - } - } + } + } + // ...insert links + for (const [key, value] of [ + ["event-shortcut", eventShortcut], + ["room-shortcut", room.toPermalink()], + ]) { + if (key !== undefined) { + const node = document.getElementById(key); + if (node !== null && value !== undefined) { + (node as HTMLAnchorElement).href = value; } + } + } - // ...set presentation - if (!("msg" in eventContent)) { - // If there's some event content, mark it as a spoiler. - const eventContainer = document.getElementById('event-container'); - if (eventContainer !== null) { - eventContainer.setAttribute("data-mx-spoiler", ""); - } + // ...insert HTML content + for (const { key, value } of [ + { key: "event-content", value: eventContent }, + ]) { + const node = document.getElementById(key); + if (node) { + if ("msg" in value) { + node.textContent = value.msg; + } else if ("text" in value) { + node.textContent = value.text; + } else if ("html" in value) { + node.innerHTML = value.html; } + } + } - // Embed additional information in the notice, for use by the - // action buttons. - const report: IReport = { - accused_id: accusedId, - reporter_id: reporterId, - event_id: eventId, - room_id: roomID, - room_alias_or_id: roomID, - }; - const notice = { - msgtype: "m.notice", - body: htmlToText(document.body.outerHTML, { wordwrap: false }), - format: "org.matrix.custom.html", - formatted_body: document.body.outerHTML, - [ABUSE_REPORT_KEY]: report - }; - - const noticeEventId = await this.owner.draupnir.client.sendMessage(this.owner.draupnir.managementRoomID, notice); - if (kind !== Kind.ERROR) { - // Now let's display buttons. - for (const [label, action] of ACTIONS) { - // Display buttons for actions that can be executed. - if (!await action.canExecute(this.owner, report, moderationroomID)) { - continue; - } - await this.owner.draupnir.client.sendEvent(this.owner.draupnir.managementRoomID, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": noticeEventId, - "key": `${action.emoji} ${await action.title(this.owner, report)} [${label}]` - } - }); - } - } + // ...set presentation + if (!("msg" in eventContent)) { + // If there's some event content, mark it as a spoiler. + const eventContainer = document.getElementById("event-container"); + if (eventContainer !== null) { + eventContainer.setAttribute("data-mx-spoiler", ""); + } } - private limitLength(text: string, maxLength: number, maxNewlines: number): string { - const originalLength = text.length - // Shorten text if it is too long. - if (text.length > maxLength) { - text = text.substring(0, maxLength); - } - // Shorten text if there are too many newlines. - // Note: This only looks for text newlines, not `
              `, `
            16. ` or any other HTML box. - let index = -1; - let newLines = 0; - while (true) { - index = text.indexOf("\n", index); - if (index === -1) { - break; - } - index += 1; - newLines += 1; - if (newLines > maxNewlines) { - text = text.substring(0, index); - break; - } - }; - if (text.length < originalLength) { - return `${text}... [total: ${originalLength} characters]`; - } else { - return text; + // Embed additional information in the notice, for use by the + // action buttons. + const report: IReport = { + accused_id: accusedId, + reporter_id: reporterId, + event_id: eventId, + room_id: roomID, + room_alias_or_id: roomID, + }; + const notice = { + msgtype: "m.notice", + body: htmlToText(document.body.outerHTML, { wordwrap: false }), + format: "org.matrix.custom.html", + formatted_body: document.body.outerHTML, + [ABUSE_REPORT_KEY]: report, + }; + + const noticeEventId = await this.owner.draupnir.client.sendMessage( + this.owner.draupnir.managementRoomID, + notice + ); + if (kind !== Kind.ERROR) { + // Now let's display buttons. + for (const [label, action] of ACTIONS) { + // Display buttons for actions that can be executed. + if (!(await action.canExecute(this.owner, report, moderationroomID))) { + continue; } + await this.owner.draupnir.client.sendEvent( + this.owner.draupnir.managementRoomID, + "m.reaction", + { + "m.relates_to": { + rel_type: "m.annotation", + event_id: noticeEventId, + key: `${action.emoji} ${await action.title(this.owner, report)} [${label}]`, + }, + } + ); + } + } + } + + private limitLength( + text: string, + maxLength: number, + maxNewlines: number + ): string { + const originalLength = text.length; + // Shorten text if it is too long. + if (text.length > maxLength) { + text = text.substring(0, maxLength); + } + // Shorten text if there are too many newlines. + // Note: This only looks for text newlines, not `
              `, `
            17. ` or any other HTML box. + let index = -1; + let newLines = 0; + while (true) { + index = text.indexOf("\n", index); + if (index === -1) { + break; + } + index += 1; + newLines += 1; + if (newLines > maxNewlines) { + text = text.substring(0, index); + break; + } + } + if (text.length < originalLength) { + return `${text}... [total: ${originalLength} characters]`; + } else { + return text; } + } } /** @@ -967,21 +1272,21 @@ class DisplayManager { * As a list, ordered for displayed when users click on "Help". */ const ACTION_LIST = [ - new KickAccused(), - new RedactMessage(), - new MuteAccused(), - new BanAccused(), - new EscalateToServerModerationRoom(), - new IgnoreBadReport(), - new Help() + new KickAccused(), + new RedactMessage(), + new MuteAccused(), + new BanAccused(), + new EscalateToServerModerationRoom(), + new IgnoreBadReport(), + new Help(), ]; /** * The actions we may be able to undertake in reaction to a report. * * As a map of labels => actions. */ -const ACTIONS = new Map(ACTION_LIST.map(action => [action.label, action])); +const ACTIONS = new Map(ACTION_LIST.map((action) => [action.label, action])); function getHomeserver(userId: string): string { - return new UserID(userId).domain + return new UserID(userId).domain; } diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index ede7efa9..921f9c49 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -26,11 +26,19 @@ limitations under the License. */ import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ReportManager } from './ReportManager'; +import { ReportManager } from "./ReportManager"; import { LogLevel, LogService } from "matrix-bot-sdk"; import ManagementRoomOutput from "../ManagementRoomOutput"; import { Draupnir } from "../Draupnir"; -import { ActionException, ActionExceptionKind, Ok, SynapseReport, Task, Value, isError } from "matrix-protection-suite"; +import { + ActionException, + ActionExceptionKind, + Ok, + SynapseReport, + Task, + Value, + isError, +} from "matrix-protection-suite"; /** * Synapse will tell us where we last got to on polling reports, so we need @@ -38,7 +46,7 @@ import { ActionException, ActionExceptionKind, Ok, SynapseReport, Task, Value, i */ export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll"; -class InvalidStateError extends Error { } +class InvalidStateError extends Error {} export type ReportPollSetting = { from: number }; @@ -49,144 +57,187 @@ export type ReportPollSetting = { from: number }; * @param manager The report manager in to which we feed new reports */ export class ReportPoller { - /** - * https://matrix-org.github.io/synapse/latest/admin_api/event_reports.html - * "from" is an opaque token that is returned from the API to paginate reports - */ - private from = 0; - /** - * The currently-pending report poll - */ - private timeout: ReturnType | null = null; - - constructor( - private draupnir: Draupnir, - private manager: ReportManager, - ) { } - - private schedulePoll() { - if (this.timeout === null) { - /* - * Important that we use `setTimeout` here, not `setInterval`, - * because if there's networking problems and `getAbuseReports` - * hangs for longer thank the interval, it could cause a stampede - * of requests when networking problems resolve - */ - this.timeout = setTimeout( - this.tryGetAbuseReports.bind(this), - 30_000 // a minute in milliseconds - ); - } else { - throw new InvalidStateError("poll already scheduled"); - } + /** + * https://matrix-org.github.io/synapse/latest/admin_api/event_reports.html + * "from" is an opaque token that is returned from the API to paginate reports + */ + private from = 0; + /** + * The currently-pending report poll + */ + private timeout: ReturnType | null = null; + + constructor( + private draupnir: Draupnir, + private manager: ReportManager + ) {} + + private schedulePoll() { + if (this.timeout === null) { + /* + * Important that we use `setTimeout` here, not `setInterval`, + * because if there's networking problems and `getAbuseReports` + * hangs for longer thank the interval, it could cause a stampede + * of requests when networking problems resolve + */ + this.timeout = setTimeout( + this.tryGetAbuseReports.bind(this), + 30_000 // a minute in milliseconds + ); + } else { + throw new InvalidStateError("poll already scheduled"); } + } - private async getAbuseReports() { - let response: { - event_reports: unknown[], - next_token: number | undefined - } | undefined; - try { - response = await this.draupnir.client.doRequest( - "GET", - "/_synapse/admin/v1/event_reports", - { - // short for direction: forward; i.e. show newest last - dir: "f", - from: this.from.toString() - } - ); - } catch (ex) { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); - return; + private async getAbuseReports() { + let response: + | { + event_reports: unknown[]; + next_token: number | undefined; } - if (response === undefined) { - throw new TypeError(`we should have got a response from /event_reports/, code is wrong.`); + | undefined; + try { + response = await this.draupnir.client.doRequest( + "GET", + "/_synapse/admin/v1/event_reports", + { + // short for direction: forward; i.e. show newest last + dir: "f", + from: this.from.toString(), } - for (const rawReport of response.event_reports) { - const reportResult = Value.Decode(SynapseReport, rawReport); - if (isError(reportResult)) { - LogService.error('ReportPoller', `Failed to decode a synapse report ${reportResult.error.uuid}`, rawReport); - continue; - } - const report = reportResult.ok; - // FIXME: shouldn't we have a SafeMatrixSendClient in the BotSDKMPS that gives us ActionResult's with - // Decoded events. - // Problem is that our current event model isn't going to match up with extensible events. - const eventContext = await this.draupnir.client.doRequest( - "GET", - `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` - ).then( - (value) => Ok(value), - (exception) => ActionException.Result(`Failed to retrieve the context for an event ${report.event_id}`, { exception, exceptionKind: ActionExceptionKind.Unknown }) + ); + } catch (ex) { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "getAbuseReports", + `failed to poll events: ${ex}` + ); + return; + } + if (response === undefined) { + throw new TypeError( + `we should have got a response from /event_reports/, code is wrong.` + ); + } + for (const rawReport of response.event_reports) { + const reportResult = Value.Decode(SynapseReport, rawReport); + if (isError(reportResult)) { + LogService.error( + "ReportPoller", + `Failed to decode a synapse report ${reportResult.error.uuid}`, + rawReport + ); + continue; + } + const report = reportResult.ok; + // FIXME: shouldn't we have a SafeMatrixSendClient in the BotSDKMPS that gives us ActionResult's with + // Decoded events. + // Problem is that our current event model isn't going to match up with extensible events. + const eventContext = await this.draupnir.client + .doRequest( + "GET", + `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` + ) + .then( + (value) => Ok(value), + (exception) => + ActionException.Result( + `Failed to retrieve the context for an event ${report.event_id}`, + { exception, exceptionKind: ActionExceptionKind.Unknown } ) - if (isError(eventContext)) { - void Task(this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${eventContext.error.uuid}`)); - continue; - } - const event = eventContext.ok.event; - - await this.manager.handleServerAbuseReport({ - roomID: report.room_id, - reporterId: report.sender, - event: event, - ...report.reason ? { reason: report.reason } : {} - }); - } - - /* - * This API endpoint returns an opaque `next_token` number that we - * need to give back to subsequent requests for pagination, so here we - * save it in account data - */ - if (response.next_token !== undefined) { - this.from = response.next_token; - try { - await this.draupnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); - } catch (ex) { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); - } - } + ); + if (isError(eventContext)) { + void Task( + this.draupnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "getAbuseReports", + `failed to get context: ${eventContext.error.uuid}` + ) + ); + continue; + } + const event = eventContext.ok.event; + + await this.manager.handleServerAbuseReport({ + roomID: report.room_id, + reporterId: report.sender, + event: event, + ...(report.reason ? { reason: report.reason } : {}), + }); } - private async tryGetAbuseReports() { - this.timeout = null; - - try { - await this.getAbuseReports() - } catch (ex) { - await this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); - } - - this.schedulePoll(); + /* + * This API endpoint returns an opaque `next_token` number that we + * need to give back to subsequent requests for pagination, so here we + * save it in account data + */ + if (response.next_token !== undefined) { + this.from = response.next_token; + try { + await this.draupnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { + from: response.next_token, + }); + } catch (ex) { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "getAbuseReports", + `failed to update progress: ${ex}` + ); + } } - public static async getReportPollSetting(client: MatrixSendClient, managementRoomOutput: ManagementRoomOutput): Promise { - let reportPollSetting: ReportPollSetting = { from: 0 }; - try { - reportPollSetting = await client.getAccountData(REPORT_POLL_EVENT_TYPE); - } catch (err) { - if (err.body?.errcode !== "M_NOT_FOUND") { - throw err; - } else { - void Task(managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet")); - } - } - return reportPollSetting; + } + + private async tryGetAbuseReports() { + this.timeout = null; + + try { + await this.getAbuseReports(); + } catch (ex) { + await this.draupnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "tryGetAbuseReports", + `failed to get abuse reports: ${ex}` + ); } - public start({from: startFrom }: ReportPollSetting) { - if (this.timeout === null) { - this.from = startFrom; - this.schedulePoll(); - } else { - throw new InvalidStateError("cannot start an already started poll"); - } + + this.schedulePoll(); + } + public static async getReportPollSetting( + client: MatrixSendClient, + managementRoomOutput: ManagementRoomOutput + ): Promise { + let reportPollSetting: ReportPollSetting = { from: 0 }; + try { + reportPollSetting = await client.getAccountData(REPORT_POLL_EVENT_TYPE); + } catch (err) { + if (err.body?.errcode !== "M_NOT_FOUND") { + throw err; + } else { + void Task( + managementRoomOutput.logMessage( + LogLevel.INFO, + "Mjolnir@startup", + "report poll setting does not exist yet" + ) + ); + } } - public stop() { - if (this.timeout !== null) { - clearTimeout(this.timeout); - this.timeout = null; - } else { - throw new InvalidStateError("cannot stop a poll that hasn't started"); - } + return reportPollSetting; + } + public start({ from: startFrom }: ReportPollSetting) { + if (this.timeout === null) { + this.from = startFrom; + this.schedulePoll(); + } else { + throw new InvalidStateError("cannot start an already started poll"); + } + } + public stop() { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } else { + throw new InvalidStateError("cannot stop a poll that hasn't started"); } + } } diff --git a/src/utils.ts b/src/utils.ts index dec80866..c424c0ca 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -26,15 +26,15 @@ limitations under the License. */ import { - LogLevel, - LogService, - MatrixGlob, - getRequestFn, - setRequestFn, - MatrixError, + LogLevel, + LogService, + MatrixGlob, + getRequestFn, + setRequestFn, + MatrixError, } from "matrix-bot-sdk"; import { ClientRequest, IncomingMessage } from "http"; -import * as Sentry from '@sentry/node'; +import * as Sentry from "@sentry/node"; import ManagementRoomOutput from "./ManagementRoomOutput"; import { IConfig } from "./config"; @@ -43,17 +43,16 @@ import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { RoomEvent } from "matrix-protection-suite"; export function htmlEscape(input: string): string { - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - return input.replace(/[<&"']/g, (c) => '&#' + c.charCodeAt(0) + ';'); + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + return input.replace(/[<&"']/g, (c) => "&#" + c.charCodeAt(0) + ";"); } - export function setToArray(set: Set): T[] { - const arr: T[] = []; - for (const v of set) { - arr.push(v); - } - return arr; + const arr: T[] = []; + for (const v of set) { + arr.push(v); + } + return arr; } /** @@ -67,11 +66,15 @@ export function setToArray(set: Set): T[] { * @param status The status value that should be modified * @param uuid The UUID of the instance. (Usually the localPart) */ -export function incrementGaugeValue(gauge: Gauge<"status" | "uuid">, status: "offline" | "disabled" | "online", uuid: string) { - // @ts-expect-error we access a private method due to lack of a public one. - if (!gauge._getValue({ status: status, uuid: uuid })) { - gauge.inc({ status: status, uuid: uuid }); - } +export function incrementGaugeValue( + gauge: Gauge<"status" | "uuid">, + status: "offline" | "disabled" | "online", + uuid: string +) { + // @ts-expect-error we access a private method due to lack of a public one. + if (!gauge._getValue({ status: status, uuid: uuid })) { + gauge.inc({ status: status, uuid: uuid }); + } } /** @@ -85,11 +88,15 @@ export function incrementGaugeValue(gauge: Gauge<"status" | "uuid">, status: "of * @param status The status value that should be modified * @param uuid The UUID of the instance. (Usually the localPart) */ -export function decrementGaugeValue(gauge: Gauge<"status" | "uuid">, status: "offline" | "disabled" | "online", uuid: string) { - // @ts-expect-error we access a private method due to lack of a public one. - if (gauge._getValue({ status: status, uuid: uuid })) { - gauge.dec({ status: status, uuid: uuid }); - } +export function decrementGaugeValue( + gauge: Gauge<"status" | "uuid">, + status: "offline" | "disabled" | "online", + uuid: string +) { + // @ts-expect-error we access a private method due to lack of a public one. + if (gauge._getValue({ status: status, uuid: uuid })) { + gauge.dec({ status: status, uuid: uuid }); + } } /** @@ -104,27 +111,67 @@ export function decrementGaugeValue(gauge: Gauge<"status" | "uuid">, status: "of * @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted. * @param noop Whether to operate in noop mode. */ -export async function redactUserMessagesIn(client: MatrixSendClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) { - for (const targetRoomId of targetRoomIds) { - await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); - - try { - await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { - for (const victimEvent of eventsToRedact) { - await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId); - if (!noop) { - await client.redactEvent(targetRoomId, victimEvent['event_id']).catch((error: unknown) => { - LogService.error("utils#redactUserMessagesIn", `Error while trying to redact messages for ${userIdOrGlob} in ${targetRoomId}:`, error, targetRoomId); - }); - } else { - await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId); - } - } - }); - } catch (error) { - await managementRoom.logMessage(LogLevel.ERROR, "utils#redactUserMessagesIn", `Error while trying to redact messages for ${userIdOrGlob} in ${targetRoomId}: ${error}`, targetRoomId); +export async function redactUserMessagesIn( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput, + userIdOrGlob: string, + targetRoomIds: string[], + limit = 1000, + noop = false +) { + for (const targetRoomId of targetRoomIds) { + await managementRoom.logMessage( + LogLevel.DEBUG, + "utils#redactUserMessagesIn", + `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, + targetRoomId + ); + + try { + await getMessagesByUserIn( + client, + userIdOrGlob, + targetRoomId, + limit, + async (eventsToRedact) => { + for (const victimEvent of eventsToRedact) { + await managementRoom.logMessage( + LogLevel.DEBUG, + "utils#redactUserMessagesIn", + `Redacting ${victimEvent["event_id"]} in ${targetRoomId}`, + targetRoomId + ); + if (!noop) { + await client + .redactEvent(targetRoomId, victimEvent["event_id"]) + .catch((error: unknown) => { + LogService.error( + "utils#redactUserMessagesIn", + `Error while trying to redact messages for ${userIdOrGlob} in ${targetRoomId}:`, + error, + targetRoomId + ); + }); + } else { + await managementRoom.logMessage( + LogLevel.WARN, + "utils#redactUserMessagesIn", + `Tried to redact ${victimEvent["event_id"]} in ${targetRoomId} but Mjolnir is running in no-op mode`, + targetRoomId + ); + } + } } + ); + } catch (error) { + await managementRoom.logMessage( + LogLevel.ERROR, + "utils#redactUserMessagesIn", + `Error while trying to redact messages for ${userIdOrGlob} in ${targetRoomId}: ${error}`, + targetRoomId + ); } + } } /** @@ -143,90 +190,103 @@ export async function redactUserMessagesIn(client: MatrixSendClient, managementR * The callback will only be called if there are any relevant events. * @returns {Promise} Resolves when either: the limit has been reached, no relevant events could be found or there is no more timeline to paginate. */ -export async function getMessagesByUserIn(client: MatrixSendClient, sender: string, roomId: string, limit: number, cb: (events: RoomEvent[]) => Promise | void): Promise { - const isGlob = sender.includes("*"); - const roomEventFilter = { - rooms: [roomId], - ...isGlob ? {} : { senders: [sender] } +export async function getMessagesByUserIn( + client: MatrixSendClient, + sender: string, + roomId: string, + limit: number, + cb: (events: RoomEvent[]) => Promise | void +): Promise { + const isGlob = sender.includes("*"); + const roomEventFilter = { + rooms: [roomId], + ...(isGlob ? {} : { senders: [sender] }), + }; + + const matcher = new MatrixGlob(sender); + + function testUser(userId: string): boolean { + if (isGlob) { + return matcher.test(userId); + } else { + return userId === sender; + } + } + + /** + * The response returned from `backfill` + * See https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3roomsroomidmessages + * for what the fields mean in detail. You have to read the spec even with the summary. + * The `chunk` contains the events in reverse-chronological order. + * The `end` is a token for the end of the `chunk` (where the older events are). + * The `start` is a token for the beginning of the `chunk` (where the most recent events are). + */ + interface BackfillResponse { + chunk?: RoomEvent[]; + end?: string; + start: string; + } + + /** + * Call `/messages` "backwards". + * @param from a token that was returned previously from this API to start paginating from or + * if `null`, start from the most recent point in the timeline. + * @returns The response part of the `/messages` API, see `BackfillResponse`. + */ + async function backfill(from: string | null): Promise { + const qs = { + filter: JSON.stringify(roomEventFilter), + dir: "b", + ...(from ? { from } : {}), }; - - const matcher = new MatrixGlob(sender); - - function testUser(userId: string): boolean { - if (isGlob) { - return matcher.test(userId); - } else { - return userId === sender; - } + LogService.info("utils", "Backfilling with token: ", from); + return client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages`, + qs + ); + } + + let processed = 0; + /** + * Filter events from the timeline to events that are from a matching sender and under the limit that can be processed by the callback. + * @param events Events from the room timeline. + * @returns Events that can safely be processed by the callback. + */ + function filterEvents(events: RoomEvent[]) { + const messages: RoomEvent[] = []; + for (const event of events) { + if (processed >= limit) return messages; // we have provided enough events. + processed++; + + if (testUser(event["sender"])) messages.push(event); } - - /** - * The response returned from `backfill` - * See https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3roomsroomidmessages - * for what the fields mean in detail. You have to read the spec even with the summary. - * The `chunk` contains the events in reverse-chronological order. - * The `end` is a token for the end of the `chunk` (where the older events are). - * The `start` is a token for the beginning of the `chunk` (where the most recent events are). - */ - interface BackfillResponse { - chunk?: RoomEvent[], - end?: string, - start: string + return messages; + } + // We check that we have the token because rooms/messages is not required to provide one + // and will not provide one when there is no more history to paginate. + let token: string | null = null; + do { + const bfMessages: BackfillResponse = await backfill(token); + const previousToken: string | null = token; + token = bfMessages["end"] ?? null; + const events = filterEvents(bfMessages["chunk"] || []); + // If we are using a glob, there may be no relevant events in this chunk. + if (events.length > 0) { + await cb(events); } - - /** - * Call `/messages` "backwards". - * @param from a token that was returned previously from this API to start paginating from or - * if `null`, start from the most recent point in the timeline. - * @returns The response part of the `/messages` API, see `BackfillResponse`. - */ - async function backfill(from: string | null): Promise { - const qs = { - filter: JSON.stringify(roomEventFilter), - dir: "b", - ...from ? { from } : {} - }; - LogService.info("utils", "Backfilling with token: ", from); - return client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages`, qs); + // This check exists only because of a Synapse compliance bug https://github.com/matrix-org/synapse/issues/12102. + // We also check after processing events as the `previousToken` can be 'null' if we are at the start of the steam + // and `token` can also be 'null' as we have paginated the entire timeline, but there would be unprocessed events in the + // chunk that was returned in this request. + if (previousToken === token) { + LogService.debug( + "utils", + "Backfill returned same end token - returning early." + ); + return; } - - let processed = 0; - /** - * Filter events from the timeline to events that are from a matching sender and under the limit that can be processed by the callback. - * @param events Events from the room timeline. - * @returns Events that can safely be processed by the callback. - */ - function filterEvents(events: RoomEvent[]) { - const messages: RoomEvent[] = []; - for (const event of events) { - if (processed >= limit) return messages; // we have provided enough events. - processed++; - - if (testUser(event['sender'])) messages.push(event); - } - return messages; - } - // We check that we have the token because rooms/messages is not required to provide one - // and will not provide one when there is no more history to paginate. - let token: string | null = null; - do { - const bfMessages: BackfillResponse = await backfill(token); - const previousToken: string | null = token; - token = bfMessages['end'] ?? null; - const events = filterEvents(bfMessages['chunk'] || []); - // If we are using a glob, there may be no relevant events in this chunk. - if (events.length > 0) { - await cb(events); - } - // This check exists only because of a Synapse compliance bug https://github.com/matrix-org/synapse/issues/12102. - // We also check after processing events as the `previousToken` can be 'null' if we are at the start of the steam - // and `token` can also be 'null' as we have paginated the entire timeline, but there would be unprocessed events in the - // chunk that was returned in this request. - if (previousToken === token) { - LogService.debug("utils", "Backfill returned same end token - returning early."); - return; - } - } while (token && processed < limit) + } while (token && processed < limit); } let isMatrixClientPatchedForConciseExceptions = false; @@ -237,21 +297,24 @@ let isMatrixClientPatchedForConciseExceptions = false; LogService.muteModule("MatrixHttpClient"); function isMatrixError(path: string): boolean { - return /^\/_matrix/.test(path) + return /^\/_matrix/.test(path); } interface RequestOptions { - method?: string | undefined; - uri: string | undefined; - [k: string]: unknown; + method?: string | undefined; + uri: string | undefined; + [k: string]: unknown; } -type RequestError = { - body?: { - [key: string]: unknown - }, - [key: string]: unknown } | undefined; -type RequestResponse = { statusCode: number, [key: string]: unknown }; +type RequestError = + | { + body?: { + [key: string]: unknown; + }; + [key: string]: unknown; + } + | undefined; +type RequestResponse = { statusCode: number; [key: string]: unknown }; /** * Patch `MatrixClient` into something that throws concise exceptions. @@ -267,106 +330,116 @@ type RequestResponse = { statusCode: number, [key: string]: unknown }; * instead throws more reasonable insetances of `Error`. */ function patchMatrixClientForConciseExceptions() { - if (isMatrixClientPatchedForConciseExceptions) { - return; - } - const originalRequestFn = getRequestFn(); - setRequestFn((params: RequestOptions, cb: typeof originalRequestFn) => { - // Store an error early, to maintain *some* semblance of stack. - // We'll only throw the error if there is one. - const error = new Error("STACK CAPTURE"); - originalRequestFn(params, function conciseExceptionRequestFn( - err: RequestError, response: RequestResponse, resBody: unknown - ) { - if (!err && (response.statusCode < 200 || response.statusCode >= 300)) { - // Normally, converting HTTP Errors into rejections is done by the caller - // of `requestFn` within matrix-bot-sdk. However, this always ends up rejecting - // with an `IncomingMessage` - exactly what we wish to avoid here. - err = response; - - // Safety note: In the calling code within matrix-bot-sdk, if we return - // an IncomingMessage as an error, we end up logging an unredacted response, - // which may include tokens, passwords, etc. This could be a grave privacy - // leak. The matrix-bot-sdk typically handles this by sanitizing the data - // before logging it but, by converting the HTTP Error into a rejection - // earlier than expected by the matrix-bot-sdk, we skip this step of - // sanitization. - // - // However, since the error we're creating is an `IncomingMessage`, we - // rewrite it into an `Error` ourselves in this function. Our `Error` - // is even more sanitized (we only include the URL, HTTP method and - // the error response) so we are NOT causing a privacy leak. - if (!(err instanceof IncomingMessage)) { - // Safety check. - throw new TypeError("Internal error: at this stage, the error should be an IncomingMessage"); - } - } - if (!(err instanceof IncomingMessage)) { - // In most cases, we're happy with the result. - return cb(err, response, resBody); - } - // However, MatrixClient has a tendency of throwing - // instances of `IncomingMessage` instead of instances - // of `Error`. The former take ~800 lines of log and - // provide no stack trace, which makes them typically - // useless. - const method: string | undefined = err.method - ? err.method - : "req" in err && err.req instanceof ClientRequest - ? err.req.method - : params.method; - const path: string = err.url - ? err.url - : "req" in err && err.req instanceof ClientRequest - ? err.req.path - : params.uri ?? ''; - let body: unknown = null; - if ("body" in err) { - body = err.body; - } - // Calling code may use `body` to check for errors, so let's - // make sure that we're providing it. - if (typeof body === 'string') { - try { - body = JSON.parse(body, jsonReviver); - } catch (ex) { - // Not JSON. - } - } - const message = `Error during MatrixClient request ${method} ${path}: ${err.statusCode} ${err.statusMessage} -- ${JSON.stringify(body)}`; - error.message = message; - if (body) { - // Define the property but don't make it visible during logging. - Object.defineProperty(error, "body", { - value: body, - enumerable: false, - }); - } - // Calling code may use `statusCode` to check for errors, so let's - // make sure that we're providing it. - if ("statusCode" in err) { - // Define the property but don't make it visible during logging. - Object.defineProperty(error, "statusCode", { - value: err.statusCode, - enumerable: false, - }); - } - // matrix-appservice-bridge depends on errors being matrix-bot-sdk's MatrixError. - // Since https://github.com/turt2live/matrix-bot-sdk/blob/836c2da7145668b20af7e0d75094b6162164f3dc/src/http.ts#L109 - // we wrote this, matrix-bot-sdk has updated so that there is now a MatrixError that is thrown - // when there are errors in the response. - if (isMatrixError(path)) { - const matrixError = new MatrixError(body as MatrixError['body'], err.statusCode as number); - if (error.stack !== undefined) { - matrixError.stack = error.stack; - } - return cb(matrixError, response, resBody) - } else { - return cb(error, response, resBody); - } - }) - }); - isMatrixClientPatchedForConciseExceptions = true; + if (isMatrixClientPatchedForConciseExceptions) { + return; + } + const originalRequestFn = getRequestFn(); + setRequestFn((params: RequestOptions, cb: typeof originalRequestFn) => { + // Store an error early, to maintain *some* semblance of stack. + // We'll only throw the error if there is one. + const error = new Error("STACK CAPTURE"); + originalRequestFn( + params, + function conciseExceptionRequestFn( + err: RequestError, + response: RequestResponse, + resBody: unknown + ) { + if (!err && (response.statusCode < 200 || response.statusCode >= 300)) { + // Normally, converting HTTP Errors into rejections is done by the caller + // of `requestFn` within matrix-bot-sdk. However, this always ends up rejecting + // with an `IncomingMessage` - exactly what we wish to avoid here. + err = response; + + // Safety note: In the calling code within matrix-bot-sdk, if we return + // an IncomingMessage as an error, we end up logging an unredacted response, + // which may include tokens, passwords, etc. This could be a grave privacy + // leak. The matrix-bot-sdk typically handles this by sanitizing the data + // before logging it but, by converting the HTTP Error into a rejection + // earlier than expected by the matrix-bot-sdk, we skip this step of + // sanitization. + // + // However, since the error we're creating is an `IncomingMessage`, we + // rewrite it into an `Error` ourselves in this function. Our `Error` + // is even more sanitized (we only include the URL, HTTP method and + // the error response) so we are NOT causing a privacy leak. + if (!(err instanceof IncomingMessage)) { + // Safety check. + throw new TypeError( + "Internal error: at this stage, the error should be an IncomingMessage" + ); + } + } + if (!(err instanceof IncomingMessage)) { + // In most cases, we're happy with the result. + return cb(err, response, resBody); + } + // However, MatrixClient has a tendency of throwing + // instances of `IncomingMessage` instead of instances + // of `Error`. The former take ~800 lines of log and + // provide no stack trace, which makes them typically + // useless. + const method: string | undefined = err.method + ? err.method + : "req" in err && err.req instanceof ClientRequest + ? err.req.method + : params.method; + const path: string = err.url + ? err.url + : "req" in err && err.req instanceof ClientRequest + ? err.req.path + : (params.uri ?? ""); + let body: unknown = null; + if ("body" in err) { + body = err.body; + } + // Calling code may use `body` to check for errors, so let's + // make sure that we're providing it. + if (typeof body === "string") { + try { + body = JSON.parse(body, jsonReviver); + } catch (ex) { + // Not JSON. + } + } + const message = `Error during MatrixClient request ${method} ${path}: ${err.statusCode} ${err.statusMessage} -- ${JSON.stringify(body)}`; + error.message = message; + if (body) { + // Define the property but don't make it visible during logging. + Object.defineProperty(error, "body", { + value: body, + enumerable: false, + }); + } + // Calling code may use `statusCode` to check for errors, so let's + // make sure that we're providing it. + if ("statusCode" in err) { + // Define the property but don't make it visible during logging. + Object.defineProperty(error, "statusCode", { + value: err.statusCode, + enumerable: false, + }); + } + // matrix-appservice-bridge depends on errors being matrix-bot-sdk's MatrixError. + // Since https://github.com/turt2live/matrix-bot-sdk/blob/836c2da7145668b20af7e0d75094b6162164f3dc/src/http.ts#L109 + // we wrote this, matrix-bot-sdk has updated so that there is now a MatrixError that is thrown + // when there are errors in the response. + if (isMatrixError(path)) { + const matrixError = new MatrixError( + body as MatrixError["body"], + err.statusCode as number + ); + if (error.stack !== undefined) { + matrixError.stack = error.stack; + } + return cb(matrixError, response, resBody); + } else { + return cb(error, response, resBody); + } + } + ); + }); + isMatrixClientPatchedForConciseExceptions = true; } const MAX_REQUEST_ATTEMPTS = 15; @@ -382,65 +455,81 @@ let isMatrixClientPatchedForRetryWhenThrottled = false; * in case of DoS, we do not wish to make the situation even worse. */ function patchMatrixClientForRetry() { - if (isMatrixClientPatchedForRetryWhenThrottled) { - return; - } - const originalRequestFn = getRequestFn(); - setRequestFn(async (params: RequestOptions, cb: typeof originalRequestFn) => { - let attempt = 1; - while (true) { - try { - const result: [RequestError, RequestResponse, unknown] = await new Promise((resolve, reject) => { - originalRequestFn(params, function requestFnWithRetry( - err: RequestError, response: RequestResponse, resBody: unknown - ) { - // Note: There is no data race on `attempt` as we `await` before continuing - // to the next iteration of the loop. - if (attempt < MAX_REQUEST_ATTEMPTS && err?.body?.errcode === 'M_LIMIT_EXCEEDED') { - // We need to retry. - // We're not able to refactor away from thsis now, pretty unfortunatley. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(err); - } else { - if (attempt >= MAX_REQUEST_ATTEMPTS) { - LogService.warn('Mjolnir.client', `Retried request ${params.method} ${params.uri} ${attempt} times, giving up.`); - } - // No need-to-retry error? Lucky us! - // Note that this may very well be an error, just not - // one we need to retry. - resolve([err, response, resBody]); - } - }); - }); - // This is our final result. - // Pass result, whether success or error. - return cb(...result); - } catch (err) { - // Need to retry. - let retryAfterMs = attempt * attempt * REQUEST_RETRY_BASE_DURATION_MS; - if ("retry_after_ms" in err) { - try { - retryAfterMs = Number.parseInt(err.retry_after_ms, 10); - } catch (ex) { - // Use default value. - } - } - LogService.debug("Mjolnir.client", `Waiting ${retryAfterMs}ms before retrying ${params.method} ${params.uri}`); - await new Promise(resolve => setTimeout(resolve, retryAfterMs)); - attempt += 1; + if (isMatrixClientPatchedForRetryWhenThrottled) { + return; + } + const originalRequestFn = getRequestFn(); + setRequestFn(async (params: RequestOptions, cb: typeof originalRequestFn) => { + let attempt = 1; + while (true) { + try { + const result: [RequestError, RequestResponse, unknown] = + await new Promise((resolve, reject) => { + originalRequestFn( + params, + function requestFnWithRetry( + err: RequestError, + response: RequestResponse, + resBody: unknown + ) { + // Note: There is no data race on `attempt` as we `await` before continuing + // to the next iteration of the loop. + if ( + attempt < MAX_REQUEST_ATTEMPTS && + err?.body?.errcode === "M_LIMIT_EXCEEDED" + ) { + // We need to retry. + // We're not able to refactor away from thsis now, pretty unfortunatley. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(err); + } else { + if (attempt >= MAX_REQUEST_ATTEMPTS) { + LogService.warn( + "Mjolnir.client", + `Retried request ${params.method} ${params.uri} ${attempt} times, giving up.` + ); + } + // No need-to-retry error? Lucky us! + // Note that this may very well be an error, just not + // one we need to retry. + resolve([err, response, resBody]); } - }}); - isMatrixClientPatchedForRetryWhenThrottled = true; + } + ); + }); + // This is our final result. + // Pass result, whether success or error. + return cb(...result); + } catch (err) { + // Need to retry. + let retryAfterMs = attempt * attempt * REQUEST_RETRY_BASE_DURATION_MS; + if ("retry_after_ms" in err) { + try { + retryAfterMs = Number.parseInt(err.retry_after_ms, 10); + } catch (ex) { + // Use default value. + } + } + LogService.debug( + "Mjolnir.client", + `Waiting ${retryAfterMs}ms before retrying ${params.method} ${params.uri}` + ); + await new Promise((resolve) => setTimeout(resolve, retryAfterMs)); + attempt += 1; + } + } + }); + isMatrixClientPatchedForRetryWhenThrottled = true; } let isMatrixClientPatchedForPrototypePollution = false; export function jsonReviver(key: string, value: T): T | undefined { - if (key === '__proto__' || key === 'constructor') { - return undefined; - } else { - return value; - } + if (key === "__proto__" || key === "constructor") { + return undefined; + } else { + return value; + } } /** @@ -449,52 +538,57 @@ export function jsonReviver(key: string, value: T): T | undefined { * The following is an inefficient workaround, but you gotta do what you can. */ function patchMatrixClientForPrototypePollution() { - if (isMatrixClientPatchedForPrototypePollution) { - return; - } - const originalRequestFn = getRequestFn(); - setRequestFn((params: RequestOptions, cb: typeof originalRequestFn) => { - originalRequestFn(params, function conciseExceptionRequestFn( - error: RequestError, response: RequestResponse, resBody: unknown - ) { - // https://github.com/turt2live/matrix-bot-sdk/blob/c7d16776502c26bbb547a3d667ec92eb50e7026c/src/http.ts#L77-L101 - // bring forwards this step and do it safely. - if (typeof resBody === 'string') { - try { - resBody = JSON.parse(resBody, jsonReviver); - } catch (e) { - // we don't care if we fail to parse the JSON as it probably isn't JSON. - } - } + if (isMatrixClientPatchedForPrototypePollution) { + return; + } + const originalRequestFn = getRequestFn(); + setRequestFn((params: RequestOptions, cb: typeof originalRequestFn) => { + originalRequestFn( + params, + function conciseExceptionRequestFn( + error: RequestError, + response: RequestResponse, + resBody: unknown + ) { + // https://github.com/turt2live/matrix-bot-sdk/blob/c7d16776502c26bbb547a3d667ec92eb50e7026c/src/http.ts#L77-L101 + // bring forwards this step and do it safely. + if (typeof resBody === "string") { + try { + resBody = JSON.parse(resBody, jsonReviver); + } catch (e) { + // we don't care if we fail to parse the JSON as it probably isn't JSON. + } + } - if (typeof response.body === 'string') { - try { - response.body = JSON.parse(response.body, jsonReviver); - } catch (e) { - // we don't care if we fail to parse the JSON as it probably isn't JSON. - } - } - return cb(error, response, resBody); - }) - }); - isMatrixClientPatchedForPrototypePollution = true; + if (typeof response.body === "string") { + try { + response.body = JSON.parse(response.body, jsonReviver); + } catch (e) { + // we don't care if we fail to parse the JSON as it probably isn't JSON. + } + } + return cb(error, response, resBody); + } + ); + }); + isMatrixClientPatchedForPrototypePollution = true; } /** * Perform any patching deemed necessary to MatrixClient. */ export function patchMatrixClient() { - // Note that the order of patches is meaningful. - // - // - `patchMatrixClientForPrototypePollution` converts all JSON bodies to safe JSON before client code can - // parse and use the JSON inappropriately. - // - `patchMatrixClientForConciseExceptions` converts all `IncomingMessage` - // errors into instances of `Error` handled as errors; - // - `patchMatrixClientForRetry` expects that all errors are returned as - // errors. - patchMatrixClientForPrototypePollution(); - patchMatrixClientForConciseExceptions(); - patchMatrixClientForRetry(); + // Note that the order of patches is meaningful. + // + // - `patchMatrixClientForPrototypePollution` converts all JSON bodies to safe JSON before client code can + // parse and use the JSON inappropriately. + // - `patchMatrixClientForConciseExceptions` converts all `IncomingMessage` + // errors into instances of `Error` handled as errors; + // - `patchMatrixClientForRetry` expects that all errors are returned as + // errors. + patchMatrixClientForPrototypePollution(); + patchMatrixClientForConciseExceptions(); + patchMatrixClientForRetry(); } patchMatrixClient(); @@ -506,18 +600,18 @@ patchMatrixClient(); * should not be used, it does nothing. */ export function initializeSentry(config: IConfig) { - if (sentryInitialized) { - return; - } - if (config.health.sentry) { - // Configure error monitoring with Sentry. - const sentry = config.health.sentry; - Sentry.init({ - dsn: sentry.dsn, - tracesSampleRate: sentry.tracesSampleRate, - }); - sentryInitialized = true; - } + if (sentryInitialized) { + return; + } + if (config.health.sentry) { + // Configure error monitoring with Sentry. + const sentry = config.health.sentry; + Sentry.init({ + dsn: sentry.dsn, + tracesSampleRate: sentry.tracesSampleRate, + }); + sentryInitialized = true; + } } // Set to `true` once we have initialized `Sentry` to ensure // that we do not attempt to initialize it more than once. diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 0c0322b8..d75e8418 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -32,7 +32,6 @@ import { ReportManager } from "../report/ReportManager"; import { IConfig } from "../config"; import { StringEventID, StringRoomID, Task } from "matrix-protection-suite"; - /** * A common prefix for all web-exposed APIs. */ @@ -41,160 +40,207 @@ const API_PREFIX = "/api/1"; const AUTHORIZATION = new RegExp("Bearer (.*)"); export class WebAPIs { - private webController: express.Express = express(); - private httpServer?: Server | undefined; - - constructor(private reportManager: ReportManager, private readonly config: IConfig) { - // Setup JSON parsing. - this.webController.use(express.json()); + private webController: express.Express = express(); + private httpServer?: Server | undefined; + + constructor( + private reportManager: ReportManager, + private readonly config: IConfig + ) { + // Setup JSON parsing. + this.webController.use(express.json()); + } + + /** + * Start accepting requests to the Web API. + */ + public async start() { + if (!this.config.web.enabled) { + return; } - - /** - * Start accepting requests to the Web API. - */ - public async start() { - if (!this.config.web.enabled) { - return; + this.httpServer = this.webController.listen( + this.config.web.port, + this.config.web.address + ); + + // configure /report API. + if (this.config.web.abuseReporting.enabled) { + console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id...`); + this.webController.options( + `${API_PREFIX}/report/:room_id/:event_id`, + (request, response) => { + // reply with CORS options + response.header("Access-Control-Allow-Origin", "*"); + response.header( + "Access-Control-Allow-Headers", + "X-Requested-With, Content-Type, Authorization, Date" + ); + response.header("Access-Control-Allow-Methods", "POST, OPTIONS"); + response.status(200); + return response.send(); } - this.httpServer = this.webController.listen(this.config.web.port, this.config.web.address); - - // configure /report API. - if (this.config.web.abuseReporting.enabled) { - console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id...`); - this.webController.options(`${API_PREFIX}/report/:room_id/:event_id`, (request, response) => { - // reply with CORS options - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization, Date"); - response.header("Access-Control-Allow-Methods", "POST, OPTIONS"); - response.status(200); - return response.send(); - }); - this.webController.post(`${API_PREFIX}/report/:room_id/:event_id`, (request, response) => { - console.debug(`Received a message on ${API_PREFIX}/report/:room_id/:event_id`, request.params); - // set CORS headers for the response - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization, Date"); - response.header("Access-Control-Allow-Methods", "POST, OPTIONS"); - void Task(this.handleReport({ request, response, roomID: request.params.room_id as StringRoomID, eventID: request.params.event_id as StringEventID })); - }); - console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`); + ); + this.webController.post( + `${API_PREFIX}/report/:room_id/:event_id`, + (request, response) => { + console.debug( + `Received a message on ${API_PREFIX}/report/:room_id/:event_id`, + request.params + ); + // set CORS headers for the response + response.header("Access-Control-Allow-Origin", "*"); + response.header( + "Access-Control-Allow-Headers", + "X-Requested-With, Content-Type, Authorization, Date" + ); + response.header("Access-Control-Allow-Methods", "POST, OPTIONS"); + void Task( + this.handleReport({ + request, + response, + roomID: request.params.room_id as StringRoomID, + eventID: request.params.event_id as StringEventID, + }) + ); } + ); + console.log( + `configuring ${API_PREFIX}/report/:room_id/:event_id... DONE` + ); } + } - public stop() { - if (this.httpServer) { - console.log("Stopping WebAPIs."); - this.httpServer.close(); - this.httpServer = undefined; - } + public stop() { + if (this.httpServer) { + console.log("Stopping WebAPIs."); + this.httpServer.close(); + this.httpServer = undefined; } + } + + /** + * Handle a call to the /report API. + * + * In case of success, respond an empty JSON body. + * + * @param roomId The room in which the reported event took place. Already extracted from the URL. + * @param eventId The event. Already extracted from the URL. + * @param request The request. Its body SHOULD hold an object `{reason?: string}` + * @param response The response. Used to propagate HTTP success/error. + */ + async handleReport({ + roomID, + eventID, + request, + response, + }: { + roomID: StringRoomID; + eventID: StringEventID; + request: express.Request; + response: express.Response; + }) { + // To display any kind of useful information, we need + // + // 1. The reporter id; + // 2. The accused id, to be able to warn/kick/ban them if necessary; + // 3. The content of the event **if the room is unencrypted**. + + try { + let reporterId; + let event; + { + // -- Create a client on behalf of the reporter. + // We'll use it to confirm the authenticity of the report. + let accessToken: string | undefined = undefined; + + // Authentication mechanism 1: Request header. + const authorization = request.get("Authorization"); + + if (authorization) { + const brearerMatch = AUTHORIZATION.exec(authorization); + if (brearerMatch === null) { + response.status(401).send("Missing access token"); + return; + } else { + [, accessToken] = brearerMatch; + } + } else if (typeof request.query["access_token"] === "string") { + // Authentication mechanism 2: Access token as query parameter. + accessToken = request.query["access_token"]; + } + if (accessToken === undefined) { + response.status(401).send("Missing access token"); + return; + } - /** - * Handle a call to the /report API. - * - * In case of success, respond an empty JSON body. - * - * @param roomId The room in which the reported event took place. Already extracted from the URL. - * @param eventId The event. Already extracted from the URL. - * @param request The request. Its body SHOULD hold an object `{reason?: string}` - * @param response The response. Used to propagate HTTP success/error. - */ - async handleReport({ roomID, eventID, request, response }: { roomID: StringRoomID, eventID: StringEventID, request: express.Request, response: express.Response }) { - // To display any kind of useful information, we need + // Create a client dedicated to this report. // - // 1. The reporter id; - // 2. The accused id, to be able to warn/kick/ban them if necessary; - // 3. The content of the event **if the room is unencrypted**. - - try { - let reporterId; - let event; - { - // -- Create a client on behalf of the reporter. - // We'll use it to confirm the authenticity of the report. - let accessToken: string | undefined = undefined; - - // Authentication mechanism 1: Request header. - const authorization = request.get('Authorization'); - - if (authorization) { - const brearerMatch = AUTHORIZATION.exec(authorization); - if (brearerMatch === null) { - response.status(401).send("Missing access token"); - return; - } else { - [, accessToken] = brearerMatch; - } - } else if (typeof(request.query["access_token"]) === 'string') { - // Authentication mechanism 2: Access token as query parameter. - accessToken = request.query["access_token"]; - } - if (accessToken === undefined) { - response.status(401).send("Missing access token"); - return; - } - - - // Create a client dedicated to this report. - // - // VERY IMPORTANT NOTES - // - // We're impersonating the user to get the context of the report. - // - // For privacy's sake, we MUST ensure that: - // - // - we DO NOT sync with this client, as this would let us - // snoop on messages other than the context of the report; - // - we DO NOT associate a crypto store (e.g. Pantalaimon), - // as this would let us read encrypted messages; - // - this client is torn down as soon as possible to avoid - // any case in which it could somehow be abused if a - // malicious third-party gains access to Mjölnir. - // - // Rationales for using this mechanism: - // - // 1. This /report interception feature can only be setup by someone - // who already controls the server. In other words, if they wish - // to snoop on unencrypted messages, they can already do it more - // easily at the level of the proxy. - // 2. The `reporterClient` is used only to provide - // - identity-checking; and - // - features that are already available in the Synapse Admin API - // (possibly in the Admin APIs of other homeservers, I haven't checked) - // so we are not extending the abilities of Mjölnir - // 3. We are avoiding the use of the Synapse Admin API to ensure that - // this feature can work with all homeservers, not just Synapse. - const reporterClient = new MatrixClient(this.config.rawHomeserverUrl, accessToken); - reporterClient.start = () => { - throw new Error("We MUST NEVER call start on the reporter client"); - }; - - reporterId = await reporterClient.getUserId(); - - /* + // VERY IMPORTANT NOTES + // + // We're impersonating the user to get the context of the report. + // + // For privacy's sake, we MUST ensure that: + // + // - we DO NOT sync with this client, as this would let us + // snoop on messages other than the context of the report; + // - we DO NOT associate a crypto store (e.g. Pantalaimon), + // as this would let us read encrypted messages; + // - this client is torn down as soon as possible to avoid + // any case in which it could somehow be abused if a + // malicious third-party gains access to Mjölnir. + // + // Rationales for using this mechanism: + // + // 1. This /report interception feature can only be setup by someone + // who already controls the server. In other words, if they wish + // to snoop on unencrypted messages, they can already do it more + // easily at the level of the proxy. + // 2. The `reporterClient` is used only to provide + // - identity-checking; and + // - features that are already available in the Synapse Admin API + // (possibly in the Admin APIs of other homeservers, I haven't checked) + // so we are not extending the abilities of Mjölnir + // 3. We are avoiding the use of the Synapse Admin API to ensure that + // this feature can work with all homeservers, not just Synapse. + const reporterClient = new MatrixClient( + this.config.rawHomeserverUrl, + accessToken + ); + reporterClient.start = () => { + throw new Error("We MUST NEVER call start on the reporter client"); + }; + + reporterId = await reporterClient.getUserId(); + + /* Past this point, the following invariants hold: - The report was sent by a Matrix user. - The identity of the Matrix user who sent the report is stored in `reporterId`. */ - // Now, let's gather more info on the event. - // IMPORTANT: The following call will return the event without decyphering it, so we're - // not obtaining anything that we couldn't also obtain through a homeserver's Admin API. - // - // By doing this with the reporterClient, we ensure that this feature of Mjölnir can work - // with all Matrix homeservers, rather than just Synapse. - event = await reporterClient.getEvent(roomID, eventID); - } - - const reason = request.body["reason"]; - await this.reportManager.handleServerAbuseReport({ roomID, reporterId, event, reason }); - - // Match the spec behavior of `/report`: return 200 and an empty JSON. - response.status(200).json({}); - } catch (ex) { - console.warn("Error responding to an abuse report", roomID, eventID, ex); - response.status(503); - } + // Now, let's gather more info on the event. + // IMPORTANT: The following call will return the event without decyphering it, so we're + // not obtaining anything that we couldn't also obtain through a homeserver's Admin API. + // + // By doing this with the reporterClient, we ensure that this feature of Mjölnir can work + // with all Matrix homeservers, rather than just Synapse. + event = await reporterClient.getEvent(roomID, eventID); + } + + const reason = request.body["reason"]; + await this.reportManager.handleServerAbuseReport({ + roomID, + reporterId, + event, + reason, + }); + + // Match the spec behavior of `/report`: return 200 and an empty JSON. + response.status(200).json({}); + } catch (ex) { + console.warn("Error responding to an abuse report", roomID, eventID, ex); + response.status(503); } + } } diff --git a/test/appservice/integration/listUnstartedDraupnirTest.ts b/test/appservice/integration/listUnstartedDraupnirTest.ts index 9292d9bb..40b6e2b2 100644 --- a/test/appservice/integration/listUnstartedDraupnirTest.ts +++ b/test/appservice/integration/listUnstartedDraupnirTest.ts @@ -5,31 +5,31 @@ import { setupHarness } from "../utils/harness"; import { isError } from "matrix-protection-suite"; interface Context extends Mocha.Context { - appservice?: MjolnirAppService + appservice?: MjolnirAppService; } -describe("Just test some commands innit", function() { - beforeEach(async function(this: Context) { - this.appservice = await setupHarness(); - }); - afterEach(function(this: Context) { - if (this.appservice) { - return this.appservice.close(); - } else { - console.warn("Missing Appservice in this context, so cannot stop it.") - return Promise.resolve(); // TS7030: Not all code paths return a value. - } - }); - it("Can list any unstarted draupnir", async function(this: Context) { - const appservice = this.appservice; - if (appservice === undefined) { - throw new TypeError(`Test setup failed`); - } - const commandClient = new AppservideBotCommandClient(appservice); - const result = await commandClient.sendCommand("list", "unstarted"); - if (isError(result)) { - throw new TypeError(`Command should have succeeded`); - } - expect(result.ok).toBeInstanceOf(Array); - }); -}) +describe("Just test some commands innit", function () { + beforeEach(async function (this: Context) { + this.appservice = await setupHarness(); + }); + afterEach(function (this: Context) { + if (this.appservice) { + return this.appservice.close(); + } else { + console.warn("Missing Appservice in this context, so cannot stop it."); + return Promise.resolve(); // TS7030: Not all code paths return a value. + } + }); + it("Can list any unstarted draupnir", async function (this: Context) { + const appservice = this.appservice; + if (appservice === undefined) { + throw new TypeError(`Test setup failed`); + } + const commandClient = new AppservideBotCommandClient(appservice); + const result = await commandClient.sendCommand("list", "unstarted"); + if (isError(result)) { + throw new TypeError(`Command should have succeeded`); + } + expect(result.ok).toBeInstanceOf(Array); + }); +}); diff --git a/test/appservice/integration/provisionTest.ts b/test/appservice/integration/provisionTest.ts index 20e29850..66b88653 100644 --- a/test/appservice/integration/provisionTest.ts +++ b/test/appservice/integration/provisionTest.ts @@ -5,51 +5,63 @@ import { MatrixClient } from "matrix-bot-sdk"; import { MjolnirAppService } from "../../../src/appservice/AppService"; interface Context extends Mocha.Context { - moderator?: MatrixClient, - appservice?: MjolnirAppService | undefined + moderator?: MatrixClient; + appservice?: MjolnirAppService | undefined; } describe("Test that the app service can provision a mjolnir on invite of the appservice bot", function () { - afterEach(function(this: Context) { - this.moderator?.stop(); - if (this.appservice) { - return this.appservice.close(); - } else { - console.warn("Missing Appservice in this context, so cannot stop it.") - return Promise.resolve(); // TS7030: Not all code paths return a value. - } + afterEach(function (this: Context) { + this.moderator?.stop(); + if (this.appservice) { + return this.appservice.close(); + } else { + console.warn("Missing Appservice in this context, so cannot stop it."); + return Promise.resolve(); // TS7030: Not all code paths return a value. + } + }); + it("A moderator that requests a mjolnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) { + const config = readTestConfig(); + this.appservice = await setupHarness(); + const appservice = this.appservice; + // create a user to act as the moderator + const moderator = await newTestUser(config.homeserver.url, { + name: { contains: "test" }, }); - it("A moderator that requests a mjolnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) { - const config = readTestConfig(); - this.appservice = await setupHarness(); - const appservice = this.appservice; - // create a user to act as the moderator - const moderator = await newTestUser(config.homeserver.url, { name: { contains: "test" } }); - const roomWeWantProtecting = await moderator.createRoom(); - // have the moderator invite the appservice bot in order to request a new mjolnir - this.moderator = moderator; - const roomsInvitedTo: string[] = []; - await new Promise(resolve => { - void (async () => { - moderator.on('room.invite', (roomId: string) => { - roomsInvitedTo.push(roomId) - // the appservice should invite the moderator to a policy room and a management room. - if (roomsInvitedTo.length === 2) { - resolve(null); - } - }); - await moderator.start(); - await moderator.inviteUser(appservice.bridge.getBot().getUserId(), roomWeWantProtecting); - })(); + const roomWeWantProtecting = await moderator.createRoom(); + // have the moderator invite the appservice bot in order to request a new mjolnir + this.moderator = moderator; + const roomsInvitedTo: string[] = []; + await new Promise((resolve) => { + void (async () => { + moderator.on("room.invite", (roomId: string) => { + roomsInvitedTo.push(roomId); + // the appservice should invite the moderator to a policy room and a management room. + if (roomsInvitedTo.length === 2) { + resolve(null); + } }); - await Promise.all(roomsInvitedTo.map(roomId => moderator.joinRoom(roomId))); - const managementRoomId = roomsInvitedTo.filter(async roomId => !(await isPolicyRoom(moderator, roomId)))[0]; - if (managementRoomId === undefined) { - throw new TypeError(`Unable to find management room`); - } - // Check that the newly provisioned mjolnir is actually responsive. - await getFirstReply(moderator, managementRoomId, () => { - return moderator.sendMessage(managementRoomId, { body: `!draupnir status`, msgtype: 'm.text' }); - }) - }) -}) + await moderator.start(); + await moderator.inviteUser( + appservice.bridge.getBot().getUserId(), + roomWeWantProtecting + ); + })(); + }); + await Promise.all( + roomsInvitedTo.map((roomId) => moderator.joinRoom(roomId)) + ); + const managementRoomId = roomsInvitedTo.filter( + async (roomId) => !(await isPolicyRoom(moderator, roomId)) + )[0]; + if (managementRoomId === undefined) { + throw new TypeError(`Unable to find management room`); + } + // Check that the newly provisioned mjolnir is actually responsive. + await getFirstReply(moderator, managementRoomId, () => { + return moderator.sendMessage(managementRoomId, { + body: `!draupnir status`, + msgtype: "m.text", + }); + }); + }); +}); diff --git a/test/appservice/integration/webAPITest.ts b/test/appservice/integration/webAPITest.ts index 5283daf9..676007ae 100644 --- a/test/appservice/integration/webAPITest.ts +++ b/test/appservice/integration/webAPITest.ts @@ -1,61 +1,79 @@ import { MjolnirAppService } from "../../../src/appservice/AppService"; import { newTestUser } from "../../integration/clientHelper"; import { isPolicyRoom, readTestConfig, setupHarness } from "../utils/harness"; -import { CreateMjolnirResponse, MjolnirWebAPIClient } from "../utils/webAPIClient"; +import { + CreateMjolnirResponse, + MjolnirWebAPIClient, +} from "../utils/webAPIClient"; import { MatrixClient } from "matrix-bot-sdk"; import { getFirstReply } from "../../integration/commands/commandUtils"; import expect from "expect"; - interface Context extends Mocha.Context { - appservice?: MjolnirAppService - moderator?: MatrixClient + appservice?: MjolnirAppService; + moderator?: MatrixClient; } - describe("Test that the app service can provision a mjolnir when requested from the web API", function () { - afterEach(function(this: Context) { - this.moderator?.stop(); - if (this.appservice) { - return this.appservice.close(); - } else { - console.warn("Missing Appservice in this context, so cannot stop it.") - return Promise.resolve(); // TS7030: Not all code paths return a value. - } + afterEach(function (this: Context) { + this.moderator?.stop(); + if (this.appservice) { + return this.appservice.close(); + } else { + console.warn("Missing Appservice in this context, so cannot stop it."); + return Promise.resolve(); // TS7030: Not all code paths return a value. + } + }); + it("A moderator that requests a mjolnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) { + const config = readTestConfig(); + this.appservice = await setupHarness(); + // create a moderator + const moderator = await newTestUser(config.homeserver.url, { + name: { contains: "test" }, + }); + const apiClient = await MjolnirWebAPIClient.makeClient( + moderator, + "http://localhost:9001" + ); + const roomToProtectId = await moderator.createRoom({ + preset: "public_chat", }); - it("A moderator that requests a mjolnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) { - const config = readTestConfig(); - this.appservice = await setupHarness(); - // create a moderator - const moderator = await newTestUser(config.homeserver.url, { name: { contains: "test" } }); - const apiClient = await MjolnirWebAPIClient.makeClient(moderator, "http://localhost:9001"); - const roomToProtectId = await moderator.createRoom({ preset: "public_chat" }); - // have the moderator invite the appservice bot in order to request a new mjolnir - this.moderator = moderator; - const roomsInvitedTo: string[] = []; - const mjolnirDetails: CreateMjolnirResponse = await new Promise(resolve => { - void (async () => { - const mjolnirDetailsPromise = apiClient.createMjolnir(roomToProtectId); - moderator.on('room.invite', (roomId: string) => { - roomsInvitedTo.push(roomId) - // the appservice should invite it to a policy room and a management room. - if (roomsInvitedTo.length === 2) { - void mjolnirDetailsPromise.then(resolve); - } - }); - await moderator.start(); - })(); - }); - await Promise.all(roomsInvitedTo.map(roomId => moderator.joinRoom(roomId))); - const managementRoomId = roomsInvitedTo.filter(async roomId => !(await isPolicyRoom(moderator, roomId)))[0]; - if (managementRoomId !== mjolnirDetails.managementRoomId) { - throw new TypeError(`Unable to find the management room`); - } - // Check that the newly provisioned mjolnir is actually responsive. - const event = await getFirstReply(moderator, managementRoomId, () => { - return moderator.sendMessage(managementRoomId, { body: `!draupnir status`, msgtype: 'm.text' }); - }) - expect(event.sender).toBe(mjolnirDetails.mjolnirUserId); - }) -}) + // have the moderator invite the appservice bot in order to request a new mjolnir + this.moderator = moderator; + const roomsInvitedTo: string[] = []; + const mjolnirDetails: CreateMjolnirResponse = await new Promise( + (resolve) => { + void (async () => { + const mjolnirDetailsPromise = + apiClient.createMjolnir(roomToProtectId); + moderator.on("room.invite", (roomId: string) => { + roomsInvitedTo.push(roomId); + // the appservice should invite it to a policy room and a management room. + if (roomsInvitedTo.length === 2) { + void mjolnirDetailsPromise.then(resolve); + } + }); + await moderator.start(); + })(); + } + ); + await Promise.all( + roomsInvitedTo.map((roomId) => moderator.joinRoom(roomId)) + ); + const managementRoomId = roomsInvitedTo.filter( + async (roomId) => !(await isPolicyRoom(moderator, roomId)) + )[0]; + if (managementRoomId !== mjolnirDetails.managementRoomId) { + throw new TypeError(`Unable to find the management room`); + } + // Check that the newly provisioned mjolnir is actually responsive. + const event = await getFirstReply(moderator, managementRoomId, () => { + return moderator.sendMessage(managementRoomId, { + body: `!draupnir status`, + msgtype: "m.text", + }); + }); + expect(event.sender).toBe(mjolnirDetails.mjolnirUserId); + }); +}); diff --git a/test/appservice/utils/AppserviceBotCommandClient.ts b/test/appservice/utils/AppserviceBotCommandClient.ts index b11055c3..5c83c30f 100644 --- a/test/appservice/utils/AppserviceBotCommandClient.ts +++ b/test/appservice/utils/AppserviceBotCommandClient.ts @@ -5,16 +5,22 @@ import { findCommandTable } from "../../../src/commands/interface-manager/Interf import { ArgumentStream } from "../../../src/commands/interface-manager/ParameterParsing"; export class AppservideBotCommandClient { - constructor(private readonly appservice: MjolnirAppService) { + constructor(private readonly appservice: MjolnirAppService) {} + public async sendCommand>( + ...items: ReadItem[] + ): Promise { + const stream = new ArgumentStream(items); + const matchingCommand = + findCommandTable("appservice bot").findAMatchingCommand(stream); + if (!matchingCommand) { + throw new TypeError( + `Couldn't finnd a command from these items ${JSON.stringify(items)}` + ); } - - public async sendCommand>(...items: ReadItem[]): Promise { - const stream = new ArgumentStream(items); - const matchingCommand = findCommandTable("appservice bot").findAMatchingCommand(stream); - if (!matchingCommand) { - throw new TypeError(`Couldn't finnd a command from these items ${JSON.stringify(items)}`); - } - return await matchingCommand.parseThenInvoke({ appservice: this.appservice }, stream) as CommandReturnType; - } + return (await matchingCommand.parseThenInvoke( + { appservice: this.appservice }, + stream + )) as CommandReturnType; + } } diff --git a/test/appservice/utils/harness.ts b/test/appservice/utils/harness.ts index dc735e9d..3df33601 100644 --- a/test/appservice/utils/harness.ts +++ b/test/appservice/utils/harness.ts @@ -1,23 +1,35 @@ import path from "path"; import { MjolnirAppService } from "../../../src/appservice/AppService"; import { ensureAliasedRoomExists } from "../../integration/mjolnirSetupUtils"; -import { read as configRead, IConfig } from "../../../src/appservice/config/config"; +import { + read as configRead, + IConfig, +} from "../../../src/appservice/config/config"; import { newTestUser } from "../../integration/clientHelper"; import { CreateEvent, MatrixClient } from "matrix-bot-sdk"; import { POLICY_ROOM_TYPE_VARIANTS } from "matrix-protection-suite"; export function readTestConfig(): IConfig { - return configRead(path.join(__dirname, "../../../src/appservice/config/config.harness.yaml")); + return configRead( + path.join(__dirname, "../../../src/appservice/config/config.harness.yaml") + ); } export async function setupHarness(): Promise { - const config = readTestConfig(); - const utilityUser = await newTestUser(config.homeserver.url, { name: { contains: "utility" }}); - await ensureAliasedRoomExists(utilityUser, config.adminRoom); - return await MjolnirAppService.run(9000, config, "mjolnir-registration.yaml"); + const config = readTestConfig(); + const utilityUser = await newTestUser(config.homeserver.url, { + name: { contains: "utility" }, + }); + await ensureAliasedRoomExists(utilityUser, config.adminRoom); + return await MjolnirAppService.run(9000, config, "mjolnir-registration.yaml"); } -export async function isPolicyRoom(user: MatrixClient, roomId: string): Promise { - const createEvent = new CreateEvent(await user.getRoomStateEvent(roomId, "m.room.create", "")); - return POLICY_ROOM_TYPE_VARIANTS.includes(createEvent.type); +export async function isPolicyRoom( + user: MatrixClient, + roomId: string +): Promise { + const createEvent = new CreateEvent( + await user.getRoomStateEvent(roomId, "m.room.create", "") + ); + return POLICY_ROOM_TYPE_VARIANTS.includes(createEvent.type); } diff --git a/test/appservice/utils/webAPIClient.ts b/test/appservice/utils/webAPIClient.ts index 3519f36f..157edfa9 100644 --- a/test/appservice/utils/webAPIClient.ts +++ b/test/appservice/utils/webAPIClient.ts @@ -2,56 +2,71 @@ import * as request from "request"; import { MatrixClient } from "matrix-bot-sdk"; interface OpenIDTokenInfo { - access_token: string, - expires_in: number, - matrix_server_name: string, - token_type: string + access_token: string; + expires_in: number; + matrix_server_name: string; + token_type: string; } async function getOpenIDToken(client: MatrixClient): Promise { - const tokenInfo: OpenIDTokenInfo = await client.doRequest("POST", `/_matrix/client/v3/user/${await client.getUserId()}/openid/request_token`, undefined, {}); - return tokenInfo.access_token; + const tokenInfo: OpenIDTokenInfo = await client.doRequest( + "POST", + `/_matrix/client/v3/user/${await client.getUserId()}/openid/request_token`, + undefined, + {} + ); + return tokenInfo.access_token; } export interface CreateMjolnirResponse { - mjolnirUserId: string, - managementRoomId: string, + mjolnirUserId: string; + managementRoomId: string; } export class MjolnirWebAPIClient { + private constructor( + private readonly openIDToken: string, + private readonly baseURL: string + ) {} - private constructor( - private readonly openIDToken: string, - private readonly baseURL: string, - ) { + public static async makeClient( + client: MatrixClient, + baseUrl: string + ): Promise { + const token = await getOpenIDToken(client); + return new MjolnirWebAPIClient(token, baseUrl); + } - } - - public static async makeClient(client: MatrixClient, baseUrl: string): Promise { - const token = await getOpenIDToken(client); - return new MjolnirWebAPIClient(token, baseUrl); - } - - public async createMjolnir(roomToProtectId: string): Promise { - const body: { mxid: string, roomId: string } = await new Promise((resolve, reject) => { - request.post(`${this.baseURL}/create`, { - json: { - openId: this.openIDToken, - roomId: roomToProtectId, - }, - }, (error, response) => { - if (error === null || error === undefined) { - resolve(response.body); - } else if (error instanceof Error) { - reject(error); - } else { - reject(new TypeError(`Someone is throwing things that aren't errors`)); - } - }); - }); - return { - mjolnirUserId: body.mxid, - managementRoomId: body.roomId - } - } + public async createMjolnir( + roomToProtectId: string + ): Promise { + const body: { mxid: string; roomId: string } = await new Promise( + (resolve, reject) => { + request.post( + `${this.baseURL}/create`, + { + json: { + openId: this.openIDToken, + roomId: roomToProtectId, + }, + }, + (error, response) => { + if (error === null || error === undefined) { + resolve(response.body); + } else if (error instanceof Error) { + reject(error); + } else { + reject( + new TypeError(`Someone is throwing things that aren't errors`) + ); + } + } + ); + } + ); + return { + mjolnirUserId: body.mxid, + managementRoomId: body.roomId, + }; + } } diff --git a/test/commands/CommandReaderTest.ts b/test/commands/CommandReaderTest.ts index 35b55586..0abf5dcf 100644 --- a/test/commands/CommandReaderTest.ts +++ b/test/commands/CommandReaderTest.ts @@ -1,54 +1,59 @@ import expect from "expect"; -import { Keyword, readCommand } from "../../src/commands/interface-manager/CommandReader"; +import { + Keyword, + readCommand, +} from "../../src/commands/interface-manager/CommandReader"; import { MatrixRoomAlias, MatrixRoomID, UserID } from "matrix-protection-suite"; -describe("Can read", function() { - it("Can read a simple command with only strings", function() { - const command = "!mjolnir list rooms"; - const readItems = readCommand(command); - expect(readItems.every(item => command.includes(item as string))).toBe(true); - }); - it("Can turn room aliases to room references", function() { - const command = "#meow:example.org"; - const readItems = readCommand(command); - expect(readItems.at(0)).toBeInstanceOf(MatrixRoomAlias); - const roomReference = readItems.at(0) as MatrixRoomAlias; - expect(roomReference.toRoomIDOrAlias()).toBe(command); - }); - it("Can turn room ids to room references", function() { - const command = "!foijoiejfoij:example.org"; - const readItems = readCommand(command); - expect(readItems.at(0)).toBeInstanceOf(MatrixRoomID); - const roomReference = readItems.at(0) as MatrixRoomID; - expect(roomReference.toRoomIDOrAlias()).toBe(command); - }); - it("Can read keywords and correctly parse their designators", function() { - const checkKeyword = (designator: string, keyword: string) => { - const readItems = readCommand(keyword); - expect(readItems.at(0)).toBeInstanceOf(Keyword); - const keywordItem = readItems.at(0) as Keyword; - expect(keywordItem.designator).toBe(designator); - } - checkKeyword("foo", "--foo"); - checkKeyword("foo", "-foo"); - checkKeyword("f", "-f"); - checkKeyword("foo", ":foo"); - checkKeyword("f", ":f"); - }); - it("Check that malformed room ids and aliases are read as strings", function() { - // We leave it for the command to validate the arguments it receives intentionally. - // From the perspective of the user, their command will fail just as early but with more context this way. - const checkMalformedRoomReference = (badReference: string) => { - expect(readCommand(badReference).at(0)).toBe(badReference); - } - checkMalformedRoomReference("#singasongaboutlife"); - checkMalformedRoomReference("!mjolnir"); - }); - it("Can parse userID's", function() { - const command = "@spam:example.com"; - const readItems = readCommand(command); - expect(readItems.at(0)).toBeInstanceOf(UserID); - const user = readItems.at(0) as UserID; - expect(user.localpart).toBe('spam'); - }); -}) +describe("Can read", function () { + it("Can read a simple command with only strings", function () { + const command = "!mjolnir list rooms"; + const readItems = readCommand(command); + expect(readItems.every((item) => command.includes(item as string))).toBe( + true + ); + }); + it("Can turn room aliases to room references", function () { + const command = "#meow:example.org"; + const readItems = readCommand(command); + expect(readItems.at(0)).toBeInstanceOf(MatrixRoomAlias); + const roomReference = readItems.at(0) as MatrixRoomAlias; + expect(roomReference.toRoomIDOrAlias()).toBe(command); + }); + it("Can turn room ids to room references", function () { + const command = "!foijoiejfoij:example.org"; + const readItems = readCommand(command); + expect(readItems.at(0)).toBeInstanceOf(MatrixRoomID); + const roomReference = readItems.at(0) as MatrixRoomID; + expect(roomReference.toRoomIDOrAlias()).toBe(command); + }); + it("Can read keywords and correctly parse their designators", function () { + const checkKeyword = (designator: string, keyword: string) => { + const readItems = readCommand(keyword); + expect(readItems.at(0)).toBeInstanceOf(Keyword); + const keywordItem = readItems.at(0) as Keyword; + expect(keywordItem.designator).toBe(designator); + }; + checkKeyword("foo", "--foo"); + checkKeyword("foo", "-foo"); + checkKeyword("f", "-f"); + checkKeyword("foo", ":foo"); + checkKeyword("f", ":f"); + }); + it("Check that malformed room ids and aliases are read as strings", function () { + // We leave it for the command to validate the arguments it receives intentionally. + // From the perspective of the user, their command will fail just as early but with more context this way. + const checkMalformedRoomReference = (badReference: string) => { + expect(readCommand(badReference).at(0)).toBe(badReference); + }; + checkMalformedRoomReference("#singasongaboutlife"); + checkMalformedRoomReference("!mjolnir"); + }); + it("Can parse userID's", function () { + const command = "@spam:example.com"; + const readItems = readCommand(command); + expect(readItems.at(0)).toBeInstanceOf(UserID); + const user = readItems.at(0) as UserID; + expect(user.localpart).toBe("spam"); + }); +}); diff --git a/test/commands/ParseTest.ts b/test/commands/ParseTest.ts index 28406ba5..527584de 100644 --- a/test/commands/ParseTest.ts +++ b/test/commands/ParseTest.ts @@ -1,35 +1,48 @@ import { Ok, isError } from "matrix-protection-suite"; -import { defineCommandTable, defineInterfaceCommand, findTableCommand } from "../../src/commands/interface-manager/InterfaceCommand"; -import { ArgumentStream, findPresentationType, parameters, union } from "../../src/commands/interface-manager/ParameterParsing"; +import { + defineCommandTable, + defineInterfaceCommand, + findTableCommand, +} from "../../src/commands/interface-manager/InterfaceCommand"; +import { + ArgumentStream, + findPresentationType, + parameters, + union, +} from "../../src/commands/interface-manager/ParameterParsing"; import { readCommand } from "../../src/commands/interface-manager/CommandReader"; import "../../src/commands/interface-manager/MatrixPresentations"; -it('A command that fookin parses mxids', async function() { - const tableName = Symbol("ParseTest"); - defineCommandTable(tableName); - defineInterfaceCommand({ - designator: ["unban"], - table: tableName, - parameters: parameters([ - { - name: "entity", - acceptor: union( - findPresentationType("UserID"), - findPresentationType("MatrixRoomReference"), - findPresentationType("string") - ) - } - ], - undefined, - ), - command: async function() { - return Ok(undefined); +it("A command that fookin parses mxids", async function () { + const tableName = Symbol("ParseTest"); + defineCommandTable(tableName); + defineInterfaceCommand({ + designator: ["unban"], + table: tableName, + parameters: parameters( + [ + { + name: "entity", + acceptor: union( + findPresentationType("UserID"), + findPresentationType("MatrixRoomReference"), + findPresentationType("string") + ), }, - summary: "Mimicks the unban command" - }); - const command = findTableCommand(tableName, "unban"); - const result = await command.parseThenInvoke(undefined, new ArgumentStream(readCommand("@spam:example.com"))); - if (isError(result)) { - throw new TypeError(`Not supposed to be error mate`); - } -}) + ], + undefined + ), + command: async function () { + return Ok(undefined); + }, + summary: "Mimicks the unban command", + }); + const command = findTableCommand(tableName, "unban"); + const result = await command.parseThenInvoke( + undefined, + new ArgumentStream(readCommand("@spam:example.com")) + ); + if (isError(result)) { + throw new TypeError(`Not supposed to be error mate`); + } +}); diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index dd890175..51e4865f 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -2,364 +2,527 @@ import { strict as assert } from "assert"; import { newTestUser } from "./clientHelper"; import { ABUSE_REPORT_KEY, IReport } from "../../src/report/ReportManager"; import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; -import { NoticeMessageContent, ReactionContent, ReactionEvent, RoomMessage, StringEventID, Value } from "matrix-protection-suite"; +import { + NoticeMessageContent, + ReactionContent, + ReactionEvent, + RoomMessage, + StringEventID, + Value, +} from "matrix-protection-suite"; /** * Test the ability to turn abuse reports into room messages. */ const REPORT_NOTICE_REGEXPS = { - reporter: /Filed by (?[^ ]*) \((?[^ ]*)\)/, - accused: /Against (?[^ ]*) \((?[^ ]*)\)/, - room: /Room (?[^ ]*)/, - event: /Event (?[^ ]*) Go to event/, - content: /Content (?.*)/, - comments: /Comments Comments (?.*)/ + reporter: /Filed by (?[^ ]*) \((?[^ ]*)\)/, + accused: /Against (?[^ ]*) \((?[^ ]*)\)/, + room: /Room (?[^ ]*)/, + event: /Event (?[^ ]*) Go to event/, + content: /Content (?.*)/, + comments: /Comments Comments (?.*)/, }; -type ReportTemplate = Partial> & Pick & { - text?: string, - comment?: string, - text_prefix?: string, -} - -type UnredactedReaction = Omit & { content: ReactionContent }; +type ReportTemplate = Partial< + Omit +> & + Pick & { + text?: string; + comment?: string; + text_prefix?: string; + }; + +type UnredactedReaction = Omit & { + content: ReactionContent; +}; describe("Test: Reporting abuse", () => { - // Testing with successive versions of the API. - // - // As of this writing, v3 is the standard, while r0 is deprecated. However, - // both versions are still in use in the wild. - // Note that this version change only affects the actual URL at which reports - // are sent. - for (const endpoint of ['v3', 'r0']) { - it(`Mjölnir intercepts abuse reports with endpoint ${endpoint}`, async function(this: DraupnirTestContext) { - this.timeout(90000); - if (this.draupnir === undefined) { - throw new TypeError("setup must have failed.") - } - const draupnir = this.draupnir; - const draupnirSyncClient = draupnirClient(); - if (draupnirSyncClient === null) { - throw new TypeError("setup must have failed."); - } - // Listen for any notices that show up. - const notices: (Omit & { content: NoticeMessageContent })[] = []; - draupnirSyncClient.on("room.event", (roomId, event) => { - if (roomId === draupnir.managementRoomID) { - notices.push(event); - } - }); - const reactions: UnredactedReaction[] = []; - draupnirSyncClient.on("room.event", (roomId, event) => { - if (roomId === draupnir.managementRoomID) { - if (Value.Check(ReactionContent, event.content)) { - reactions.push(event) - } - } - }) - - // Create a few users and a room. - const goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); - const goodUserId = await goodUser.getUserId(); - const badUserId = await badUser.getUserId(); - - const roomId = await goodUser.createRoom({ invite: [await badUser.getUserId()] }); - await goodUser.inviteUser(await badUser.getUserId(), roomId); - await badUser.joinRoom(roomId); - - console.log("Test: Reporting abuse - send messages"); - // Exchange a few messages. - const badText = `BAD: ${Math.random()}`; // Will be reported as abuse. - const badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse. - const badText3 = `BAD: ${Math.random()}`; // Will be reported as abuse. - const badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long. - const badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines. - const badEventId = await badUser.sendText(roomId, badText); - const badEventId2 = await badUser.sendText(roomId, badText2); - const badEventId3 = await badUser.sendText(roomId, badText3); - const badEventId4 = await badUser.sendText(roomId, badText4); - const badEventId5 = await badUser.sendText(roomId, badText5); - const badEvent2Comment = `COMMENT: ${Math.random()}`; - - console.log("Test: Reporting abuse - send reports"); - const reportsToFind: ReportTemplate[] = [] - - // Time to report, first without a comment, then with one. - try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); - reportsToFind.push({ - reporter_id: goodUserId, - accused_id: badUserId, - event_id: badEventId, - text: badText, - }); - } catch (e) { - console.error("Could not send first report", e.body || e); - throw e; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`, "", { - reason: badEvent2Comment - }); - reportsToFind.push({ - reporter_id: goodUserId, - accused_id: badUserId, - event_id: badEventId2, - text: badText2, - comment: badEvent2Comment, - }); - } catch (e) { - console.error("Could not send second report", e.body || e); - throw e; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, ""); - reportsToFind.push({ - reporter_id: goodUserId, - accused_id: badUserId, - event_id: badEventId3, - text: badText3, - }); - } catch (e) { - console.error("Could not send third report", e.body || e); - throw e; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, ""); - reportsToFind.push({ - reporter_id: goodUserId, - accused_id: badUserId, - event_id: badEventId4, - text_prefix: badText4.substring(0, 256), - }); - } catch (e) { - console.error("Could not send fourth report", e.body || e); - throw e; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, ""); - reportsToFind.push({ - reporter_id: goodUserId, - accused_id: badUserId, - event_id: badEventId5, - text_prefix: badText5.substring(0, 256).split("\n").join(" "), - }); - } catch (e) { - console.error("Could not send fifth report", e.body || e); - throw e; - } - - console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); - const found: ReportTemplate[] = []; - for (const toFind of reportsToFind) { - for (const event of notices) { - if (Value.Check(RoomMessage, event)) { - if (!(ABUSE_REPORT_KEY in event.content) || typeof event.content[ABUSE_REPORT_KEY] !== 'object' || event.content[ABUSE_REPORT_KEY] === null || !('event_id' in event.content[ABUSE_REPORT_KEY])|| typeof event.content[ABUSE_REPORT_KEY].event_id !== 'string' || event.content[ABUSE_REPORT_KEY].event_id !== toFind.event_id) { - // Not a report or not our report. - continue; - } - const report = event.content[ABUSE_REPORT_KEY] as IReport; - const body = event.content.body; - let matches: Map | null = new Map(); - for (const key of Object.keys(REPORT_NOTICE_REGEXPS) as (keyof typeof REPORT_NOTICE_REGEXPS)[]) { - const match = body.match(REPORT_NOTICE_REGEXPS[key]); - if (match) { - console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups); - } else { - console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]); - // Not a report, skipping. - matches = null; - break; - } - matches.set(key, match); - } - if (!matches) { - // Not a report, skipping. - continue; - } - - assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); - assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); - - assert.equal(matches.get("event")?.groups?.eventId, toFind.event_id, "The report should specify the correct event id");; - - assert.equal(matches.get("reporter")?.groups?.reporterId, toFind.reporter_id, "The report should specify the correct reporter"); - assert.equal(report.reporter_id, toFind.reporter_id, "The embedded report should specify the correct reporter"); - assert.ok(((reporter: string | undefined) => reporter !== undefined && toFind.reporter_id.includes(reporter))(matches.get("reporter")?.groups?.reporterDisplay), "The report should display the correct reporter"); - - assert.equal(matches.get("accused")?.groups?.accusedId, toFind.accused_id, "The report should specify the correct accused"); - assert.equal(report.accused_id, toFind.accused_id, "The embedded report should specify the correct accused"); - assert.ok(((accused: string | undefined) => accused !== undefined && toFind.accused_id.includes(accused))(matches.get("accused")?.groups?.accusedDisplay), "The report should display the correct reporter"); - - if (toFind.text) { - assert.equal(matches.get("content")?.groups?.eventContent, toFind.text, "The report should contain the text we inserted in the event"); - } - if (toFind.text_prefix) { - assert.ok(matches.get("content")?.groups?.eventContent?.startsWith(toFind.text_prefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.text_prefix} in? ${matches.get("content")?.groups?.eventContent}`); - } - if (toFind.comment) { - assert.equal(matches.get("comments")?.groups?.comments, toFind.comment, "The report should contain the comment we added"); - } - assert.equal(matches.get("room")?.groups?.roomAliasOrId, roomId, "The report should specify the correct room"); - assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); - found.push(toFind); - break; - } - } - } - assert.deepEqual(found, reportsToFind); - - // Since Mjölnir is not a member of the room, the only buttons we should find - // are `help` and `ignore`. - for (const event of reactions) { - const regexp = /\/([[^]]*)\]/; - const matches = event.content["m.relates_to"]?.["key"]?.match(regexp); - if (!matches) { - continue; - } - switch (matches[1]) { - case "bad-report": - case "help": - continue; - default: - throw new Error(`Didn't expect label ${matches[1]}`); - } - } - } as unknown as Mocha.AsyncFunc); - } - it('The redact action works', async function(this: DraupnirTestContext) { - this.timeout(60000); + // Testing with successive versions of the API. + // + // As of this writing, v3 is the standard, while r0 is deprecated. However, + // both versions are still in use in the wild. + // Note that this version change only affects the actual URL at which reports + // are sent. + for (const endpoint of ["v3", "r0"]) { + it( + `Mjölnir intercepts abuse reports with endpoint ${endpoint}`, + async function (this: DraupnirTestContext) { + this.timeout(90000); + if (this.draupnir === undefined) { + throw new TypeError("setup must have failed."); + } const draupnir = this.draupnir; const draupnirSyncClient = draupnirClient(); - if (draupnir === undefined || draupnirSyncClient === null) { - throw new TypeError("setup code didn't work"); + if (draupnirSyncClient === null) { + throw new TypeError("setup must have failed."); } - // Listen for any notices that show up. - const notices: (Omit & { content: NoticeMessageContent })[] = []; + const notices: (Omit & { + content: NoticeMessageContent; + })[] = []; draupnirSyncClient.on("room.event", (roomId, event) => { - if (roomId === draupnir.managementRoomID) { - if (Value.Check(NoticeMessageContent, event.content)) { - notices.push(event); - } - } + if (roomId === draupnir.managementRoomID) { + notices.push(event); + } }); const reactions: UnredactedReaction[] = []; draupnirSyncClient.on("room.event", (roomId, event) => { - if (roomId === draupnir.managementRoomID) { - if (Value.Check(ReactionContent, event.content)) { - reactions.push(event) - } + if (roomId === draupnir.managementRoomID) { + if (Value.Check(ReactionContent, event.content)) { + reactions.push(event); } - }) - - // Create a moderator. - const moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }}); - await draupnir.client.inviteUser(await moderatorUser.getUserId(), draupnir.managementRoomID); - await moderatorUser.joinRoom(draupnir.managementRoomID); + } + }); // Create a few users and a room. - const goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-good-user" }}); - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-bad-user" }}); + const goodUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-good-user" }, + }); + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-bad-user" }, + }); + const goodUserId = await goodUser.getUserId(); + const badUserId = await badUser.getUserId(); - const roomId = await moderatorUser.createRoom({ invite: [await badUser.getUserId()] }); - await moderatorUser.inviteUser(await goodUser.getUserId(), roomId); - await moderatorUser.inviteUser(await badUser.getUserId(), roomId); + const roomId = await goodUser.createRoom({ + invite: [await badUser.getUserId()], + }); + await goodUser.inviteUser(await badUser.getUserId(), roomId); await badUser.joinRoom(roomId); - await goodUser.joinRoom(roomId); - - // Setup Mjölnir as moderator for our room. - await moderatorUser.inviteUser(await draupnir.client.getUserId(), roomId); - await moderatorUser.setUserPowerLevel(await draupnir.client.getUserId(), roomId, 100); console.log("Test: Reporting abuse - send messages"); // Exchange a few messages. - const badText = `BAD: ${Math.random()}`; // Will be reported as abuse. + const badText = `BAD: ${Math.random()}`; // Will be reported as abuse. + const badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse. + const badText3 = `BAD: ${Math.random()}`; // Will be reported as abuse. + const badText4 = [...Array(1024)] + .map((_) => `${Math.random()}`) + .join(""); // Text is too long. + const badText5 = [...Array(1024)].map((_) => "ABC").join("\n"); // Text has too many lines. const badEventId = await badUser.sendText(roomId, badText); + const badEventId2 = await badUser.sendText(roomId, badText2); + const badEventId3 = await badUser.sendText(roomId, badText3); + const badEventId4 = await badUser.sendText(roomId, badText4); + const badEventId5 = await badUser.sendText(roomId, badText5); + const badEvent2Comment = `COMMENT: ${Math.random()}`; console.log("Test: Reporting abuse - send reports"); + const reportsToFind: ReportTemplate[] = []; + // Time to report, first without a comment, then with one. try { - await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}` + ); + reportsToFind.push({ + reporter_id: goodUserId, + accused_id: badUserId, + event_id: badEventId, + text: badText, + }); } catch (e) { - console.error("Could not send first report", e.body || e); - throw e; + console.error("Could not send first report", e.body || e); + throw e; } - console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); - - const mjolnirRooms = new Set(await draupnir.client.getJoinedRooms()); - assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room"); - - // Find the notice - let noticeId; - for (const event of notices) { - if ("content" in event && ABUSE_REPORT_KEY in event.content) { - if (!(ABUSE_REPORT_KEY in event.content) || (event.content[ABUSE_REPORT_KEY] as IReport).event_id !== badEventId) { - // Not a report or not our report. - continue; - } - noticeId = event.event_id; - break; + try { + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`, + "", + { + reason: badEvent2Comment, } + ); + reportsToFind.push({ + reporter_id: goodUserId, + accused_id: badUserId, + event_id: badEventId2, + text: badText2, + comment: badEvent2Comment, + }); + } catch (e) { + console.error("Could not send second report", e.body || e); + throw e; } - assert.ok(noticeId, "We should have found our notice"); - - // Find the redact button... and click it. - let redactButtonId: StringEventID | null = null; - for (const button of reactions) { - if (button.content["m.relates_to"]?.["key"]?.includes("[redact-message]")) { - redactButtonId = button["event_id"]; - await moderatorUser.sendEvent(draupnir.managementRoomID, "m.reaction", button["content"]); - break; - } + + try { + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, + "" + ); + reportsToFind.push({ + reporter_id: goodUserId, + accused_id: badUserId, + event_id: badEventId3, + text: badText3, + }); + } catch (e) { + console.error("Could not send third report", e.body || e); + throw e; } - assert.ok(redactButtonId, "We should have found the redact button"); - // This should have triggered a confirmation request, with more buttons! - await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, + "" + ); + reportsToFind.push({ + reporter_id: goodUserId, + accused_id: badUserId, + event_id: badEventId4, + text_prefix: badText4.substring(0, 256), + }); + } catch (e) { + console.error("Could not send fourth report", e.body || e); + throw e; + } - // Find the confirmation prompt - const confirmationPromptEvent = notices.find((event) => event.content.body.includes('🗍 Redact')); - if (confirmationPromptEvent === undefined) { - throw new TypeError(`We should have found the confirmation prompt`); + try { + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, + "" + ); + reportsToFind.push({ + reporter_id: goodUserId, + accused_id: badUserId, + event_id: badEventId5, + text_prefix: badText5.substring(0, 256).split("\n").join(" "), + }); + } catch (e) { + console.error("Could not send fifth report", e.body || e); + throw e; } - let confirmEventId = null; - for (const event of reactions) { - console.debug("Is this the confirm button?", event); - const content = event.content; - if (!Value.Check(ReactionContent, content)) { - console.debug("Not a reaction"); - continue; - } - if (!content["m.relates_to"]?.["key"]?.includes("[confirm]")) { - console.debug("Not confirm"); + + console.log("Test: Reporting abuse - wait"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const found: ReportTemplate[] = []; + for (const toFind of reportsToFind) { + for (const event of notices) { + if (Value.Check(RoomMessage, event)) { + if ( + !(ABUSE_REPORT_KEY in event.content) || + typeof event.content[ABUSE_REPORT_KEY] !== "object" || + event.content[ABUSE_REPORT_KEY] === null || + !("event_id" in event.content[ABUSE_REPORT_KEY]) || + typeof event.content[ABUSE_REPORT_KEY].event_id !== "string" || + event.content[ABUSE_REPORT_KEY].event_id !== toFind.event_id + ) { + // Not a report or not our report. continue; - } - if (content["m.relates_to"]["event_id"] !== confirmationPromptEvent.event_id) { - console.debug("Not reaction to redact button"); + } + const report = event.content[ABUSE_REPORT_KEY] as IReport; + const body = event.content.body; + let matches: Map | null = new Map(); + for (const key of Object.keys( + REPORT_NOTICE_REGEXPS + ) as (keyof typeof REPORT_NOTICE_REGEXPS)[]) { + const match = body.match(REPORT_NOTICE_REGEXPS[key]); + if (match) { + console.debug( + "We have a match", + key, + REPORT_NOTICE_REGEXPS[key], + match.groups + ); + } else { + console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]); + // Not a report, skipping. + matches = null; + break; + } + matches.set(key, match); + } + if (!matches) { + // Not a report, skipping. continue; + } + + assert( + body.length < 3000, + `The report shouldn't be too long ${body.length}` + ); + assert( + body.split("\n").length < 200, + "The report shouldn't have too many newlines." + ); + + assert.equal( + matches.get("event")?.groups?.eventId, + toFind.event_id, + "The report should specify the correct event id" + ); + + assert.equal( + matches.get("reporter")?.groups?.reporterId, + toFind.reporter_id, + "The report should specify the correct reporter" + ); + assert.equal( + report.reporter_id, + toFind.reporter_id, + "The embedded report should specify the correct reporter" + ); + assert.ok( + ((reporter: string | undefined) => + reporter !== undefined && + toFind.reporter_id.includes(reporter))( + matches.get("reporter")?.groups?.reporterDisplay + ), + "The report should display the correct reporter" + ); + + assert.equal( + matches.get("accused")?.groups?.accusedId, + toFind.accused_id, + "The report should specify the correct accused" + ); + assert.equal( + report.accused_id, + toFind.accused_id, + "The embedded report should specify the correct accused" + ); + assert.ok( + ((accused: string | undefined) => + accused !== undefined && toFind.accused_id.includes(accused))( + matches.get("accused")?.groups?.accusedDisplay + ), + "The report should display the correct reporter" + ); + + if (toFind.text) { + assert.equal( + matches.get("content")?.groups?.eventContent, + toFind.text, + "The report should contain the text we inserted in the event" + ); + } + if (toFind.text_prefix) { + assert.ok( + matches + .get("content") + ?.groups?.eventContent?.startsWith(toFind.text_prefix), + `The report should contain a prefix of the long text we inserted in the event: ${toFind.text_prefix} in? ${matches.get("content")?.groups?.eventContent}` + ); + } + if (toFind.comment) { + assert.equal( + matches.get("comments")?.groups?.comments, + toFind.comment, + "The report should contain the comment we added" + ); + } + assert.equal( + matches.get("room")?.groups?.roomAliasOrId, + roomId, + "The report should specify the correct room" + ); + assert.equal( + report.room_id, + roomId, + "The embedded report should specify the correct room" + ); + found.push(toFind); + break; } + } + } + assert.deepEqual(found, reportsToFind); + + // Since Mjölnir is not a member of the room, the only buttons we should find + // are `help` and `ignore`. + for (const event of reactions) { + const regexp = /\/([[^]]*)\]/; + const matches = event.content["m.relates_to"]?.["key"]?.match(regexp); + if (!matches) { + continue; + } + switch (matches[1]) { + case "bad-report": + case "help": + continue; + default: + throw new Error(`Didn't expect label ${matches[1]}`); + } + } + } as unknown as Mocha.AsyncFunc + ); + } + it("The redact action works", async function (this: DraupnirTestContext) { + this.timeout(60000); + const draupnir = this.draupnir; + const draupnirSyncClient = draupnirClient(); + if (draupnir === undefined || draupnirSyncClient === null) { + throw new TypeError("setup code didn't work"); + } - // It's the confirm button, click it! - confirmEventId = event["event_id"]; - await moderatorUser.sendEvent(draupnir.managementRoomID, "m.reaction", event["content"]); - break; + // Listen for any notices that show up. + const notices: (Omit & { + content: NoticeMessageContent; + })[] = []; + draupnirSyncClient.on("room.event", (roomId, event) => { + if (roomId === draupnir.managementRoomID) { + if (Value.Check(NoticeMessageContent, event.content)) { + notices.push(event); } - assert.ok(confirmEventId, "We should have found the confirm button"); + } + }); + const reactions: UnredactedReaction[] = []; + draupnirSyncClient.on("room.event", (roomId, event) => { + if (roomId === draupnir.managementRoomID) { + if (Value.Check(ReactionContent, event.content)) { + reactions.push(event); + } + } + }); + + // Create a moderator. + const moderatorUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-moderator-user" }, + }); + await draupnir.client.inviteUser( + await moderatorUser.getUserId(), + draupnir.managementRoomID + ); + await moderatorUser.joinRoom(draupnir.managementRoomID); + + // Create a few users and a room. + const goodUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reacting-abuse-good-user" }, + }); + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reacting-abuse-bad-user" }, + }); + + const roomId = await moderatorUser.createRoom({ + invite: [await badUser.getUserId()], + }); + await moderatorUser.inviteUser(await goodUser.getUserId(), roomId); + await moderatorUser.inviteUser(await badUser.getUserId(), roomId); + await badUser.joinRoom(roomId); + await goodUser.joinRoom(roomId); + + // Setup Mjölnir as moderator for our room. + await moderatorUser.inviteUser(await draupnir.client.getUserId(), roomId); + await moderatorUser.setUserPowerLevel( + await draupnir.client.getUserId(), + roomId, + 100 + ); + + console.log("Test: Reporting abuse - send messages"); + // Exchange a few messages. + const badText = `BAD: ${Math.random()}`; // Will be reported as abuse. + const badEventId = await badUser.sendText(roomId, badText); + + console.log("Test: Reporting abuse - send reports"); + + try { + await goodUser.doRequest( + "POST", + `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}` + ); + } catch (e) { + console.error("Could not send first report", e.body || e); + throw e; + } + + console.log("Test: Reporting abuse - wait"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const mjolnirRooms = new Set(await draupnir.client.getJoinedRooms()); + assert.ok( + mjolnirRooms.has(roomId), + "Mjölnir should be a member of the room" + ); + + // Find the notice + let noticeId; + for (const event of notices) { + if ("content" in event && ABUSE_REPORT_KEY in event.content) { + if ( + !(ABUSE_REPORT_KEY in event.content) || + (event.content[ABUSE_REPORT_KEY] as IReport).event_id !== badEventId + ) { + // Not a report or not our report. + continue; + } + noticeId = event.event_id; + break; + } + } + assert.ok(noticeId, "We should have found our notice"); + + // Find the redact button... and click it. + let redactButtonId: StringEventID | null = null; + for (const button of reactions) { + if ( + button.content["m.relates_to"]?.["key"]?.includes("[redact-message]") + ) { + redactButtonId = button["event_id"]; + await moderatorUser.sendEvent( + draupnir.managementRoomID, + "m.reaction", + button["content"] + ); + break; + } + } + assert.ok(redactButtonId, "We should have found the redact button"); - await new Promise(resolve => setTimeout(resolve, 1000)); + // This should have triggered a confirmation request, with more buttons! + await new Promise((resolve) => setTimeout(resolve, 1000)); - // This should have redacted the message. - const newBadEvent = await draupnir.client.getEvent(roomId, badEventId); - assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event"); - } as unknown as Mocha.AsyncFunc); + // Find the confirmation prompt + const confirmationPromptEvent = notices.find((event) => + event.content.body.includes("🗍 Redact") + ); + if (confirmationPromptEvent === undefined) { + throw new TypeError(`We should have found the confirmation prompt`); + } + let confirmEventId = null; + for (const event of reactions) { + console.debug("Is this the confirm button?", event); + const content = event.content; + if (!Value.Check(ReactionContent, content)) { + console.debug("Not a reaction"); + continue; + } + if (!content["m.relates_to"]?.["key"]?.includes("[confirm]")) { + console.debug("Not confirm"); + continue; + } + if ( + content["m.relates_to"]["event_id"] !== confirmationPromptEvent.event_id + ) { + console.debug("Not reaction to redact button"); + continue; + } + + // It's the confirm button, click it! + confirmEventId = event["event_id"]; + await moderatorUser.sendEvent( + draupnir.managementRoomID, + "m.reaction", + event["content"] + ); + break; + } + assert.ok(confirmEventId, "We should have found the confirm button"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // This should have redacted the message. + const newBadEvent = await draupnir.client.getEvent(roomId, badEventId); + assert.deepEqual( + Object.keys(newBadEvent.content), + [], + "Redaction should have removed the content of the offending event" + ); + } as unknown as Mocha.AsyncFunc); }); diff --git a/test/integration/banPropagationTest.ts b/test/integration/banPropagationTest.ts index 547e90ff..1309758a 100644 --- a/test/integration/banPropagationTest.ts +++ b/test/integration/banPropagationTest.ts @@ -1,94 +1,159 @@ import expect from "expect"; import { newTestUser } from "./clientHelper"; -import { getFirstEventMatching } from './commands/commandUtils'; +import { getFirstEventMatching } from "./commands/commandUtils"; import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; -import { MatrixRoomReference, NoticeMessageContent, PolicyRuleType, PropagationType, RoomMessage, StringRoomID, Value, findProtection } from "matrix-protection-suite"; +import { + MatrixRoomReference, + NoticeMessageContent, + PolicyRuleType, + PropagationType, + RoomMessage, + StringRoomID, + Value, + findProtection, +} from "matrix-protection-suite"; // We will need to disable this in tests that are banning people otherwise it will cause // mocha to hang for awhile until it times out waiting for a response to a prompt. -describe("Ban propagation test", function() { - it("Should be enabled by default", async function(this: DraupnirTestContext) { - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`setup didn't run properly`); - } - const banPropagationProtection = findProtection("BanPropagationProtection"); - if (banPropagationProtection === undefined) { - throw new TypeError(`should be able to find the ban propagation protection`); - } - expect(draupnir.protectedRoomsSet.protections.isEnabledProtection(banPropagationProtection)).toBeTruthy(); - } as unknown as Mocha.AsyncFunc) - it("Should prompt to add bans to a policy list, then add the ban", async function(this: DraupnirTestContext) { - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`setup didn't run properly`); - } - const draupnirMatrixClient = draupnirClient(); - if (draupnirMatrixClient === null) { - throw new TypeError(`setup didn't run properly`); - } - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await moderator.joinRoom(draupnir.managementRoomID); - const protectedRooms = await Promise.all([...Array(5)].map(async _ => { - const room = await moderator.createRoom({ invite: [draupnir.clientUserID] }); - await draupnir.client.joinRoom(room); - await moderator.setUserPowerLevel(draupnir.clientUserID, room, 100); - await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(MatrixRoomReference.fromRoomID(room as StringRoomID)); - return room; - })); - // create a policy list so that we can check it for a user rule later - const policyListId = await moderator.createRoom({ invite: [draupnir.clientUserID] }); - await moderator.setUserPowerLevel(draupnir.clientUserID, policyListId, 100); - await draupnir.client.joinRoom(policyListId); - await draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, MatrixRoomReference.fromRoomID(policyListId as StringRoomID), {}); - - // check for the prompt - const promptEvent = await getFirstEventMatching({ - matrix: draupnirMatrixClient, - targetRoom: draupnir.managementRoomID, - lookAfterEvent: async function () { - // ban a user in one of our protected rooms using the moderator - await moderator.banUser('@test:example.com', protectedRooms[0], "spam"); - return undefined; - }, - predicate: function (event: unknown): boolean { - return Value.Check(RoomMessage, event) && Value.Check(NoticeMessageContent, event.content) && (event['content']['body']).startsWith('The user') - } +describe("Ban propagation test", function () { + it("Should be enabled by default", async function ( + this: DraupnirTestContext + ) { + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`setup didn't run properly`); + } + const banPropagationProtection = findProtection("BanPropagationProtection"); + if (banPropagationProtection === undefined) { + throw new TypeError( + `should be able to find the ban propagation protection` + ); + } + expect( + draupnir.protectedRoomsSet.protections.isEnabledProtection( + banPropagationProtection + ) + ).toBeTruthy(); + } as unknown as Mocha.AsyncFunc); + it( + "Should prompt to add bans to a policy list, then add the ban", + async function (this: DraupnirTestContext) { + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`setup didn't run properly`); + } + const draupnirMatrixClient = draupnirClient(); + if (draupnirMatrixClient === null) { + throw new TypeError(`setup didn't run properly`); + } + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + await moderator.joinRoom(draupnir.managementRoomID); + const protectedRooms = await Promise.all( + [...Array(5)].map(async (_) => { + const room = await moderator.createRoom({ + invite: [draupnir.clientUserID], + }); + await draupnir.client.joinRoom(room); + await moderator.setUserPowerLevel(draupnir.clientUserID, room, 100); + await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( + MatrixRoomReference.fromRoomID(room as StringRoomID) + ); + return room; }) - // select the prompt - await moderator.unstableApis.addReactionToEvent( - draupnir.managementRoomID, promptEvent['event_id'], '1️⃣' - ); - // check the policy list, after waiting a few seconds. - await new Promise(resolve => setTimeout(resolve, 10000)); + ); + // create a policy list so that we can check it for a user rule later + const policyListId = await moderator.createRoom({ + invite: [draupnir.clientUserID], + }); + await moderator.setUserPowerLevel( + draupnir.clientUserID, + policyListId, + 100 + ); + await draupnir.client.joinRoom(policyListId); + await draupnir.protectedRoomsSet.issuerManager.watchList( + PropagationType.Direct, + MatrixRoomReference.fromRoomID(policyListId as StringRoomID), + {} + ); - const policyListRevisionAfterBan = draupnir.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision; - const rules = policyListRevisionAfterBan.allRulesMatchingEntity('@test:example.com', PolicyRuleType.User); - expect(rules.length).toBe(1); - expect(rules[0]?.entity).toBe('@test:example.com'); - expect(rules[0]?.reason).toBe('spam'); + // check for the prompt + const promptEvent = await getFirstEventMatching({ + matrix: draupnirMatrixClient, + targetRoom: draupnir.managementRoomID, + lookAfterEvent: async function () { + // ban a user in one of our protected rooms using the moderator + await moderator.banUser( + "@test:example.com", + protectedRooms[0], + "spam" + ); + return undefined; + }, + predicate: function (event: unknown): boolean { + return ( + Value.Check(RoomMessage, event) && + Value.Check(NoticeMessageContent, event.content) && + event["content"]["body"].startsWith("The user") + ); + }, + }); + // select the prompt + await moderator.unstableApis.addReactionToEvent( + draupnir.managementRoomID, + promptEvent["event_id"], + "1️⃣" + ); + // check the policy list, after waiting a few seconds. + await new Promise((resolve) => setTimeout(resolve, 10000)); - // now unban them >:3 - const unbanPrompt = await getFirstEventMatching({ - matrix: draupnirMatrixClient, - targetRoom: draupnir.managementRoomID, - lookAfterEvent: async function () { - // ban a user in one of our protected rooms using the moderator - await moderator.unbanUser('@test:example.com', protectedRooms[0]); - return undefined; - }, - predicate: function (event: unknown): boolean { - return Value.Check(RoomMessage, event) && Value.Check(NoticeMessageContent, event.content) && (event['content']['body']).startsWith('The user') - } - }); + const policyListRevisionAfterBan = + draupnir.protectedRoomsSet.issuerManager.policyListRevisionIssuer + .currentRevision; + const rules = policyListRevisionAfterBan.allRulesMatchingEntity( + "@test:example.com", + PolicyRuleType.User + ); + expect(rules.length).toBe(1); + expect(rules[0]?.entity).toBe("@test:example.com"); + expect(rules[0]?.reason).toBe("spam"); - await moderator.unstableApis.addReactionToEvent( - draupnir.managementRoomID, unbanPrompt['event_id'], 'unban from all' - ); - await new Promise(resolve => setTimeout(resolve, 10000)); - const policyListRevisionAfterUnBan = draupnir.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision; + // now unban them >:3 + const unbanPrompt = await getFirstEventMatching({ + matrix: draupnirMatrixClient, + targetRoom: draupnir.managementRoomID, + lookAfterEvent: async function () { + // ban a user in one of our protected rooms using the moderator + await moderator.unbanUser("@test:example.com", protectedRooms[0]); + return undefined; + }, + predicate: function (event: unknown): boolean { + return ( + Value.Check(RoomMessage, event) && + Value.Check(NoticeMessageContent, event.content) && + event["content"]["body"].startsWith("The user") + ); + }, + }); - const rulesAfterUnban = policyListRevisionAfterUnBan.allRulesMatchingEntity('@test:example.com', PolicyRuleType.User); - expect(rulesAfterUnban.length).toBe(0); - } as unknown as Mocha.AsyncFunc) -}) + await moderator.unstableApis.addReactionToEvent( + draupnir.managementRoomID, + unbanPrompt["event_id"], + "unban from all" + ); + await new Promise((resolve) => setTimeout(resolve, 10000)); + const policyListRevisionAfterUnBan = + draupnir.protectedRoomsSet.issuerManager.policyListRevisionIssuer + .currentRevision; + + const rulesAfterUnban = + policyListRevisionAfterUnBan.allRulesMatchingEntity( + "@test:example.com", + PolicyRuleType.User + ); + expect(rulesAfterUnban.length).toBe(0); + } as unknown as Mocha.AsyncFunc + ); +}); diff --git a/test/integration/clientHelper.ts b/test/integration/clientHelper.ts index d0a953cb..a452a506 100644 --- a/test/integration/clientHelper.ts +++ b/test/integration/clientHelper.ts @@ -1,7 +1,17 @@ import { HmacSHA1 } from "crypto-js"; -import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk"; +import { + getRequestFn, + LogService, + MatrixClient, + MemoryStorageProvider, + PantalaimonClient, +} from "matrix-bot-sdk"; import "../../src/utils"; // we need this for the patches to matrix-bot-sdk's `getRequestFn`. -import { NoticeMessageContent, RoomMessage, Value } from "matrix-protection-suite"; +import { + NoticeMessageContent, + RoomMessage, + Value, +} from "matrix-protection-suite"; const REGISTRATION_ATTEMPTS = 10; const REGISTRATION_RETRY_BASE_DELAY_MS = 100; @@ -17,105 +27,155 @@ const REGISTRATION_RETRY_BASE_DELAY_MS = 100; * @param admin True to make the user an admin, false otherwise. * @returns The response from synapse. */ -export async function registerUser(homeserver: string, username: string, displayname: string, password: string, admin: boolean): Promise { - const registerUrl = `${homeserver}/_synapse/admin/v1/register` - const nonce: string = await new Promise((resolve, reject) => { - getRequestFn()({uri: registerUrl, method: "GET", timeout: 60000}, (error: unknown, _response: unknown, resBody: unknown) => { - if (error) { - if (error instanceof Error) { - reject(error); - } else { - throw new TypeError(`Something is throwing absoloute garbage`); - } - } else if (typeof resBody === 'object' && resBody !== null && 'nonce' in resBody && typeof resBody.nonce === 'string') { - resolve(resBody.nonce) - } else { - reject(new TypeError(`Don't know what to do with response body ${JSON.stringify(resBody)}`)); - } - }); - }); - const mac = HmacSHA1(`${nonce}\0${username}\0${password}\0${admin ? 'admin' : 'notadmin'}`, 'REGISTRATION_SHARED_SECRET'); - for (let i = 1; i <= REGISTRATION_ATTEMPTS; ++i) { - try { - const params = { - uri: registerUrl, - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - nonce, - username, - displayname, - password, - admin, - mac: mac.toString() - }), - timeout: 60000 - } - await new Promise((resolve, reject) => { - getRequestFn()(params, (error: unknown) => { error !== undefined ? error instanceof Error ? reject(error) : resolve(new TypeError(`something is throwing garbage`)) : resolve(undefined); }); - }); return; - } catch (ex) { - // In case of timeout or throttling, backoff and retry. - if (ex?.code === 'ESOCKETTIMEDOUT' || ex?.code === 'ETIMEDOUT' - || ex?.body?.errcode === 'M_LIMIT_EXCEEDED') { - await new Promise(resolve => setTimeout(resolve, REGISTRATION_RETRY_BASE_DELAY_MS * i * i)); - continue; - } - throw ex; +export async function registerUser( + homeserver: string, + username: string, + displayname: string, + password: string, + admin: boolean +): Promise { + const registerUrl = `${homeserver}/_synapse/admin/v1/register`; + const nonce: string = await new Promise((resolve, reject) => { + getRequestFn()( + { uri: registerUrl, method: "GET", timeout: 60000 }, + (error: unknown, _response: unknown, resBody: unknown) => { + if (error) { + if (error instanceof Error) { + reject(error); + } else { + throw new TypeError(`Something is throwing absoloute garbage`); + } + } else if ( + typeof resBody === "object" && + resBody !== null && + "nonce" in resBody && + typeof resBody.nonce === "string" + ) { + resolve(resBody.nonce); + } else { + reject( + new TypeError( + `Don't know what to do with response body ${JSON.stringify(resBody)}` + ) + ); } + } + ); + }); + const mac = HmacSHA1( + `${nonce}\0${username}\0${password}\0${admin ? "admin" : "notadmin"}`, + "REGISTRATION_SHARED_SECRET" + ); + for (let i = 1; i <= REGISTRATION_ATTEMPTS; ++i) { + try { + const params = { + uri: registerUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nonce, + username, + displayname, + password, + admin, + mac: mac.toString(), + }), + timeout: 60000, + }; + await new Promise((resolve, reject) => { + getRequestFn()(params, (error: unknown) => { + error !== undefined + ? error instanceof Error + ? reject(error) + : resolve(new TypeError(`something is throwing garbage`)) + : resolve(undefined); + }); + }); + return; + } catch (ex) { + // In case of timeout or throttling, backoff and retry. + if ( + ex?.code === "ESOCKETTIMEDOUT" || + ex?.code === "ETIMEDOUT" || + ex?.body?.errcode === "M_LIMIT_EXCEEDED" + ) { + await new Promise((resolve) => + setTimeout(resolve, REGISTRATION_RETRY_BASE_DELAY_MS * i * i) + ); + continue; + } + throw ex; } - throw new Error(`Retried registration ${REGISTRATION_ATTEMPTS} times, is Mjolnir or Synapse misconfigured?`); + } + throw new Error( + `Retried registration ${REGISTRATION_ATTEMPTS} times, is Mjolnir or Synapse misconfigured?` + ); } export type RegistrationOptions = { - /** - * If specified and true, make the user an admin. - */ - isAdmin?: boolean, - /** - * If `exact`, use the account with this exact name, attempting to reuse - * an existing account if possible. - * - * If `contains` create a new account with a name that contains this - * specific string. - */ - name: { exact: string } | { contains: string }, - /** - * If specified and true, throttle this user. - */ - isThrottled?: boolean -} + /** + * If specified and true, make the user an admin. + */ + isAdmin?: boolean; + /** + * If `exact`, use the account with this exact name, attempting to reuse + * an existing account if possible. + * + * If `contains` create a new account with a name that contains this + * specific string. + */ + name: { exact: string } | { contains: string }; + /** + * If specified and true, throttle this user. + */ + isThrottled?: boolean; +}; /** * Register a new test user. * * @returns A string that is both the username and password of a new user. */ -async function registerNewTestUser(homeserver: string, options: RegistrationOptions) { - while (true) { - let username; +async function registerNewTestUser( + homeserver: string, + options: RegistrationOptions +) { + while (true) { + let username; + if ("exact" in options.name) { + username = options.name.exact; + } else { + username = `mjolnir-test-user-${options.name.contains}${Math.floor(Math.random() * 100000)}`; + } + try { + await registerUser( + homeserver, + username, + username, + username, + Boolean(options.isAdmin) + ); + return username; + } catch (e) { + if (e?.body?.errcode === "M_USER_IN_USE") { if ("exact" in options.name) { - username = options.name.exact; + LogService.debug( + "test/clientHelper", + `${username} already registered, reusing` + ); + return username; } else { - username = `mjolnir-test-user-${options.name.contains}${Math.floor(Math.random() * 100000)}` - } - try { - await registerUser(homeserver, username, username, username, Boolean(options.isAdmin)); - return username; - } catch (e) { - if (e?.body?.errcode === 'M_USER_IN_USE') { - if ("exact" in options.name) { - LogService.debug("test/clientHelper", `${username} already registered, reusing`); - return username; - } else { - LogService.debug("test/clientHelper", `${username} already registered, trying another`); - } - } else { - console.error(`failed to register user ${e}`); - throw e; - } + LogService.debug( + "test/clientHelper", + `${username} already registered, trying another` + ); } + } else { + console.error(`failed to register user ${e}`); + throw e; + } } + } } /** @@ -123,15 +183,24 @@ async function registerNewTestUser(homeserver: string, options: RegistrationOpti * * @returns A new `MatrixClient` session for a unique test user. */ -export async function newTestUser(homeserver: string, options: RegistrationOptions): Promise { - const username = await registerNewTestUser(homeserver, options); - const pantalaimon = new PantalaimonClient(homeserver, new MemoryStorageProvider()); - const client = await pantalaimon.createClientWithCredentials(username, username); - if (!options.isThrottled) { - const userId = await client.getUserId(); - await overrideRatelimitForUser(homeserver, userId); - } - return client; +export async function newTestUser( + homeserver: string, + options: RegistrationOptions +): Promise { + const username = await registerNewTestUser(homeserver, options); + const pantalaimon = new PantalaimonClient( + homeserver, + new MemoryStorageProvider() + ); + const client = await pantalaimon.createClientWithCredentials( + username, + username + ); + if (!options.isThrottled) { + const userId = await client.getUserId(); + await overrideRatelimitForUser(homeserver, userId); + } + return client; } let _globalAdminUser: MatrixClient | undefined; @@ -141,57 +210,90 @@ let _globalAdminUser: MatrixClient | undefined; * @returns A client logged in with an admin user. */ async function getGlobalAdminUser(homeserver: string): Promise { - // Initialize global admin user if needed. - if (_globalAdminUser === undefined) { - const USERNAME = "mjolnir-test-internal-admin-user"; - try { - await registerUser(homeserver, USERNAME, USERNAME, USERNAME, true); - } catch (e) { - if (e?.body?.errcode === 'M_USER_IN_USE') { - // Then we've already registered the user in a previous run and that is ok. - } else { - throw e; - } - } - _globalAdminUser = await new PantalaimonClient(homeserver, new MemoryStorageProvider()).createClientWithCredentials(USERNAME, USERNAME); + // Initialize global admin user if needed. + if (_globalAdminUser === undefined) { + const USERNAME = "mjolnir-test-internal-admin-user"; + try { + await registerUser(homeserver, USERNAME, USERNAME, USERNAME, true); + } catch (e) { + if (e?.body?.errcode === "M_USER_IN_USE") { + // Then we've already registered the user in a previous run and that is ok. + } else { + throw e; + } } - return _globalAdminUser; + _globalAdminUser = await new PantalaimonClient( + homeserver, + new MemoryStorageProvider() + ).createClientWithCredentials(USERNAME, USERNAME); + } + return _globalAdminUser; } /** * Disable ratelimiting for this user in Synapse. * @param userId The user to disable ratelimiting for, has to include both the server part and local part. */ -export async function overrideRatelimitForUser(homeserver: string, userId: string) { - await (await getGlobalAdminUser(homeserver)).doRequest("POST", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null, { - "messages_per_second": 0, - "burst_count": 0 - }); +export async function overrideRatelimitForUser( + homeserver: string, + userId: string +) { + await ( + await getGlobalAdminUser(homeserver) + ).doRequest( + "POST", + `/_synapse/admin/v1/users/${userId}/override_ratelimit`, + null, + { + messages_per_second: 0, + burst_count: 0, + } + ); } /** * Put back the default ratelimiting for this user in Synapse. * @param userId The user to use default ratelimiting for, has to include both the server part and local part. */ -export async function resetRatelimitForUser(homeserver: string, userId: string) { - await (await getGlobalAdminUser(homeserver)).doRequest("DELETE", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null); +export async function resetRatelimitForUser( + homeserver: string, + userId: string +) { + await ( + await getGlobalAdminUser(homeserver) + ).doRequest( + "DELETE", + `/_synapse/admin/v1/users/${userId}/override_ratelimit`, + null + ); } - /** * Utility to create an event listener for m.notice msgtype m.room.messages. * @param targetRoomdId The roomId to listen into. * @param cb The callback when a m.notice event is found in targetRoomId. * @returns The callback to pass to `MatrixClient.on('room.message', cb)` */ -export function noticeListener(targetRoomdId: string, cb: (event: Omit & { content: NoticeMessageContent }) => void) { - return (roomId: string, event: unknown) => { - if (roomId !== targetRoomdId) { - return; - } - if (Value.Check(RoomMessage, event) && Value.Check(NoticeMessageContent, event.content)) { - cb(event as Omit & { content: NoticeMessageContent }); - return; +export function noticeListener( + targetRoomdId: string, + cb: ( + event: Omit & { content: NoticeMessageContent } + ) => void +) { + return (roomId: string, event: unknown) => { + if (roomId !== targetRoomdId) { + return; + } + if ( + Value.Check(RoomMessage, event) && + Value.Check(NoticeMessageContent, event.content) + ) { + cb( + event as Omit & { + content: NoticeMessageContent; } + ); + return; } + }; } diff --git a/test/integration/commands/commandUtils.ts b/test/integration/commands/commandUtils.ts index e8cfdd5f..19bb8a17 100644 --- a/test/integration/commands/commandUtils.ts +++ b/test/integration/commands/commandUtils.ts @@ -2,21 +2,25 @@ import { MatrixClient } from "matrix-bot-sdk"; import { strict as assert } from "assert"; import * as crypto from "crypto"; import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { NoticeMessageContent, ReactionEvent, RoomEvent, StringEventID, TextMessageContent, Value } from "matrix-protection-suite"; +import { + NoticeMessageContent, + ReactionEvent, + RoomEvent, + StringEventID, + TextMessageContent, + Value, +} from "matrix-protection-suite"; import { Type } from "@sinclair/typebox"; export const ReplyContent = Type.Intersect([ - Type.Object({ - 'm.relates_to': Type.Object({ - 'm.in_reply_to': Type.Object({ - event_id: StringEventID, - }), - }) + Type.Object({ + "m.relates_to": Type.Object({ + "m.in_reply_to": Type.Object({ + event_id: StringEventID, + }), }), - Type.Union([ - NoticeMessageContent, - TextMessageContent - ]) + }), + Type.Union([NoticeMessageContent, TextMessageContent]), ]); export type ReplyContent = typeof ReplyContent; @@ -28,8 +32,12 @@ export type ReplyContent = typeof ReplyContent; * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @returns The replying event. */ -export async function getFirstReply(matrix: MatrixEmitter, targetRoom: string, targetEventThunk: () => Promise): Promise> { - return getNthReply(matrix, targetRoom, 1, targetEventThunk); +export async function getFirstReply( + matrix: MatrixEmitter, + targetRoom: string, + targetEventThunk: () => Promise +): Promise> { + return getNthReply(matrix, targetRoom, 1, targetEventThunk); } /** @@ -41,61 +49,65 @@ export async function getFirstReply(matrix: MatrixEmitter, targetRoom: string, t * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @returns The replying event. */ -export async function getNthReply(matrix: MatrixEmitter, targetRoom: string, n: number, targetEventThunk: () => Promise): Promise> { - if (Number.isNaN(n) || !Number.isInteger(n) || n <= 0) { - throw new TypeError(`Invalid number of events ${n}`); +export async function getNthReply( + matrix: MatrixEmitter, + targetRoom: string, + n: number, + targetEventThunk: () => Promise +): Promise> { + if (Number.isNaN(n) || !Number.isInteger(n) || n <= 0) { + throw new TypeError(`Invalid number of events ${n}`); + } + const reactionEvents: RoomEvent[] = []; + const addEvent = function (roomId: string, event: RoomEvent) { + if (roomId !== targetRoom) return; + if (event.type !== "m.room.message") return; + reactionEvents.push(event); + }; + let targetCb; + try { + matrix.on("room.event", addEvent); + const targetEventId = await targetEventThunk(); + if (typeof targetEventId !== "string") { + throw new TypeError(); } - const reactionEvents: RoomEvent[] = []; - const addEvent = function (roomId: string, event: RoomEvent) { - if (roomId !== targetRoom) return; - if (event.type !== 'm.room.message') return; - reactionEvents.push(event); - }; - let targetCb; - try { - matrix.on('room.event', addEvent) - const targetEventId = await targetEventThunk(); - if (typeof targetEventId !== 'string') { - throw new TypeError(); - } - for (const event of reactionEvents) { - if (Value.Check(ReplyContent, event.content)) { - const in_reply_to = event.content['m.relates_to']['m.in_reply_to']; - if (in_reply_to.event_id === targetEventId) { - n -= 1; - if (n === 0) { - return event as RoomEvent; - } - } - } + for (const event of reactionEvents) { + if (Value.Check(ReplyContent, event.content)) { + const in_reply_to = event.content["m.relates_to"]["m.in_reply_to"]; + if (in_reply_to.event_id === targetEventId) { + n -= 1; + if (n === 0) { + return event as RoomEvent; + } } - return await new Promise(resolve => { - targetCb = function(roomId: string, event: RoomEvent) { - if (roomId !== targetRoom) return; - if (event.type !== 'm.room.message') return; - if (Value.Check(ReplyContent, event.content)) { - const in_reply_to = event.content['m.relates_to']['m.in_reply_to']; - if (in_reply_to.event_id === targetEventId) { - n -= 1; - if (n === 0) { - resolve(event as RoomEvent); - } - } - } + } + } + return await new Promise((resolve) => { + targetCb = function (roomId: string, event: RoomEvent) { + if (roomId !== targetRoom) return; + if (event.type !== "m.room.message") return; + if (Value.Check(ReplyContent, event.content)) { + const in_reply_to = event.content["m.relates_to"]["m.in_reply_to"]; + if (in_reply_to.event_id === targetEventId) { + n -= 1; + if (n === 0) { + resolve(event as RoomEvent); } - matrix.on('room.event', targetCb); - }); - } finally { - matrix.removeListener('room.event', addEvent); - // the type feedback for eslitn has to be wrong here i don't get it. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (targetCb) { - matrix.removeListener('room.event', targetCb); + } } + }; + matrix.on("room.event", targetCb); + }); + } finally { + matrix.removeListener("room.event", addEvent); + // the type feedback for eslitn has to be wrong here i don't get it. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (targetCb) { + matrix.removeListener("room.event", targetCb); } + } } - /** * Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk. * @param matrix A MatrixEmitter for a MatrixClient that is already in the targetRoom that can be started to listen for the event produced by targetEventThunk. @@ -105,42 +117,53 @@ export async function getNthReply(matrix: MatrixEmitter, targetRoom: string, n: * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction. * @returns The reaction event. */ -export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise): Promise { - const reactionEvents: ReactionEvent[] = []; - const addEvent = function (roomId: string, event: RoomEvent) { +export async function getFirstReaction( + matrix: MatrixEmitter, + targetRoom: string, + reactionKey: string, + targetEventThunk: () => Promise +): Promise { + const reactionEvents: ReactionEvent[] = []; + const addEvent = function (roomId: string, event: RoomEvent) { + if (roomId !== targetRoom) return; + if (!Value.Check(ReactionEvent, event)) return; + reactionEvents.push(event); + }; + let targetCb; + try { + matrix.on("room.event", addEvent); + const targetEventId = await targetEventThunk(); + for (const event of reactionEvents) { + const relates_to = event.content?.["m.relates_to"]; + if ( + relates_to?.event_id === targetEventId && + relates_to.key === reactionKey + ) { + return event; + } + } + return await new Promise((resolve) => { + targetCb = function (roomId: string, event: RoomEvent) { if (roomId !== targetRoom) return; if (!Value.Check(ReactionEvent, event)) return; - reactionEvents.push(event); - }; - let targetCb; - try { - matrix.on('room.event', addEvent) - const targetEventId = await targetEventThunk(); - for (const event of reactionEvents) { - const relates_to = event.content?.['m.relates_to']; - if (relates_to?.event_id === targetEventId && relates_to.key === reactionKey) { - return event; - } - } - return await new Promise((resolve) => { - targetCb = function(roomId: string, event: RoomEvent) { - if (roomId !== targetRoom) return; - if (!Value.Check(ReactionEvent, event)) return; - const relates_to = event.content['m.relates_to']; - if (relates_to?.event_id === targetEventId && relates_to.key === reactionKey) { - resolve(event) - } - } - matrix.on('room.event', targetCb); - }); - } finally { - matrix.off('room.event', addEvent); - // idk why the type checker can't detect that this condition is necessary. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (targetCb) { - matrix.off('room.event', targetCb); + const relates_to = event.content["m.relates_to"]; + if ( + relates_to?.event_id === targetEventId && + relates_to.key === reactionKey + ) { + resolve(event); } + }; + matrix.on("room.event", targetCb); + }); + } finally { + matrix.off("room.event", addEvent); + // idk why the type checker can't detect that this condition is necessary. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (targetCb) { + matrix.off("room.event", targetCb); } + } } /** @@ -150,33 +173,38 @@ export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string * `getFirstEventMatching` should start matching events right away. * @returns The event matching the predicate provided. */ -export async function getFirstEventMatching(details: { matrix: MatrixEmitter, targetRoom: string, lookAfterEvent: () => Promise, predicate: (event: RoomEvent) => boolean }): Promise { - let targetCb; - try { - return await new Promise((resolve) => { - void details.lookAfterEvent().then((afterEventId: string|undefined) => { - // if the event has returned an event id, then we will wait for that in the timeline, - // otherwise the "event" isn't a matrix event and we just have to start looking right away. - let isAfterEventId = afterEventId === undefined; - targetCb = (roomId: string, event: RoomEvent) => { - if (event['event_id'] === afterEventId) { - isAfterEventId = true; - return; - } - if (isAfterEventId && details.predicate(event)) { - resolve(event); - } - }; - details.matrix.on('room.event', targetCb) - }) - }) - } finally { - // unless i'm dumb, the type inference for this is wrong, and i don't know why. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (targetCb) { - details.matrix.off('room.event', targetCb) - } +export async function getFirstEventMatching(details: { + matrix: MatrixEmitter; + targetRoom: string; + lookAfterEvent: () => Promise; + predicate: (event: RoomEvent) => boolean; +}): Promise { + let targetCb; + try { + return await new Promise((resolve) => { + void details.lookAfterEvent().then((afterEventId: string | undefined) => { + // if the event has returned an event id, then we will wait for that in the timeline, + // otherwise the "event" isn't a matrix event and we just have to start looking right away. + let isAfterEventId = afterEventId === undefined; + targetCb = (roomId: string, event: RoomEvent) => { + if (event["event_id"] === afterEventId) { + isAfterEventId = true; + return; + } + if (isAfterEventId && details.predicate(event)) { + resolve(event); + } + }; + details.matrix.on("room.event", targetCb); + }); + }); + } finally { + // unless i'm dumb, the type inference for this is wrong, and i don't know why. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (targetCb) { + details.matrix.off("room.event", targetCb); } + } } /** @@ -186,11 +214,28 @@ export async function getFirstEventMatching(details: { matrix: MatrixEmitter, ta * @param client A client that isn't mjolnir to send the message with, as you will be invited to the room. * @returns The shortcode for the list that can be used to refer to the list in future commands. */ -export async function createBanList(managementRoom: string, mjolnir: MatrixEmitter, client: MatrixClient): Promise { - const listName = crypto.randomUUID(); - const listCreationResponse = await getFirstReply(mjolnir, managementRoom, async () => { - return await client.sendMessage(managementRoom, { msgtype: 'm.text', body: `!mjolnir list create ${listName} ${listName}`}); - }); - assert.equal(listCreationResponse.content.body.includes('This list is now being watched.'), true, 'could not create a list to test with.'); - return listName; +export async function createBanList( + managementRoom: string, + mjolnir: MatrixEmitter, + client: MatrixClient +): Promise { + const listName = crypto.randomUUID(); + const listCreationResponse = await getFirstReply( + mjolnir, + managementRoom, + async () => { + return await client.sendMessage(managementRoom, { + msgtype: "m.text", + body: `!mjolnir list create ${listName} ${listName}`, + }); + } + ); + assert.equal( + listCreationResponse.content.body.includes( + "This list is now being watched." + ), + true, + "could not create a list to test with." + ); + return listName; } diff --git a/test/integration/commands/hijackRoomCommandTest.ts b/test/integration/commands/hijackRoomCommandTest.ts index f003b52e..dc5d0af3 100644 --- a/test/integration/commands/hijackRoomCommandTest.ts +++ b/test/integration/commands/hijackRoomCommandTest.ts @@ -4,31 +4,71 @@ import { getFirstReaction } from "./commandUtils"; import { DraupnirTestContext, draupnirSafeEmitter } from "../mjolnirSetupUtils"; describe("Test: The make admin command", function () { - it('Mjölnir make the bot self room administrator', async function (this: DraupnirTestContext) { - this.timeout(90000); - if (!this.config.admin?.enableMakeRoomAdminCommand) { - this.done(); - } - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`Test didn't setup correctly`); - } - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - const userA = await newTestUser(this.config.homeserverUrl, { name: { contains: "a" } }); - const userAId = await userA.getUserId(); + it("Mjölnir make the bot self room administrator", async function ( + this: DraupnirTestContext + ) { + this.timeout(90000); + if (!this.config.admin?.enableMakeRoomAdminCommand) { + this.done(); + } + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`Test didn't setup correctly`); + } + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + const userA = await newTestUser(this.config.homeserverUrl, { + name: { contains: "a" }, + }); + const userAId = await userA.getUserId(); - await moderator.joinRoom(draupnir.managementRoomID); - const targetRoom = await moderator.createRoom({ invite: [draupnir.clientUserID], preset: "public_chat" }); - await moderator.sendMessage(draupnir.managementRoomID, { msgtype: 'm.text.', body: `!draupnir rooms add ${targetRoom}` }); - await userA.joinRoom(targetRoom); - const powerLevelsBefore = await moderator.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); - assert.notEqual(powerLevelsBefore["users"][draupnir.clientUserID], 100, `Bot should not yet be an admin of ${targetRoom}`); - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return await moderator.sendMessage(draupnir.managementRoomID, { msgtype: 'm.text', body: `!draupnir hijack room ${targetRoom} ${draupnir.clientUserID}` }); + await moderator.joinRoom(draupnir.managementRoomID); + const targetRoom = await moderator.createRoom({ + invite: [draupnir.clientUserID], + preset: "public_chat", + }); + await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text.", + body: `!draupnir rooms add ${targetRoom}`, + }); + await userA.joinRoom(targetRoom); + const powerLevelsBefore = await moderator.getRoomStateEvent( + targetRoom, + "m.room.power_levels", + "" + ); + assert.notEqual( + powerLevelsBefore["users"][draupnir.clientUserID], + 100, + `Bot should not yet be an admin of ${targetRoom}` + ); + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir hijack room ${targetRoom} ${draupnir.clientUserID}`, }); + } + ); - const powerLevelsAfter = await moderator.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); - assert.equal(powerLevelsAfter["users"][draupnir.clientUserID], 100, "Bot should be a room admin."); - assert.equal(powerLevelsAfter["users"][userAId], undefined, "User A is not supposed to be a room admin."); - } as unknown as Mocha.AsyncFunc); + const powerLevelsAfter = await moderator.getRoomStateEvent( + targetRoom, + "m.room.power_levels", + "" + ); + assert.equal( + powerLevelsAfter["users"][draupnir.clientUserID], + 100, + "Bot should be a room admin." + ); + assert.equal( + powerLevelsAfter["users"][userAId], + undefined, + "User A is not supposed to be a room admin." + ); + } as unknown as Mocha.AsyncFunc); }); diff --git a/test/integration/commands/redactCommandTest.ts b/test/integration/commands/redactCommandTest.ts index 3c12ac91..54cef2f2 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -3,133 +3,283 @@ import { newTestUser } from "../clientHelper"; import { getMessagesByUserIn } from "../../../src/utils"; import { LogService } from "matrix-bot-sdk"; import { getFirstReaction } from "./commandUtils"; -import { draupnirClient, draupnirSafeEmitter, DraupnirTestContext } from "../mjolnirSetupUtils"; +import { + draupnirClient, + draupnirSafeEmitter, + DraupnirTestContext, +} from "../mjolnirSetupUtils"; import { MatrixClient } from "matrix-bot-sdk"; interface RedactionTestContext extends DraupnirTestContext { - moderator?: MatrixClient; + moderator?: MatrixClient; } describe("Test: The redaction command", function () { - // If a test has a timeout while awaitng on a promise then we never get given control back. - afterEach(function() { - this.moderator?.stop(); - }); + // If a test has a timeout while awaitng on a promise then we never get given control back. + afterEach(function () { + this.moderator?.stop(); + }); - it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.', async function(this: RedactionTestContext) { - this.timeout(60000); - // Create a few users and a room. - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); - const badUserId = await badUser.getUserId(); - const draupnirMatrixClient = draupnirClient(); - const draupnir = this.draupnir; - if (draupnirMatrixClient === null || draupnir === undefined) { - throw new TypeError(`Test isn't setup correctly`); - } - const mjolnirUserId = await draupnirMatrixClient.getUserId(); - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - this.moderator = moderator; - await moderator.joinRoom(this.config.managementRoom); - const targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]}); - await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); - await badUser.joinRoom(targetRoom); - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); - }); - LogService.debug("redactionTest", `targetRoom: ${targetRoom}, managementRoom: ${this.config.managementRoom}`); - // Sandwich irrelevant messages in bad messages. - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: `Irrelevant Message #${i}`}))); - for (let i = 0; i < 5; i++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + it( + "Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.", + async function (this: RedactionTestContext) { + this.timeout(60000); + // Create a few users and a room. + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "spammer-needs-redacting" }, + }); + const badUserId = await badUser.getUserId(); + const draupnirMatrixClient = draupnirClient(); + const draupnir = this.draupnir; + if (draupnirMatrixClient === null || draupnir === undefined) { + throw new TypeError(`Test isn't setup correctly`); + } + const mjolnirUserId = await draupnirMatrixClient.getUserId(); + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + const targetRoom = await moderator.createRoom({ + invite: [await badUser.getUserId(), mjolnirUserId], + }); + await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + await badUser.joinRoom(targetRoom); + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms add ${targetRoom}`, + }); } - await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: `Irrelevant Message #${i}`}))); - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return await moderator.sendMessage(draupnir.managementRoomID, { msgtype: 'm.text', body: `!draupnir redact ${badUserId} --room ${targetRoom}` }); - }); - - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) { - events.map(e => { - if (e.type === 'm.room.member') { - assert.equal(Object.keys(e.content).length, 1, "Only membership should be left on the membership even when it has been redacted.") - } else if (Object.keys(e.content).length !== 0) { - throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`) - } - }) + ); + LogService.debug( + "redactionTest", + `targetRoom: ${targetRoom}, managementRoom: ${this.config.managementRoom}` + ); + // Sandwich irrelevant messages in bad messages. + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text", + body: `Irrelevant Message #${i}`, + }) + ) + ); + for (let i = 0; i < 5; i++) { + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", }); - } as unknown as Mocha.AsyncFunc) + } + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text", + body: `Irrelevant Message #${i}`, + }) + ) + ); + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); - it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id in multiple rooms.', async function(this: RedactionTestContext) { - this.timeout(60000); - // Create a few users and a room. - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); - const badUserId = await badUser.getUserId(); - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`Test isn't setup correctly`); + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir redact ${badUserId} --room ${targetRoom}`, + }); } - const mjolnirUserId = await draupnir.client.getUserId(); - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - this.moderator = moderator; - await moderator.joinRoom(this.config.managementRoom); - const targetRooms: string[] = []; - for (let i = 0; i < 5; i++) { - const targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]}); - await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); - await badUser.joinRoom(targetRoom); - await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); - targetRooms.push(targetRoom); + ); - // Sandwich irrelevant messages in bad messages. - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - await Promise.all([...Array(50).keys()].map((j) => moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: `Irrelevant Message #${j}`}))); - for (let j = 0; j < 5; j++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await getMessagesByUserIn( + moderator, + badUserId, + targetRoom, + 1000, + function (events) { + events.map((e) => { + if (e.type === "m.room.member") { + assert.equal( + Object.keys(e.content).length, + 1, + "Only membership should be left on the membership even when it has been redacted." + ); + } else if (Object.keys(e.content).length !== 0) { + throw new Error( + `This event should have been redacted: ${JSON.stringify(e, null, 2)}` + ); } - await Promise.all([...Array(50).keys()].map((j) => moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: `Irrelevant Message #${j}`}))); - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + }); } + ); + } as unknown as Mocha.AsyncFunc + ); - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return await moderator.sendMessage(draupnir.managementRoomID, { msgtype: 'm.text', body: `!draupnir redact ${badUserId}` }); + it( + "Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id in multiple rooms.", + async function (this: RedactionTestContext) { + this.timeout(60000); + // Create a few users and a room. + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "spammer-needs-redacting" }, + }); + const badUserId = await badUser.getUserId(); + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`Test isn't setup correctly`); + } + const mjolnirUserId = await draupnir.client.getUserId(); + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + const targetRooms: string[] = []; + for (let i = 0; i < 5; i++) { + const targetRoom = await moderator.createRoom({ + invite: [await badUser.getUserId(), mjolnirUserId], }); + await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + await badUser.joinRoom(targetRoom); + await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms add ${targetRoom}`, + }); + targetRooms.push(targetRoom); - await Promise.all(targetRooms.map(async targetRoom => { - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) { - events.map(e => { - if (e.type === 'm.room.member') { - assert.equal(Object.keys(e.content).length, 1, "Only membership should be left on the membership even when it has been redacted.") - } else if (Object.keys(e.content).length !== 0) { - throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`) - } - }) + // Sandwich irrelevant messages in bad messages. + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); + await Promise.all( + [...Array(50).keys()].map((j) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text", + body: `Irrelevant Message #${j}`, }) - })); - } as unknown as Mocha.AsyncFunc); - it("Redacts a single event when instructed to.", async function (this: RedactionTestContext) { - this.timeout(60000); - // Create a few users and a room. - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`Test isn't setup correctly`); + ) + ); + for (let j = 0; j < 5; j++) { + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); } - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - this.moderator = moderator; - await moderator.joinRoom(this.config.managementRoom); - const targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), draupnir.clientUserID]}); - await moderator.setUserPowerLevel(draupnir.clientUserID, targetRoom, 100); - await badUser.joinRoom(targetRoom); - await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); - const eventToRedact = await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await Promise.all( + [...Array(50).keys()].map((j) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text", + body: `Irrelevant Message #${j}`, + }) + ) + ); + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); + } + + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir redact ${badUserId}`, + }); + } + ); + + await Promise.all( + targetRooms.map(async (targetRoom) => { + await getMessagesByUserIn( + moderator, + badUserId, + targetRoom, + 1000, + function (events) { + events.map((e) => { + if (e.type === "m.room.member") { + assert.equal( + Object.keys(e.content).length, + 1, + "Only membership should be left on the membership even when it has been redacted." + ); + } else if (Object.keys(e.content).length !== 0) { + throw new Error( + `This event should have been redacted: ${JSON.stringify(e, null, 2)}` + ); + } + }); + } + ); + }) + ); + } as unknown as Mocha.AsyncFunc + ); + it("Redacts a single event when instructed to.", async function ( + this: RedactionTestContext + ) { + this.timeout(60000); + // Create a few users and a room. + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "spammer-needs-redacting" }, + }); + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`Test isn't setup correctly`); + } + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + const targetRoom = await moderator.createRoom({ + invite: [await badUser.getUserId(), draupnir.clientUserID], + }); + await moderator.setUserPowerLevel(draupnir.clientUserID, targetRoom, 100); + await badUser.joinRoom(targetRoom); + await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms add ${targetRoom}`, + }); + const eventToRedact = await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`}); + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`, }); + } + ); - const redactedEvent = await moderator.getEvent(targetRoom, eventToRedact); - assert.equal(Object.keys(redactedEvent.content).length, 0, "This event should have been redacted"); - } as unknown as Mocha.AsyncFunc) + const redactedEvent = await moderator.getEvent(targetRoom, eventToRedact); + assert.equal( + Object.keys(redactedEvent.content).length, + 0, + "This event should have been redacted" + ); + } as unknown as Mocha.AsyncFunc); }); diff --git a/test/integration/commands/roomsTest.ts b/test/integration/commands/roomsTest.ts index 6bf4c935..45e5f359 100644 --- a/test/integration/commands/roomsTest.ts +++ b/test/integration/commands/roomsTest.ts @@ -2,43 +2,88 @@ import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; import { getFirstReaction, getFirstReply } from "./commandUtils"; import { draupnirSafeEmitter, DraupnirTestContext } from "../mjolnirSetupUtils"; -import { MatrixClient } from 'matrix-bot-sdk'; +import { MatrixClient } from "matrix-bot-sdk"; interface RoomsTestContext extends DraupnirTestContext { - moderator?: MatrixClient; + moderator?: MatrixClient; } describe("Test: The rooms commands", function () { - // If a test has a timeout while awaitng on a promise then we never get given control back. - afterEach(function() { - this.moderator?.stop(); - }); + // If a test has a timeout while awaitng on a promise then we never get given control back. + afterEach(function () { + this.moderator?.stop(); + }); - it('Mjolnir can protect a room, show that it is protected and then stop protecting the room.', async function(this: RoomsTestContext) { - // Create a few users and a room. - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`Test isn't setup correctly`); - } - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - this.moderator = moderator; - await moderator.joinRoom(this.config.managementRoom); - const targetRoom = await moderator.createRoom({ invite: [draupnir.clientUserID]}); - await moderator.setUserPowerLevel(draupnir.clientUserID, targetRoom, 100); + it( + "Mjolnir can protect a room, show that it is protected and then stop protecting the room.", + async function (this: RoomsTestContext) { + // Create a few users and a room. + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`Test isn't setup correctly`); + } + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + const targetRoom = await moderator.createRoom({ + invite: [draupnir.clientUserID], + }); + await moderator.setUserPowerLevel(draupnir.clientUserID, targetRoom, 100); - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); - }); - let protectedRoomsMessage = await getFirstReply(draupnirSafeEmitter(), draupnir.managementRoomID, async () => { - return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms`}); - }) - assert.equal(protectedRoomsMessage['content']['body'].includes('2'), true, "There should be two protected rooms (including the management room)"); - await getFirstReaction(draupnirSafeEmitter(), draupnir.managementRoomID, '✅', async () => { - return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms remove ${targetRoom}`}); - }); - protectedRoomsMessage = await getFirstReply(draupnirSafeEmitter(), draupnir.managementRoomID, async () => { - return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms`}); - }) - assert.equal(protectedRoomsMessage['content']['body'].includes('1'), true, "Only the management room should be protected."); - } as unknown as Mocha.AsyncFunc) -}) + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms add ${targetRoom}`, + }); + } + ); + let protectedRoomsMessage = await getFirstReply( + draupnirSafeEmitter(), + draupnir.managementRoomID, + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms`, + }); + } + ); + assert.equal( + protectedRoomsMessage["content"]["body"].includes("2"), + true, + "There should be two protected rooms (including the management room)" + ); + await getFirstReaction( + draupnirSafeEmitter(), + draupnir.managementRoomID, + "✅", + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms remove ${targetRoom}`, + }); + } + ); + protectedRoomsMessage = await getFirstReply( + draupnirSafeEmitter(), + draupnir.managementRoomID, + async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir rooms`, + }); + } + ); + assert.equal( + protectedRoomsMessage["content"]["body"].includes("1"), + true, + "Only the management room should be protected." + ); + } as unknown as Mocha.AsyncFunc + ); +}); diff --git a/test/integration/commands/shutdownCommandTest.ts b/test/integration/commands/shutdownCommandTest.ts index d846c364..cdf9a845 100644 --- a/test/integration/commands/shutdownCommandTest.ts +++ b/test/integration/commands/shutdownCommandTest.ts @@ -5,62 +5,71 @@ import { DraupnirTestContext, draupnirClient } from "../mjolnirSetupUtils"; import { MatrixClient, MatrixError } from "matrix-bot-sdk"; import { Task } from "matrix-protection-suite"; -describe("Test: shutdown command", function() { - let client: MatrixClient; - this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "shutdown-command" }}); - await client.start(); - }) - this.afterEach(async function () { - client.stop(); - }) - it("Mjolnir asks synapse to shut down a channel", async function(this: DraupnirTestContext) { - this.timeout(20000); - const badRoom = await client.createRoom(); - const draupnir = this.draupnir; - const draupnirMatrixClient = draupnirClient(); - if (draupnir === undefined || draupnirMatrixClient === null) { - throw new TypeError(`setup code is wrong`); - } - await client.joinRoom(draupnir.managementRoomID); +describe("Test: shutdown command", function () { + let client: MatrixClient; + this.beforeEach(async function () { + client = await newTestUser(this.config.homeserverUrl, { + name: { contains: "shutdown-command" }, + }); + await client.start(); + }); + this.afterEach(async function () { + client.stop(); + }); + it("Mjolnir asks synapse to shut down a channel", async function ( + this: DraupnirTestContext + ) { + this.timeout(20000); + const badRoom = await client.createRoom(); + const draupnir = this.draupnir; + const draupnirMatrixClient = draupnirClient(); + if (draupnir === undefined || draupnirMatrixClient === null) { + throw new TypeError(`setup code is wrong`); + } + await client.joinRoom(draupnir.managementRoomID); - const reply1 = new Promise((resolve) => { - void Task((async () => { - const msgid = await client.sendMessage(draupnir.managementRoomID, {msgtype: "m.text", body: `!draupnir shutdown room ${badRoom} closure test`}); - client.on('room.event', (roomId, event) => { - if ( - roomId === draupnir.managementRoomID - && event?.type === "m.reaction" - && event.sender === draupnir.clientUserID - && event.content?.["m.relates_to"]?.event_id === msgid - ) { - resolve(event); - } - }); - })()) - }); + const reply1 = new Promise((resolve) => { + void Task( + (async () => { + const msgid = await client.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: `!draupnir shutdown room ${badRoom} closure test`, + }); + client.on("room.event", (roomId, event) => { + if ( + roomId === draupnir.managementRoomID && + event?.type === "m.reaction" && + event.sender === draupnir.clientUserID && + event.content?.["m.relates_to"]?.event_id === msgid + ) { + resolve(event); + } + }); + })() + ); + }); - const reply2 = new Promise((resolve) => { - draupnirMatrixClient.on('room.event', (roomId, event) => { - if ( - roomId !== draupnir.managementRoomID - && roomId !== badRoom - && event?.type === "m.room.message" - && event.sender === draupnir.clientUserID - && event.content?.body === "closure test" - ) { - resolve(event); - } - }); - }); + const reply2 = new Promise((resolve) => { + draupnirMatrixClient.on("room.event", (roomId, event) => { + if ( + roomId !== draupnir.managementRoomID && + roomId !== badRoom && + event?.type === "m.room.message" && + event.sender === draupnir.clientUserID && + event.content?.body === "closure test" + ) { + resolve(event); + } + }); + }); - await reply1 - await reply2 + await reply1; + await reply2; - await assert.rejects(client.joinRoom(badRoom), (e: MatrixError) => { - assert.equal(e.statusCode, 403); - assert.equal(e.body.error, "This room has been blocked on this server"); - return true; - }); - } as unknown as Mocha.AsyncFunc); + await assert.rejects(client.joinRoom(badRoom), (e: MatrixError) => { + assert.equal(e.statusCode, 403); + assert.equal(e.body.error, "This room has been blocked on this server"); + return true; + }); + } as unknown as Mocha.AsyncFunc); }); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 0f1bb0cc..666b98f5 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -1,8 +1,17 @@ -import { MatrixRoomReference, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE } from "matrix-protection-suite"; +import { + MatrixRoomReference, + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, +} from "matrix-protection-suite"; import { constructWebAPIs } from "../../src/DraupnirBotMode"; import { read as configRead } from "../../src/config"; import { patchMatrixClient } from "../../src/utils"; -import { DraupnirTestContext, draupnirClient, makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils"; +import { + DraupnirTestContext, + draupnirClient, + makeMjolnir, + teardownManagementRoom, +} from "./mjolnirSetupUtils"; patchMatrixClient(); @@ -12,52 +21,77 @@ patchMatrixClient(); // testing code, which is problematic when we want to create a fresh management room for each test. // So there is some code in here to "undo" the mutation after we stop Mjolnir syncing. export const mochaHooks = { - beforeEach: [ - async function(this: DraupnirTestContext) { - console.error("---- entering test", JSON.stringify(this.currentTest?.title)); // Makes MatrixClient error logs a bit easier to parse. - console.log("mochaHooks.beforeEach"); - const test = MatrixRoomReference.fromPermalink('https://matrix.to/#/!JzRjamSLPHAikHkPab%3Alocalhost%3A9999?via=localhost:9999'); - console.log(test) - // Sometimes it takes a little longer to register users. - this.timeout(30000); - const config = this.config = configRead(); - this.managementRoomAlias = config.managementRoom; - this.draupnir = await makeMjolnir(config); - const draupnirMatrixClient = draupnirClient(); - if (draupnirMatrixClient === null) { - throw new TypeError(`setup code is broken`); - } - config.RUNTIME.client = draupnirMatrixClient; - await Promise.all([ - this.draupnir.client.setAccountData(MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, { rooms: [] }), - this.draupnir.client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, { references: [] }), - ]); - await this.draupnir.start(); - this.apis = constructWebAPIs(this.draupnir); - await this.apis.start(); - await draupnirClient()?.start(); - console.log("mochaHooks.beforeEach DONE"); - } - ], - afterEach: [ - async function(this: DraupnirTestContext) { - this.timeout(10000) - this.apis?.stop(); - draupnirClient()?.stop(); - this.draupnir?.stop(); + beforeEach: [ + async function (this: DraupnirTestContext) { + console.error( + "---- entering test", + JSON.stringify(this.currentTest?.title) + ); // Makes MatrixClient error logs a bit easier to parse. + console.log("mochaHooks.beforeEach"); + const test = MatrixRoomReference.fromPermalink( + "https://matrix.to/#/!JzRjamSLPHAikHkPab%3Alocalhost%3A9999?via=localhost:9999" + ); + console.log(test); + // Sometimes it takes a little longer to register users. + this.timeout(30000); + const config = (this.config = configRead()); + this.managementRoomAlias = config.managementRoom; + this.draupnir = await makeMjolnir(config); + const draupnirMatrixClient = draupnirClient(); + if (draupnirMatrixClient === null) { + throw new TypeError(`setup code is broken`); + } + config.RUNTIME.client = draupnirMatrixClient; + await Promise.all([ + this.draupnir.client.setAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + { rooms: [] } + ), + this.draupnir.client.setAccountData( + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, + { references: [] } + ), + ]); + await this.draupnir.start(); + this.apis = constructWebAPIs(this.draupnir); + await this.apis.start(); + await draupnirClient()?.start(); + console.log("mochaHooks.beforeEach DONE"); + }, + ], + afterEach: [ + async function (this: DraupnirTestContext) { + this.timeout(10000); + this.apis?.stop(); + draupnirClient()?.stop(); + this.draupnir?.stop(); - // remove alias from management room and leave it. - if (this.draupnir !== undefined) { - await Promise.all([ - this.draupnir.client.setAccountData(MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, { rooms: [] }), - this.draupnir.client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, { references: [] }), - ]); - const client = draupnirClient(); - if (client !== null && this.managementRoomAlias !== undefined) { - await teardownManagementRoom(client, this.draupnir.managementRoomID, this.managementRoomAlias); - } - } - console.error("---- completed test", JSON.stringify(this.currentTest?.title), "\n\n"); // Makes MatrixClient error logs a bit easier to parse. + // remove alias from management room and leave it. + if (this.draupnir !== undefined) { + await Promise.all([ + this.draupnir.client.setAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + { rooms: [] } + ), + this.draupnir.client.setAccountData( + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, + { references: [] } + ), + ]); + const client = draupnirClient(); + if (client !== null && this.managementRoomAlias !== undefined) { + await teardownManagementRoom( + client, + this.draupnir.managementRoomID, + this.managementRoomAlias + ); } - ] + } + console.error( + "---- completed test", + JSON.stringify(this.currentTest?.title), + "\n\n" + ); // Makes MatrixClient error logs a bit easier to parse. + }, + ], }; diff --git a/test/integration/helloTest.ts b/test/integration/helloTest.ts index 27734134..cea0caae 100644 --- a/test/integration/helloTest.ts +++ b/test/integration/helloTest.ts @@ -1,36 +1,49 @@ import { MatrixClient } from "matrix-bot-sdk"; -import { newTestUser, noticeListener } from "./clientHelper" +import { newTestUser, noticeListener } from "./clientHelper"; import { DraupnirTestContext } from "./mjolnirSetupUtils"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder } from "matrix-protection-suite"; -describe("Test: !help command", function() { - let client: MatrixClient; - this.beforeEach(async function (this: DraupnirTestContext) { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "-" }});; - await client.start(); - } as unknown as Mocha.AsyncFunc) - this.afterEach(async function () { - client.stop(); - } as unknown as Mocha.AsyncFunc) - it('Mjolnir responded to !mjolnir help', async function(this: DraupnirTestContext) { - this.timeout(30000); - // send a messgage - const draupnir = this.draupnir; - const clientEmitter = new SafeMatrixEmitterWrapper(client, DefaultEventDecoder); - if (draupnir === undefined) { - throw new TypeError(`setup code is wrong`); - } - await client.joinRoom(this.config.managementRoom); - // listener for getting the event reply - const reply = new Promise((resolve) => { - clientEmitter.on('room.message', noticeListener(draupnir.managementRoomID, (event) => { - if (event.content.body.includes("which can be used")) { - resolve(event); - } - })) - }); - await client.sendMessage(draupnir.managementRoomID, {msgtype: "m.text", body: "!draupnir help"}) - await reply - } as unknown as Mocha.AsyncFunc) -}) +describe("Test: !help command", function () { + let client: MatrixClient; + this.beforeEach(async function (this: DraupnirTestContext) { + client = await newTestUser(this.config.homeserverUrl, { + name: { contains: "-" }, + }); + await client.start(); + } as unknown as Mocha.AsyncFunc); + this.afterEach(async function () { + client.stop(); + } as unknown as Mocha.AsyncFunc); + it("Mjolnir responded to !mjolnir help", async function ( + this: DraupnirTestContext + ) { + this.timeout(30000); + // send a messgage + const draupnir = this.draupnir; + const clientEmitter = new SafeMatrixEmitterWrapper( + client, + DefaultEventDecoder + ); + if (draupnir === undefined) { + throw new TypeError(`setup code is wrong`); + } + await client.joinRoom(this.config.managementRoom); + // listener for getting the event reply + const reply = new Promise((resolve) => { + clientEmitter.on( + "room.message", + noticeListener(draupnir.managementRoomID, (event) => { + if (event.content.body.includes("which can be used")) { + resolve(event); + } + }) + ); + }); + await client.sendMessage(draupnir.managementRoomID, { + msgtype: "m.text", + body: "!draupnir help", + }); + await reply; + } as unknown as Mocha.AsyncFunc); +}); diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 532cc31c..5fd0aeac 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -3,18 +3,24 @@ */ import { draupnirClient, makeMjolnir } from "./mjolnirSetupUtils"; -import { read as configRead } from '../../src/config'; +import { read as configRead } from "../../src/config"; import { constructWebAPIs } from "../../src/DraupnirBotMode"; import { SqliteRoomStateBackingStore } from "../../src/backingstore/better-sqlite3/SqliteRoomStateBackingStore"; import path from "path"; import { DefaultEventDecoder } from "matrix-protection-suite"; void (async () => { - const config = configRead(); - const mjolnir = await makeMjolnir(config, new SqliteRoomStateBackingStore(path.join(config.dataPath, 'room-state-backing-store.db'), DefaultEventDecoder)); - console.info(`management room ${mjolnir.managementRoom.toPermalink()}`); - await mjolnir.start(); - const apis = constructWebAPIs(mjolnir); - await draupnirClient()?.start(); - await apis.start(); + const config = configRead(); + const mjolnir = await makeMjolnir( + config, + new SqliteRoomStateBackingStore( + path.join(config.dataPath, "room-state-backing-store.db"), + DefaultEventDecoder + ) + ); + console.info(`management room ${mjolnir.managementRoom.toPermalink()}`); + await mjolnir.start(); + const apis = constructWebAPIs(mjolnir); + await draupnirClient()?.start(); + await apis.start(); })(); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index cf4d1975..dfd1947d 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -14,32 +14,48 @@ See the License for the specific language governing permissions and limitations under the License. */ import { - MatrixClient, - PantalaimonClient, - MemoryStorageProvider, - LogService, - LogLevel, - RichConsoleLogger + MatrixClient, + PantalaimonClient, + MemoryStorageProvider, + LogService, + LogLevel, + RichConsoleLogger, } from "matrix-bot-sdk"; import { overrideRatelimitForUser, registerUser } from "./clientHelper"; import { initializeSentry, patchMatrixClient } from "../../src/utils"; import { IConfig } from "../../src/config"; import { Draupnir } from "../../src/Draupnir"; import { makeDraupnirBotModeFromConfig } from "../../src/DraupnirBotMode"; -import { SafeMatrixEmitter, SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { DefaultEventDecoder, RoomStateBackingStore } from "matrix-protection-suite"; +import { + SafeMatrixEmitter, + SafeMatrixEmitterWrapper, +} from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + DefaultEventDecoder, + RoomStateBackingStore, +} from "matrix-protection-suite"; import { WebAPIs } from "../../src/webapis/WebAPIs"; patchMatrixClient(); // they are add [key: string]: any to their interface, amazing. -export type SafeMochaContext = Pick +export type SafeMochaContext = Pick< + Mocha.Context, + | "test" + | "currentTest" + | "runnable" + | "timeout" + | "slow" + | "skip" + | "retries" + | "done" +>; export interface DraupnirTestContext extends SafeMochaContext { - draupnir?: Draupnir - managementRoomAlias?: string, - apis?: WebAPIs, - config: IConfig, + draupnir?: Draupnir; + managementRoomAlias?: string; + apis?: WebAPIs; + config: IConfig; } /** @@ -48,69 +64,97 @@ export interface DraupnirTestContext extends SafeMochaContext { * @param alias The alias of the room. * @returns The room ID of the aliased room. */ -export async function ensureAliasedRoomExists(client: MatrixClient, alias: string): Promise { - try { - return await client.resolveRoom(alias); - } catch (e) { - if (e?.body?.errcode === 'M_NOT_FOUND') { - console.info(`${alias} hasn't been created yet, so we're making it now.`) - const roomId = await client.createRoom({ - visibility: "public", - }); - await client.createRoomAlias(alias, roomId); - return roomId - } - throw e; +export async function ensureAliasedRoomExists( + client: MatrixClient, + alias: string +): Promise { + try { + return await client.resolveRoom(alias); + } catch (e) { + if (e?.body?.errcode === "M_NOT_FOUND") { + console.info(`${alias} hasn't been created yet, so we're making it now.`); + const roomId = await client.createRoom({ + visibility: "public", + }); + await client.createRoomAlias(alias, roomId); + return roomId; } + throw e; + } } async function configureMjolnir(config: IConfig) { - // Initialize error monitoring as early as possible. - initializeSentry(config); - try { - await registerUser(config.homeserverUrl, config.pantalaimon.username, config.pantalaimon.username, config.pantalaimon.password, true) - } catch (e) { - if (e?.body?.errcode === 'M_USER_IN_USE') { - console.log(`${config.pantalaimon.username} already registered, skipping`); - return; - } - throw e; - }; + // Initialize error monitoring as early as possible. + initializeSentry(config); + try { + await registerUser( + config.homeserverUrl, + config.pantalaimon.username, + config.pantalaimon.username, + config.pantalaimon.password, + true + ); + } catch (e) { + if (e?.body?.errcode === "M_USER_IN_USE") { + console.log( + `${config.pantalaimon.username} already registered, skipping` + ); + return; + } + throw e; + } } export function draupnir(): Draupnir | null { - return globalMjolnir; + return globalMjolnir; } export function draupnirClient(): MatrixClient | null { - return globalClient; + return globalClient; } export function draupnirSafeEmitter(): SafeMatrixEmitter { - if (globalSafeEmitter !== undefined) { - return globalSafeEmitter; - } - throw new TypeError(`Setup code didn't run properly`); + if (globalSafeEmitter !== undefined) { + return globalSafeEmitter; + } + throw new TypeError(`Setup code didn't run properly`); } -let globalClient: MatrixClient | null +let globalClient: MatrixClient | null; let globalMjolnir: Draupnir | null; let globalSafeEmitter: SafeMatrixEmitter | undefined; /** * Return a test instance of Mjolnir. */ -export async function makeMjolnir(config: IConfig, backingStore?: RoomStateBackingStore): Promise { - await configureMjolnir(config); - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); - LogService.info("test/mjolnirSetupUtils", "Starting bot..."); - const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()); - const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password); - await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId()); - await ensureAliasedRoomExists(client, config.managementRoom); - const mj = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config, backingStore); - globalClient = client; - globalMjolnir = mj; - globalSafeEmitter = new SafeMatrixEmitterWrapper(client, DefaultEventDecoder); - return mj; +export async function makeMjolnir( + config: IConfig, + backingStore?: RoomStateBackingStore +): Promise { + await configureMjolnir(config); + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); + LogService.info("test/mjolnirSetupUtils", "Starting bot..."); + const pantalaimon = new PantalaimonClient( + config.homeserverUrl, + new MemoryStorageProvider() + ); + const client = await pantalaimon.createClientWithCredentials( + config.pantalaimon.username, + config.pantalaimon.password + ); + await overrideRatelimitForUser( + config.homeserverUrl, + await client.getUserId() + ); + await ensureAliasedRoomExists(client, config.managementRoom); + const mj = await makeDraupnirBotModeFromConfig( + client, + new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), + config, + backingStore + ); + globalClient = client; + globalMjolnir = mj; + globalSafeEmitter = new SafeMatrixEmitterWrapper(client, DefaultEventDecoder); + return mj; } /** @@ -119,7 +163,11 @@ export async function makeMjolnir(config: IConfig, backingStore?: RoomStateBacki * @param roomId The roomId of the room to leave. * @param alias The alias to remove from the room. */ -export async function teardownManagementRoom(client: MatrixClient, roomId: string, alias: string) { - await client.deleteRoomAlias(alias); - await client.leaveRoom(roomId); +export async function teardownManagementRoom( + client: MatrixClient, + roomId: string, + alias: string +) { + await client.deleteRoomAlias(alias); + await client.leaveRoom(roomId); } diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 45825a92..f7a4c114 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -1,65 +1,97 @@ import { MatrixClient } from "matrix-bot-sdk"; import { newTestUser } from "./clientHelper"; import { DraupnirTestContext } from "./mjolnirSetupUtils"; -import { ActionResult, MatrixRoomReference, Ok, Protection, ProtectionDescription, StandardProtectionSettings, StringRoomID, Task } from "matrix-protection-suite"; +import { + ActionResult, + MatrixRoomReference, + Ok, + Protection, + ProtectionDescription, + StandardProtectionSettings, + StringRoomID, + Task, +} from "matrix-protection-suite"; -describe("Test: Report polling", function() { - let client: MatrixClient; - this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }}); - }) - it("Mjolnir correctly retrieves a report from synapse", async function(this: DraupnirTestContext) { - this.timeout(40000); - const draupnir = this.draupnir; - if (draupnir === undefined) { - throw new TypeError(`Test didn't setup properly`); - } - const protectedRoomId = await draupnir.client.createRoom({ invite: [await client.getUserId()] }); - await client.joinRoom(protectedRoomId); - await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID)); +describe("Test: Report polling", function () { + let client: MatrixClient; + this.beforeEach(async function () { + client = await newTestUser(this.config.homeserverUrl, { + name: { contains: "protection-settings" }, + }); + }); + it("Mjolnir correctly retrieves a report from synapse", async function ( + this: DraupnirTestContext + ) { + this.timeout(40000); + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`Test didn't setup properly`); + } + const protectedRoomId = await draupnir.client.createRoom({ + invite: [await client.getUserId()], + }); + await client.joinRoom(protectedRoomId); + await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( + MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID) + ); - const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); - await new Promise(resolve => { - const testProtectionDescription: ProtectionDescription = { - name: "jYvufI", - description: "A test protection", - capabilities: {}, - defaultCapabilities: {}, - factory: function (_description, _protectedRoomsSet, _context, _capabilities, _settings): ActionResult> { - return Ok({ - handleEventReport(report) { - if (report.reason === "x5h1Je") { - resolve(null); - } - return Promise.resolve(Ok(undefined)); - }, - description: testProtectionDescription, - requiredEventPermissions: [], - requiredPermissions: [], - requiredStatePermissions: [], - }) - }, - protectionSettings: new StandardProtectionSettings( - {}, - {} - ) + const eventId = await client.sendMessage(protectedRoomId, { + msgtype: "m.text", + body: "uwNd3q", + }); + await new Promise((resolve) => { + const testProtectionDescription: ProtectionDescription = { + name: "jYvufI", + description: "A test protection", + capabilities: {}, + defaultCapabilities: {}, + factory: function ( + _description, + _protectedRoomsSet, + _context, + _capabilities, + _settings + ): ActionResult> { + return Ok({ + handleEventReport(report) { + if (report.reason === "x5h1Je") { + resolve(null); + } + return Promise.resolve(Ok(undefined)); + }, + description: testProtectionDescription, + requiredEventPermissions: [], + requiredPermissions: [], + requiredStatePermissions: [], + }); + }, + protectionSettings: new StandardProtectionSettings({}, {}), + }; + void Task( + (async () => { + await draupnir.protectedRoomsSet.protections.addProtection( + testProtectionDescription, + {}, + draupnir.protectedRoomsSet, + draupnir + ); + await client.doRequest( + "POST", + `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, + "", + { + reason: "x5h1Je", } - void Task((async () => { - await draupnir.protectedRoomsSet.protections.addProtection(testProtectionDescription, {}, draupnir.protectedRoomsSet, draupnir); - await client.doRequest( - "POST", - `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { - reason: "x5h1Je" - } - ); - })()); - }); - // So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26) - // because the promise above gets resolved before we finish awaiting the report sending request on L#31, - // then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond. - // Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling. - // Ok, well apparently that needs a big refactor to change, but if you change the config before running this test, - // then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326. - await new Promise(resolve => setTimeout(resolve, 1000)); - } as unknown as Mocha.AsyncFunc); + ); + })() + ); + }); + // So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26) + // because the promise above gets resolved before we finish awaiting the report sending request on L#31, + // then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond. + // Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling. + // Ok, well apparently that needs a big refactor to change, but if you change the config before running this test, + // then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326. + await new Promise((resolve) => setTimeout(resolve, 1000)); + } as unknown as Mocha.AsyncFunc); }); diff --git a/test/integration/throttleQueueTest.ts b/test/integration/throttleQueueTest.ts index d3fcbf8d..d74f5947 100644 --- a/test/integration/throttleQueueTest.ts +++ b/test/integration/throttleQueueTest.ts @@ -1,79 +1,85 @@ import { ThrottlingQueue } from "../../src/queues/ThrottlingQueue"; -describe("Test: ThrottlingQueue", function() { - it("Tasks enqueued with `push()` are executed exactly once and in the right order", async function() { - this.timeout(20000); +describe("Test: ThrottlingQueue", function () { + it("Tasks enqueued with `push()` are executed exactly once and in the right order", async function () { + this.timeout(20000); - const queue = new ThrottlingQueue(this.mjolnir, 10); - const state = new Map(); - const promises: Promise[] = []; - for (let counter = 0; counter < 10; ++counter) { - const i = counter; - const promise = queue.push(async () => { - if (state.get(i)) { - throw new Error(`We shouldn't have set state[${i}] yet`); - } - state.set(i, true); - for (let j = 0; j < i; ++j) { - if (!state.get(j)) { - throw new Error(`We should have set state[${j}] already`); - } - } - }); - promises.push(promise); + const queue = new ThrottlingQueue(this.mjolnir, 10); + const state = new Map(); + const promises: Promise[] = []; + for (let counter = 0; counter < 10; ++counter) { + const i = counter; + const promise = queue.push(async () => { + if (state.get(i)) { + throw new Error(`We shouldn't have set state[${i}] yet`); } - await Promise.all(promises); - for (let i = 0; i < 10; ++i) { - if (!state.get(i)) { - throw new Error(`This is the end of the test, we should have set state[${i}]`); - } + state.set(i, true); + for (let j = 0; j < i; ++j) { + if (!state.get(j)) { + throw new Error(`We should have set state[${j}] already`); + } } + }); + promises.push(promise); + } + await Promise.all(promises); + for (let i = 0; i < 10; ++i) { + if (!state.get(i)) { + throw new Error( + `This is the end of the test, we should have set state[${i}]` + ); + } + } - // Give code a little bit more time to trip itself, in case `promises` are accidentally - // resolved too early. - await new Promise(resolve => setTimeout(resolve, 1000)); + // Give code a little bit more time to trip itself, in case `promises` are accidentally + // resolved too early. + await new Promise((resolve) => setTimeout(resolve, 1000)); - queue.dispose(); - }); + queue.dispose(); + }); - it("Tasks enqueued with `push()` are executed exactly once and in the right order, even if we call `block()` at some point", async function() { - this.timeout(20000); - const queue = new ThrottlingQueue(this.mjolnir, 10); - const state = new Map(); - const promises: Promise[] = []; - for (let counter = 0; counter < 10; ++counter) { - const i = counter; - promises.push(queue.push(async () => { - if (state.get(i)) { - throw new Error(`We shouldn't have set state[${i}] yet`); - } - state.set(i, true); - for (let j = 0; j < i; ++j) { - queue.block(100); - if (!state.get(j)) { - throw new Error(`We should have set state[${j}] already`); - } - } - if (i % 2 === 0) { - // Arbitrary call to `delay()`. - queue.block(20); - } - })); - } + it("Tasks enqueued with `push()` are executed exactly once and in the right order, even if we call `block()` at some point", async function () { + this.timeout(20000); + const queue = new ThrottlingQueue(this.mjolnir, 10); + const state = new Map(); + const promises: Promise[] = []; + for (let counter = 0; counter < 10; ++counter) { + const i = counter; + promises.push( + queue.push(async () => { + if (state.get(i)) { + throw new Error(`We shouldn't have set state[${i}] yet`); + } + state.set(i, true); + for (let j = 0; j < i; ++j) { + queue.block(100); + if (!state.get(j)) { + throw new Error(`We should have set state[${j}] already`); + } + } + if (i % 2 === 0) { + // Arbitrary call to `delay()`. + queue.block(20); + } + }) + ); + } - queue.block(100); + queue.block(100); - await Promise.all(promises); - for (let i = 0; i < 10; ++i) { - if (!state.get(i)) { - throw new Error(`This is the end of the test, we should have set state[${i}]`); - } - } + await Promise.all(promises); + for (let i = 0; i < 10; ++i) { + if (!state.get(i)) { + throw new Error( + `This is the end of the test, we should have set state[${i}]` + ); + } + } - // Give code a little bit more time to trip itself, in case `promises` are accidentally - // resolved too early. - await new Promise(resolve => setTimeout(resolve, 1000)); + // Give code a little bit more time to trip itself, in case `promises` are accidentally + // resolved too early. + await new Promise((resolve) => setTimeout(resolve, 1000)); - queue.dispose(); - }); + queue.dispose(); + }); }); diff --git a/test/integration/throttleTest.ts b/test/integration/throttleTest.ts index a4555e39..93852c1c 100644 --- a/test/integration/throttleTest.ts +++ b/test/integration/throttleTest.ts @@ -3,19 +3,39 @@ import { newTestUser } from "./clientHelper"; import { getMessagesByUserIn } from "../../src/utils"; describe("Test: throttled users can function with Mjolnir.", function () { - it('throttled users survive being throttled by synapse', async function() { - const throttledUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "throttled" }, isThrottled: true }); - const throttledUserId = await throttledUser.getUserId(); - const targetRoom = await throttledUser.createRoom(); - // send enough messages to hit the rate limit. - await Promise.all([...Array(25).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`}))); - let messageCount = 0; - await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 25, (events) => { - messageCount += events.length; - }); - assert.equal(messageCount, 25, "There should have been 25 messages in this room"); - }) -}) + it("throttled users survive being throttled by synapse", async function () { + const throttledUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "throttled" }, + isThrottled: true, + }); + const throttledUserId = await throttledUser.getUserId(); + const targetRoom = await throttledUser.createRoom(); + // send enough messages to hit the rate limit. + await Promise.all( + [...Array(25).keys()].map((i) => + throttledUser.sendMessage(targetRoom, { + msgtype: "m.text.", + body: `Message #${i}`, + }) + ) + ); + let messageCount = 0; + await getMessagesByUserIn( + throttledUser, + throttledUserId, + targetRoom, + 25, + (events) => { + messageCount += events.length; + } + ); + assert.equal( + messageCount, + 25, + "There should have been 25 messages in this room" + ); + }); +}); /** * We used to have a test here that tested whether Mjolnir was going to carry out a redact order the default limits in a reasonable time scale. diff --git a/test/integration/timelinePaginationTest.ts b/test/integration/timelinePaginationTest.ts index 81fccdb4..28974181 100644 --- a/test/integration/timelinePaginationTest.ts +++ b/test/integration/timelinePaginationTest.ts @@ -8,91 +8,215 @@ import { TextMessageContent } from "matrix-protection-suite"; * Ensure that Mjolnir paginates only the necessary segment of the room timeline when backfilling. */ describe("Test: timeline pagination", function () { - it('does not paginate across the entire room history while backfilling.', async function() { - this.timeout(60000); - // Create a few users and a room. - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); - const badUserId = await badUser.getUserId(); - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - const targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]}); - await badUser.joinRoom(targetRoom); + it("does not paginate across the entire room history while backfilling.", async function () { + this.timeout(60000); + // Create a few users and a room. + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "spammer" }, + }); + const badUserId = await badUser.getUserId(); + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + const targetRoom = await moderator.createRoom({ + invite: [await badUser.getUserId()], + }); + await badUser.joinRoom(targetRoom); - // send some irrelevant messages - await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); - // bad guy sends 5 messages - for (let i = 0; i < 5; i++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - } - // send some irrelevant messages - await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); - // bad guy sends 1 extra message at the most recent edge of the timeline. - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - // then call this paignator and ensure that we don't go across the entire room history. - let cbCount = 0; - let eventCount = 0; - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) { - cbCount += 1; - eventCount += events.length; - events.map(e => { assert.equal(e.sender, badUserId, "All the events should be from the same sender"); }); - }); - assert.equal(cbCount, 1, "The callback only needs to be called once with all the messages because the events should be filtered."); - assert.equal(eventCount, 7, "There shouldn't be any more events (1 member event and 6 messages), and they should all be from the same account."); - }) - it('does not call the callback with an empty array when there are no relevant events', async function() { - this.timeout(60000); - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); - const badUserId = await badUser.getUserId(); - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - const targetRoom = await moderator.createRoom(); - // send some irrelevant messages - await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); - // The callback should not be called. - let cbCount = 0; - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, (_events) => { - cbCount += 1; - }); - assert.equal(cbCount, 0, "The callback should never get called"); - }) - it("The limit provided is respected", async function() { - this.timeout(60000); - const badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); - const badUserId = await badUser.getUserId(); - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - const targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]}); - await badUser.joinRoom(targetRoom); - // send some bad person messages - // bad guy sends 5 messages at the start of the timeline - for (let i = 0; i < 5; i++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - } - // send some irrelevant messages - await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); - let cbCount = 0; - await getMessagesByUserIn(moderator, "*spammer*", targetRoom, 200, (_events) => { - cbCount += 1; + // send some irrelevant messages + await Promise.all( + [...Array(200).keys()].map((i) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text.", + body: `Irrelevant Message #${i}`, + }) + ) + ); + // bad guy sends 5 messages + for (let i = 0; i < 5; i++) { + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); + } + // send some irrelevant messages + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text.", + body: `Irrelevant Message #${i}`, + }) + ) + ); + // bad guy sends 1 extra message at the most recent edge of the timeline. + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); + // then call this paignator and ensure that we don't go across the entire room history. + let cbCount = 0; + let eventCount = 0; + await getMessagesByUserIn( + moderator, + badUserId, + targetRoom, + 1000, + function (events) { + cbCount += 1; + eventCount += events.length; + events.map((e) => { + assert.equal( + e.sender, + badUserId, + "All the events should be from the same sender" + ); }); - // Remember that the limit is the number of events that getMessagesByUserIn has checked against the glob, - // not the number of events to provide to the callback. - // E.g. we don't want to paginate to the beginning of history just because less than 200 events match the glob, - // which is very likely if a user has only just started sending messages. - assert.equal(cbCount, 0, "The callback should never be called as the limit should be reached beforehand."); - await getMessagesByUserIn(moderator, "*spammer*", targetRoom, 205, (events) => { - cbCount += 1; - events.map(e => { assert.equal(e.sender, badUserId, "All the events should be from the same sender"); }); + } + ); + assert.equal( + cbCount, + 1, + "The callback only needs to be called once with all the messages because the events should be filtered." + ); + assert.equal( + eventCount, + 7, + "There shouldn't be any more events (1 member event and 6 messages), and they should all be from the same account." + ); + }); + it("does not call the callback with an empty array when there are no relevant events", async function () { + this.timeout(60000); + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "spammer" }, + }); + const badUserId = await badUser.getUserId(); + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + const targetRoom = await moderator.createRoom(); + // send some irrelevant messages + await Promise.all( + [...Array(200).keys()].map((i) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text.", + body: `Irrelevant Message #${i}`, + }) + ) + ); + // The callback should not be called. + let cbCount = 0; + await getMessagesByUserIn( + moderator, + badUserId, + targetRoom, + 1000, + (_events) => { + cbCount += 1; + } + ); + assert.equal(cbCount, 0, "The callback should never get called"); + }); + it("The limit provided is respected", async function () { + this.timeout(60000); + const badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "spammer" }, + }); + const badUserId = await badUser.getUserId(); + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + const targetRoom = await moderator.createRoom({ + invite: [await badUser.getUserId()], + }); + await badUser.joinRoom(targetRoom); + // send some bad person messages + // bad guy sends 5 messages at the start of the timeline + for (let i = 0; i < 5; i++) { + await badUser.sendMessage(targetRoom, { + msgtype: "m.text", + body: "Very Bad Stuff", + }); + } + // send some irrelevant messages + await Promise.all( + [...Array(200).keys()].map((i) => + moderator.sendMessage(targetRoom, { + msgtype: "m.text.", + body: `Irrelevant Message #${i}`, + }) + ) + ); + let cbCount = 0; + await getMessagesByUserIn( + moderator, + "*spammer*", + targetRoom, + 200, + (_events) => { + cbCount += 1; + } + ); + // Remember that the limit is the number of events that getMessagesByUserIn has checked against the glob, + // not the number of events to provide to the callback. + // E.g. we don't want to paginate to the beginning of history just because less than 200 events match the glob, + // which is very likely if a user has only just started sending messages. + assert.equal( + cbCount, + 0, + "The callback should never be called as the limit should be reached beforehand." + ); + await getMessagesByUserIn( + moderator, + "*spammer*", + targetRoom, + 205, + (events) => { + cbCount += 1; + events.map((e) => { + assert.equal( + e.sender, + badUserId, + "All the events should be from the same sender" + ); }); - assert.equal(cbCount, 1, "The callback should be called once with events matching the glob."); + } + ); + assert.equal( + cbCount, + 1, + "The callback should be called once with events matching the glob." + ); + }); + it("Gives the events to the callback ordered by youngest first (even more important when the limit is reached halfway through a chunk).", async function () { + this.timeout(60000); + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, }); - it("Gives the events to the callback ordered by youngest first (even more important when the limit is reached halfway through a chunk).", async function() { - this.timeout(60000); - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - const moderatorId = await moderator.getUserId(); - const targetRoom = await moderator.createRoom(); - for (let i = 0; i < 20; i++) { - await moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `${i}`}) - } - await getMessagesByUserIn(moderator, moderatorId, targetRoom, 5, (events) => { - const messageNumbers = events.map(event => parseInt((event.content as TextMessageContent).body, 10)); - messageNumbers.map(n => { assert.equal(n >= 15, true, "The youngest events should be given to the callback first."); }) + const moderatorId = await moderator.getUserId(); + const targetRoom = await moderator.createRoom(); + for (let i = 0; i < 20; i++) { + await moderator.sendMessage(targetRoom, { + msgtype: "m.text.", + body: `${i}`, + }); + } + await getMessagesByUserIn( + moderator, + moderatorId, + targetRoom, + 5, + (events) => { + const messageNumbers = events.map((event) => + parseInt((event.content as TextMessageContent).body, 10) + ); + messageNumbers.map((n) => { + assert.equal( + n >= 15, + true, + "The youngest events should be given to the callback first." + ); }); - }) + } + ); + }); }); diff --git a/test/integration/utilsTest.ts b/test/integration/utilsTest.ts index 9ad68b29..4fd72ebf 100644 --- a/test/integration/utilsTest.ts +++ b/test/integration/utilsTest.ts @@ -1,42 +1,54 @@ import { strict as assert } from "assert"; import { LogLevel } from "matrix-bot-sdk"; import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; -import { NoticeMessageContent, RoomEvent, Value } from "matrix-protection-suite"; +import { + NoticeMessageContent, + RoomEvent, + Value, +} from "matrix-protection-suite"; -describe("Test: utils", function() { - it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function(this: DraupnirTestContext) { - const managementRoomAlias = this.config.managementRoom; - const draupnir = this.draupnir; - const draupnirMatrixClient = draupnirClient(); - if (draupnir === undefined || draupnirMatrixClient === null) { - throw new TypeError(`Setup code is broken`); - } - const managementRoomOutput = draupnir.managementRoomOutput; - await draupnir.client.sendStateEvent( - draupnir.managementRoomID, - "m.room.canonical_alias", - "", - { alias: managementRoomAlias } - ); +describe("Test: utils", function () { + it( + "replaceRoomIdsWithPills correctly turns a room ID in to a pill", + async function (this: DraupnirTestContext) { + const managementRoomAlias = this.config.managementRoom; + const draupnir = this.draupnir; + const draupnirMatrixClient = draupnirClient(); + if (draupnir === undefined || draupnirMatrixClient === null) { + throw new TypeError(`Setup code is broken`); + } + const managementRoomOutput = draupnir.managementRoomOutput; + await draupnir.client.sendStateEvent( + draupnir.managementRoomID, + "m.room.canonical_alias", + "", + { alias: managementRoomAlias } + ); - const message: RoomEvent = await new Promise(resolve => { - draupnirMatrixClient.on('room.message', (roomId, event) => { - if (roomId === draupnir.managementRoomID) { - if (event.content?.body?.startsWith("it's")) { - resolve(event); - } - } - }) - void managementRoomOutput.logMessage(LogLevel.INFO, 'replaceRoomIdsWithPills test', - `it's fun here in ${draupnir.managementRoomID}`, - [draupnir.managementRoomID, "!myfaketestid:example.com"]); + const message: RoomEvent = await new Promise((resolve) => { + draupnirMatrixClient.on("room.message", (roomId, event) => { + if (roomId === draupnir.managementRoomID) { + if (event.content?.body?.startsWith("it's")) { + resolve(event); + } + } }); - if (!Value.Check(NoticeMessageContent, message.content)) { - throw new TypeError(`This test is written with the expectation logMessage will send a notice`); - } - assert.equal( - message.content.formatted_body, - `it's fun here in ${managementRoomAlias}` + void managementRoomOutput.logMessage( + LogLevel.INFO, + "replaceRoomIdsWithPills test", + `it's fun here in ${draupnir.managementRoomID}`, + [draupnir.managementRoomID, "!myfaketestid:example.com"] + ); + }); + if (!Value.Check(NoticeMessageContent, message.content)) { + throw new TypeError( + `This test is written with the expectation logMessage will send a notice` ); - } as unknown as Mocha.AsyncFunc); + } + assert.equal( + message.content.formatted_body, + `it's fun here in ${managementRoomAlias}` + ); + } as unknown as Mocha.AsyncFunc + ); }); diff --git a/test/scripts/memberQueryTest.ts b/test/scripts/memberQueryTest.ts index d3d79553..b874b717 100644 --- a/test/scripts/memberQueryTest.ts +++ b/test/scripts/memberQueryTest.ts @@ -16,94 +16,108 @@ import { MatrixClient } from "matrix-bot-sdk"; */ // const accessToken = "redacted"; -const client = new MatrixClient("https://matrix-client.matrix.org", accessToken); +const client = new MatrixClient( + "https://matrix-client.matrix.org", + accessToken +); const roomId = "!OGEhHVWSdvArJzumhm:matrix.org"; enum MemberFetchMethod { - JoinedMembers = "/joined_members", - Members = "/members", - State = "/state", + JoinedMembers = "/joined_members", + Members = "/members", + State = "/state", } -const shuffledMethods = - [MemberFetchMethod.JoinedMembers, MemberFetchMethod.Members, MemberFetchMethod.State] - .reduce((acc: MemberFetchMethod[], method: MemberFetchMethod) => { - return [...acc, ...[...Array(5)].map(_ => method)] - }, []); +const shuffledMethods = [ + MemberFetchMethod.JoinedMembers, + MemberFetchMethod.Members, + MemberFetchMethod.State, +].reduce((acc: MemberFetchMethod[], method: MemberFetchMethod) => { + return [...acc, ...[...Array(5)].map((_) => method)]; +}, []); // shuffle https://stackoverflow.com/a/12646864 for (let i = shuffledMethods.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const [methodJ, methodI] = [shuffledMethods[j], shuffledMethods[i]]; - if (methodJ && methodI) { - [shuffledMethods[i], shuffledMethods[j]] = [methodJ, methodI]; - } + const j = Math.floor(Math.random() * (i + 1)); + const [methodJ, methodI] = [shuffledMethods[j], shuffledMethods[i]]; + if (methodJ && methodI) { + [shuffledMethods[i], shuffledMethods[j]] = [methodJ, methodI]; + } } async function fetchStateWithMethod(method: MemberFetchMethod) { - switch (method) { - case MemberFetchMethod.JoinedMembers: - return await client.getJoinedRoomMembers(roomId); - case MemberFetchMethod.Members: - return await client.getRoomMembers(roomId, undefined, undefined, ['leave', 'ban']); - case MemberFetchMethod.State: - return await client.getRoomState(roomId); - default: - throw new TypeError(); - } + switch (method) { + case MemberFetchMethod.JoinedMembers: + return await client.getJoinedRoomMembers(roomId); + case MemberFetchMethod.Members: + return await client.getRoomMembers(roomId, undefined, undefined, [ + "leave", + "ban", + ]); + case MemberFetchMethod.State: + return await client.getRoomState(roomId); + default: + throw new TypeError(); + } } const times = new Map([ - [MemberFetchMethod.JoinedMembers, []], - [MemberFetchMethod.Members, []], - [MemberFetchMethod.State, []] + [MemberFetchMethod.JoinedMembers, []], + [MemberFetchMethod.Members, []], + [MemberFetchMethod.State, []], ]); function addTime(method: MemberFetchMethod, time: number) { - const entry = times.get(method); - if (entry === undefined) { - throw new TypeError() - } - entry.push(time); + const entry = times.get(method); + if (entry === undefined) { + throw new TypeError(); + } + entry.push(time); } function getTimes(method: MemberFetchMethod) { - return times.get(method); + return times.get(method); } function evenMedian(s: number[], mid: number): number | undefined { - const leftMid = s[mid - 1]; - const rightMid = s[mid]; - if (leftMid === undefined || rightMid === undefined) { - throw new TypeError(`Code is wrong bozo`) - } - return ((leftMid + rightMid) / 2); + const leftMid = s[mid - 1]; + const rightMid = s[mid]; + if (leftMid === undefined || rightMid === undefined) { + throw new TypeError(`Code is wrong bozo`); + } + return (leftMid + rightMid) / 2; } // https://stackoverflow.com/a/70806192 -function calculateMedian (arr: number[]): number | undefined { - if (!arr.length) return undefined; - const s = [...arr].sort((a, b) => a - b); - const mid = Math.floor(s.length / 2); - return s.length % 2 === 0 ? evenMedian(s, mid) : s[mid]; -}; +function calculateMedian(arr: number[]): number | undefined { + if (!arr.length) return undefined; + const s = [...arr].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 === 0 ? evenMedian(s, mid) : s[mid]; +} void (async () => { - for (const method of shuffledMethods) { - const start = Date.now(); - await fetchStateWithMethod(method); - const elapsedMs = Date.now() - start; - addTime(method, elapsedMs); - } + for (const method of shuffledMethods) { + const start = Date.now(); + await fetchStateWithMethod(method); + const elapsedMs = Date.now() - start; + addTime(method, elapsedMs); + } - for (const method of [MemberFetchMethod.JoinedMembers, MemberFetchMethod.Members, MemberFetchMethod.State]) { - const nextTimes = getTimes(method); - if (nextTimes === undefined) { - throw new TypeError(`Times shouldn't be undefined matey`); - } - const sum = nextTimes.reduce((a, b) => a + b, 0); - const mean = (sum / nextTimes.length) || 0; - const median = calculateMedian(nextTimes); - console.log(`${method}: total time elapsed ${sum / 1000}seconds, mean time ${mean}ms, median time ${median}ms`); + for (const method of [ + MemberFetchMethod.JoinedMembers, + MemberFetchMethod.Members, + MemberFetchMethod.State, + ]) { + const nextTimes = getTimes(method); + if (nextTimes === undefined) { + throw new TypeError(`Times shouldn't be undefined matey`); } + const sum = nextTimes.reduce((a, b) => a + b, 0); + const mean = sum / nextTimes.length || 0; + const median = calculateMedian(nextTimes); + console.log( + `${method}: total time elapsed ${sum / 1000}seconds, mean time ${mean}ms, median time ${median}ms` + ); + } })(); diff --git a/test/tsconfig.json b/test/tsconfig.json index d64dd1ed..40fbace7 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../tsconfig.json", - "include": ["."], - "compilerOptions": { - "noEmit": true, - } + "extends": "../tsconfig.json", + "include": ["."], + "compilerOptions": { + "noEmit": true + } } diff --git a/yarn.lock b/yarn.lock index 3a0b97c9..9e3f20e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2954,6 +2954,11 @@ prelude-ls@^1.2.1: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"