diff --git a/.eslintrc.js b/.eslintrc.js index 92659dad..7da1513e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,5 @@ module.exports = { + "extends": [ "plugin:editorconfig/all" ], "env": { "browser": false, "es6": true, @@ -6,11 +7,12 @@ module.exports = { }, "parser": "@typescript-eslint/parser", "parserOptions": { - "project": "tsconfig.json", + "project": ["tsconfig.json", "test/tsconfig.json"], "sourceType": "module" }, "plugins": [ - "@typescript-eslint" + "@typescript-eslint", + "editorconfig" ], "root": true, "rules": { diff --git a/config/default.yaml b/config/default.yaml index eb2dfc69..e9c001bf 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -203,6 +203,14 @@ protections: # (users will always be banned if they say a bad word) minutesBeforeTrusting: 20 +# The room state backing store writes a copy of the room state for all protected +# rooms to the data directory. +# It is recommended to enable this option unless you deploy Draupnir close to the +# homeserver and know that Draupnir is starting up quickly. If your homeserver can +# respond quickly to Draupnir's requests for `/state` then you might not need this option. +roomStateBackingStore: + enabled: false + # Options for advanced monitoring of the health of the bot. health: # healthz options. These options are best for use in container environments diff --git a/config/harness.yaml b/config/harness.yaml index 9e9303d5..a4ad86f8 100644 --- a/config/harness.yaml +++ b/config/harness.yaml @@ -34,7 +34,7 @@ autojoinOnlyIfManager: true # If `autojoinOnlyIfManager` is false, only the members in this space can invite # the bot to new rooms. -acceptInvitesFromSpace: '!example:example.org' +acceptInvitesFromSpace: "!example:example.org" # If the bot is invited to a room and it won't accept the invite (due to the # conditions above), report it to the management room. Defaults to disabled (no @@ -53,7 +53,6 @@ managementRoom: "#moderators:localhost:9999" # mainly involves "all-OK" messages, and debugging messages for when draupnir checks bans in a room. verboseLogging: false - # The log level for the logs themselves. One of DEBUG, INFO, WARN, and ERROR. # This should be at INFO or DEBUG in order to get support for Mjolnir problems. logLevel: "DEBUG" @@ -201,7 +200,3 @@ web: abuseReporting: # Whether to enable this feature. enabled: true - # A web API for a description of all the combined rules from watched banlists. - # GET /api/1/ruleserver/updates - ruleServer: - enabled: false diff --git a/package.json b/package.json index 7ad08ba5..096f848f 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "license": "AFL-3.0", "private": true, "scripts": { - "build": "tsc", - "postbuild": "yarn remove-tests-from-lib && yarn describe-version", + "build": "tsc --project test/tsconfig.json && tsc > /dev/null 2>&1", + "postbuild": "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": "eslint ./**/*.ts", + "lint": "eslint -c .eslintrc.js src/**/*.ts test/**/*.ts src/**/*.tsx", "start:dev": "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\"", @@ -23,6 +23,7 @@ "version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][^\"]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.9", "@types/config": "^3.3.1", "@types/crypto-js": "^4.2.2", "@types/express": "^4.17.21", @@ -40,6 +41,7 @@ "@typescript-eslint/parser": "^6.19.0", "crypto-js": "^4.2.0", "eslint": "^8.56", + "eslint-plugin-editorconfig": "^4.0.3", "expect": "^29.7.0", "mocha": "^10.2.0", "ts-mocha": "^10.0.0", @@ -49,7 +51,9 @@ "dependencies": { "@sentry/node": "^7.17.2", "@sentry/tracing": "^7.17.2", + "@sinclair/typebox": "~0.31.15", "await-lock": "^2.2.2", + "better-sqlite3": "^9.4.3", "body-parser": "^1.20.2", "config": "^3.3.9", "express": "^4.18", @@ -59,12 +63,18 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.12.1", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.12.1", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", "ulidx": "^2.2.1", "yaml": "^2.3.2" }, + "overrides": { + "matrix-bot-sdk": "$@vector-im/matrix-bot-sdk", + "@vector-im/matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.6.6-element.1" + }, "engines": { "node": ">=18.0.0" } diff --git a/src/Draupnir.ts b/src/Draupnir.ts new file mode 100644 index 00000000..d8c92cc3 --- /dev/null +++ b/src/Draupnir.ts @@ -0,0 +1,318 @@ +/** + * Copyright (C) 2022-2023 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { ActionResult, Client, ClientPlatform, ClientRooms, EventReport, Logger, MatrixRoomID, MatrixRoomReference, Membership, MembershipEvent, Ok, PolicyRoomManager, ProtectedRoomsSet, RoomEvent, RoomMembershipManager, 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"; +import ManagementRoomOutput from "./ManagementRoomOutput"; +import { ReportPoller } from "./report/ReportPoller"; +import { ReportManager } from "./report/ReportManager"; +import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; +import { MatrixSendClient, SynapseAdminClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { IConfig } from "./config"; +import { COMMAND_PREFIX, DraupnirContext, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; +import { htmlEscape } from "./utils"; +import { LogLevel } from "matrix-bot-sdk"; +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"; + +const log = new Logger('Draupnir'); + +// webAPIS should not be included on the Draupnir class. +// That should be managed elsewhere. +// It's not actually relevant to the Draupnir instance and it only was connected +// to Mjolnir because it needs to be started after Mjolnir started and not before. +// 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(); + + private readonly commandTable = findCommandTable("mjolnir"); + 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 commandContext: Omit; + + private readonly timelineEventListener = this.handleTimelineEvent.bind(this); + + 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 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); + 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); + } + + public static async makeDraupnirBot( + client: MatrixSendClient, + clientUserID: StringUserID, + clientPlatform: ClientPlatform, + managementRoom: MatrixRoomID, + clientRooms: ClientRooms, + protectedRoomsSet: ProtectedRoomsSet, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager, + config: IConfig + ): Promise> { + const draupnir = new Draupnir( + client, + clientUserID, + clientPlatform, + managementRoom, + clientRooms, + config, + protectedRoomsSet, + roomStateManager, + policyRoomManager, + roomMembershipManager, + 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.protectedRoomsConfig.addRoom( + managementRoom + ); + if (isError(managementRoomProtectResult)) { + return managementRoomProtectResult; + } + return Ok(draupnir); + } + + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + Task(this.joinOnInviteListener(roomID, event)); + this.managementRoomMessageListener(roomID, event); + this.reactionHandler.handleEvent(roomID, event); + if (this.protectedRoomsSet.isProtectedRoom(roomID)) { + this.protectedRoomsSet.handleTimelineEvent(roomID, event); + } + } + + 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}.`); + Task(this.client.unstableApis.addReactionToEvent(roomID, event.event_id, '⚠').then(_ => Ok(undefined))); + Task(this.client.unstableApis.addReactionToEvent(roomID, event.event_id, 'UISI').then(_ => Ok(undefined))); + 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}`); + Task(this.client.sendReadReceipt(roomID, event.event_id).then((_) => Ok(undefined))) + Task(handleCommand(roomID, event, commandBeingRun, this, this.commandTable).then((_) => Ok(undefined))); + } + this.reportManager.handleTimelineEvent(roomID, event); + } + + /** + * Adds a listener to the client that will automatically accept invitations. + * FIXME: This is just copied in from Mjolnir and there are plenty of places for uncaught exceptions that will cause havok. + * FIXME: MOVE TO A PROTECTION. + * @param {MatrixSendClient} client + * @param options By default accepts invites from anyone. + * @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true. + * @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`. + * @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`. + * @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space. + */ + private async joinOnInviteListener(roomID: StringRoomID, event: RoomEvent): Promise { + if (Value.Check(MembershipEvent, event) && event.state_key === this.clientUserID) { + const inviteEvent = event; + const reportInvite = async () => { + if (!this.config.recordIgnoredInvites) return; // Nothing to do + + Task((async () => { + await this.client.sendMessage(this.managementRoomID, { + msgtype: "m.text", + body: `${inviteEvent.sender} has invited me to ${inviteEvent.room_id} but the config prevents me from accepting the invitation. ` + + `If you would like this room protected, use "!mjolnir rooms add ${inviteEvent.room_id}" so I can accept the invite.`, + format: "org.matrix.custom.html", + formatted_body: `${htmlEscape(inviteEvent.sender)} has invited me to ${htmlEscape(inviteEvent.room_id)} but the config prevents me from ` + + `accepting the invitation. If you would like this room protected, use !mjolnir rooms add ${htmlEscape(inviteEvent.room_id)} ` + + `so I can accept the invite.`, + }); + return Ok(undefined); + })()); + }; + + if (this.config.autojoinOnlyIfManager) { + const managementMembership = this.protectedRoomsSet.setMembership.getRevision(this.managementRoomID); + if (managementMembership === undefined) { + throw new TypeError(`Processing an invitation before the protected rooms set has properly initialized. Are we protecting the management room?`); + } + const senderMembership = managementMembership.membershipForUser(inviteEvent.sender); + if (senderMembership?.membership !== Membership.Join) return reportInvite(); // ignore invite + } else { + if (!(isStringRoomID(this.config.acceptInvitesFromSpace) || isStringRoomAlias(this.config.acceptInvitesFromSpace))) { + // FIXME: We need to do StringRoomID stuff at parse time of the config. + throw new TypeError(`${this.config.acceptInvitesFromSpace} is not a valid room ID or Alias`); + } + const spaceReference = MatrixRoomReference.fromRoomIDOrAlias(this.config.acceptInvitesFromSpace); + const spaceID = await resolveRoomReferenceSafe(this.client, spaceReference); + if (isError(spaceID)) { + await this.managementRoomOutput.logMessage(LogLevel.ERROR, 'Draupnir', `Unable to resolve the space ${spaceReference.toPermalink} from config.acceptInvitesFromSpace when trying to accept an invitation from ${inviteEvent.sender}`); + } + const spaceId = await this.client.resolveRoom(this.config.acceptInvitesFromSpace); + const spaceUserIds = await this.client.getJoinedRoomMembers(spaceId) + .catch(async e => { + if (e.body?.errcode === "M_FORBIDDEN") { + await this.managementRoomOutput.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); + await this.client.joinRoom(spaceId); + return await this.client.getJoinedRoomMembers(spaceId); + } else { + return Promise.reject(e); + } + }); + if (!spaceUserIds.includes(inviteEvent.sender)) { + return reportInvite(); // ignore invite + } + } + await this.client.joinRoom(roomID); + } + } + + /** + * 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 new file mode 100644 index 00000000..2fde654b --- /dev/null +++ b/src/DraupnirBotMode.ts @@ -0,0 +1,123 @@ +/** + * Copyright (C) 2022-2023 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { + 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'; +import { IConfig } from "./config"; +import { Draupnir } from "./Draupnir"; +import { DraupnirFactory } from "./draupnirfactory/DraupnirFactory"; +import { WebAPIs } from "./webapis/WebAPIs"; + +setGlobalLoggerProvider(new BotSDKLogServiceLogger()); + +export function constructWebAPIs(draupnir: Draupnir): WebAPIs { + return new WebAPIs(draupnir.reportManager, draupnir.config); +} + +/** + * This is a file for providing default concrete implementations + * for all things to bootstrap Draupnir in 'bot mode'. + * However, people should be encouraged to make their own when + * APIs are stable as the protection-suite makes Draupnir + * almost completely modular and customizable. + */ + +export async function makeDraupnirBotModeFromConfig( + 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 clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + 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 b65ee494..757a4189 100644 --- a/src/ManagementRoomOutput.ts +++ b/src/ManagementRoomOutput.ts @@ -26,11 +26,11 @@ limitations under the License. */ import * as Sentry from "@sentry/node"; -import { LogLevel, LogService, MessageType, TextualMessageEventContent, UserID } from "matrix-bot-sdk"; -import { Permalinks } from "./commands/interface-manager/Permalinks"; +import { LogLevel, LogService, MessageType, TextualMessageEventContent } from "matrix-bot-sdk"; import { IConfig } from "./config"; -import { MatrixSendClient } from "./MatrixEmitter"; import { htmlEscape } from "./utils"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { Permalinks, StringRoomAlias, StringRoomID, StringUserID, serverName } from "matrix-protection-suite"; const levelToFn = { [LogLevel.DEBUG.toString()]: LogService.debug, @@ -45,7 +45,8 @@ const levelToFn = { export default class ManagementRoomOutput { constructor( - private readonly managementRoomId: string, + private readonly managementRoomID: StringRoomID, + private readonly clientUserID: StringUserID, private readonly client: MatrixSendClient, private readonly config: IConfig, ) { @@ -77,7 +78,7 @@ export default class ManagementRoomOutput { return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); }; - const viaServers = [(new UserID(await this.client.getUserId())).domain]; + const viaServers = [serverName(this.clientUserID)]; for (const roomId of roomIds) { let alias = roomId; try { @@ -90,7 +91,7 @@ export default class ManagementRoomOutput { const regexRoomId = new RegExp(escapeRegex(roomId), "g"); content.body = content.body.replace(regexRoomId, alias); if (content.formatted_body) { - const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers); + const permalink = Permalinks.forRoom(alias as StringRoomAlias, alias !== roomId ? [] : viaServers); content.formatted_body = content.formatted_body.replace(regexRoomId, `${htmlEscape(alias)}`); } } @@ -120,7 +121,7 @@ export default class ManagementRoomOutput { if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`; const client = this.client; - const roomIds = [this.managementRoomId, ...additionalRoomIds]; + const roomIds = [this.managementRoomID, ...additionalRoomIds]; let evContent: TextualMessageEventContent = { body: message, @@ -133,7 +134,7 @@ export default class ManagementRoomOutput { } try { - await client.sendMessage(this.managementRoomId, evContent); + await client.sendMessage(this.managementRoomID, evContent); } catch (ex) { // We want to be informed if we cannot log a message. Sentry.captureException(ex); diff --git a/src/MatrixEmitter.ts b/src/MatrixEmitter.ts deleted file mode 100644 index 5d22d822..00000000 --- a/src/MatrixEmitter.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 EventEmitter from "events"; -import { MatrixClient } from "matrix-bot-sdk"; - -/** - * This is an interface created in order to keep the event listener - * Mjolnir uses for new events generic. - * Used to provide a unified API for messages received from matrix-bot-sdk (using GET /sync) - * when we're in single bot mode and messages received from matrix-appservice-bridge (using pushed /transaction) - * when we're in appservice mode. - */ -export declare interface MatrixEmitter extends EventEmitter { - on(event: 'room.event', listener: (roomId: string, mxEvent: any) => void ): this - emit(event: 'room.event', roomId: string, mxEvent: any): boolean - - on(event: 'room.message', listener: (roomId: string, mxEvent: any) => void ): this - emit(event: 'room.message', roomId: string, mxEvent: any): boolean - - on(event: 'room.invite', listener: (roomId: string, mxEvent: any) => void ): this - emit(event: 'room.invite', roomId: string, mxEvent: any): boolean - - on(event: 'room.join', listener: (roomId: string, mxEvent: any) => void ): this - emit(event: 'room.join', roomId: string, mxEvent: any): boolean - - on(event: 'room.leave', listener: (roomId: string, mxEvent: any) => void ): this - emit(event: 'room.leave', roomId: string, mxEvent: any): boolean - - on(event: 'room.archived', listener: (roomId: string, mxEvent: any) => void ): this - emit(event: 'room.archived', roomId: string, mxEvent: any): boolean - - start(): Promise; - stop(): void; -} - -/** - * A `MatrixClient` without the properties of `MatrixEmitter`. - * This is in order to enforce listeners are added to `MatrixEmitter`s - * rather than on the matrix-bot-sdk version of the matrix client. - */ -export type MatrixSendClient = Omit; diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts deleted file mode 100644 index 603f60b8..00000000 --- a/src/Mjolnir.ts +++ /dev/null @@ -1,519 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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, - LogService, - MembershipEvent, -} from "matrix-bot-sdk"; - -import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule"; -import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; -import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; -import { htmlEscape } from "./utils"; -import { ReportManager } from "./report/ReportManager"; -import { ReportPoller } from "./report/ReportPoller"; -import { WebAPIs } from "./webapis/WebAPIs"; -import RuleServer from "./models/RuleServer"; -import { ThrottlingQueue } from "./queues/ThrottlingQueue"; -import { getDefaultConfig, IConfig } from "./config"; -import { PolicyListManager } from "./models/PolicyListManager"; -import { ProtectedRoomsSet } from "./ProtectedRoomsSet"; -import ManagementRoomOutput from "./ManagementRoomOutput"; -import { ProtectionManager } from "./protections/ProtectionManager"; -import { RoomMemberManager } from "./RoomMembers"; -import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; -import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; -import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; -import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; - -export const STATE_NOT_STARTED = "not_started"; -export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; -export const STATE_SYNCING = "syncing"; -export const STATE_RUNNING = "running"; - -/** - * Synapse will tell us where we last got to on polling reports, so we need - * to store that for pagination on further polls - */ -export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll"; - -export class Mjolnir { - private displayName: string; - private localpart: string; - private currentState: string = STATE_NOT_STARTED; - public readonly roomJoins: RoomMemberManager; - /** - * This is for users who are not listed on a watchlist, - * but have been flagged by the automatic spam detection as suispicous - */ - private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); - - private protectedRoomsConfig: ProtectedRoomsConfig; - public readonly protectedRoomsTracker: ProtectedRoomsSet; - private webapis: WebAPIs; - public taskQueue: ThrottlingQueue; - /** - * Reporting back to the management room. - */ - public readonly managementRoomOutput: ManagementRoomOutput; - /* - * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports - */ - private reportPoller?: ReportPoller; - /** - * Store the protections being used by Mjolnir. - */ - public readonly protectionManager: ProtectionManager; - /** - * Handle user reports from the homeserver. - */ - public readonly reportManager: ReportManager; - - private readonly commandTable = findCommandTable("mjolnir"); - public readonly policyListManager: PolicyListManager; - - public readonly reactionHandler: MatrixReactionHandler; - - /** - * Adds a listener to the client that will automatically accept invitations. - * @param {MatrixSendClient} client - * @param options By default accepts invites from anyone. - * @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true. - * @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`. - * @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`. - * @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space. - */ - private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixSendClient, options: { [key: string]: any }) { - mjolnir.matrixEmitter.on("room.invite", async (roomId: string, inviteEvent: any) => { - const membershipEvent = new MembershipEvent(inviteEvent); - - const reportInvite = async () => { - if (!options.recordIgnoredInvites) return; // Nothing to do - - await client.sendMessage(mjolnir.managementRoomId, { - msgtype: "m.text", - body: `${membershipEvent.sender} has invited me to ${roomId} but the config prevents me from accepting the invitation. ` - + `If you would like this room protected, use "!mjolnir rooms add ${roomId}" so I can accept the invite.`, - format: "org.matrix.custom.html", - formatted_body: `${htmlEscape(membershipEvent.sender)} has invited me to ${htmlEscape(roomId)} but the config prevents me from ` - + `accepting the invitation. If you would like this room protected, use !mjolnir rooms add ${htmlEscape(roomId)} ` - + `so I can accept the invite.`, - }); - }; - - if (options.autojoinOnlyIfManager) { - const managers = await client.getJoinedRoomMembers(mjolnir.managementRoomId); - if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite - } else { - const spaceId = await client.resolveRoom(options.acceptInvitesFromSpace); - const spaceUserIds = await client.getJoinedRoomMembers(spaceId) - .catch(async e => { - if (e.body?.errcode === "M_FORBIDDEN") { - await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); - await client.joinRoom(spaceId); - return await client.getJoinedRoomMembers(spaceId); - } else { - return Promise.reject(e); - } - }); - if (!spaceUserIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite - } - return client.joinRoom(roomId); - }); - } - - /** - * Create a new Mjolnir instance from a client and the options in the configuration file, ready to be started. - * @param {MatrixSendClient} client The client for Mjolnir to use. - * @returns A new Mjolnir instance that can be started without further setup. - */ - static async setupMjolnirFromConfig(client: MatrixSendClient, matrixEmitter: MatrixEmitter, config: IConfig): Promise { - if (!config.autojoinOnlyIfManager && config.acceptInvitesFromSpace === getDefaultConfig().acceptInvitesFromSpace) { - throw new TypeError("`autojoinOnlyIfManager` has been disabled, yet no space has been provided for `acceptInvitesFromSpace`."); - } - const joinedRooms = await client.getJoinedRooms(); - - // Ensure we're also in the management room - LogService.info("index", "Resolving management room..."); - const managementRoomId = await client.resolveRoom(config.managementRoom); - if (!joinedRooms.includes(managementRoomId)) { - await client.joinRoom(config.managementRoom); - } - - const ruleServer = config.web.ruleServer ? new RuleServer() : null; - const mjolnir = new Mjolnir(client, await client.getUserId(), matrixEmitter, managementRoomId, config, ruleServer); - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); - Mjolnir.addJoinOnInviteListener(mjolnir, client, config); - return mjolnir; - } - - constructor( - public readonly client: MatrixSendClient, - private readonly clientUserId: string, - public readonly matrixEmitter: MatrixEmitter, - public readonly managementRoomId: string, - public readonly config: IConfig, - // Combines the rules from ban lists so they can be served to a homeserver module or another consumer. - public readonly ruleServer: RuleServer | null, - ) { - this.protectedRoomsConfig = new ProtectedRoomsConfig(client); - this.policyListManager = new PolicyListManager(this); - this.reactionHandler = new MatrixReactionHandler(this.managementRoomId, client, clientUserId); - - const mutedModules = (LogService as any).mutedModules; - if (!Array.isArray(mutedModules)) { - throw new TypeError("MatrixBotSdk has changed their hacky handling of muted modules, praise be"); - } - for (const module of config.logMutedModules) { - if (!mutedModules.includes(module)) { - LogService.muteModule(module); - } - } - // Setup bot. - - matrixEmitter.on("room.event", this.handleEvent.bind(this)); - - matrixEmitter.on("room.message", async (roomId, event) => { - if (roomId !== this.managementRoomId) return; - if (!event['content']) return; - - const content = event['content']; - if (content['msgtype'] === "m.text" && content['body']) { - const prefixes = [ - COMMAND_PREFIX, - this.localpart + ":", - this.displayName + ":", - await client.getUserId() + ":", - this.localpart + " ", - this.displayName + " ", - await client.getUserId() + " ", - ...config.commands.additionalPrefixes.map(p => `!${p}`), - ...config.commands.additionalPrefixes.map(p => `${p}:`), - ...config.commands.additionalPrefixes.map(p => `${p} `), - ...config.commands.additionalPrefixes, - ]; - if (config.commands.allowNoPrefix) prefixes.push("!"); - - const prefixUsed = prefixes.find(p => content['body'].toLowerCase().startsWith(p.toLowerCase())); - if (!prefixUsed) return; - - // rewrite the event body to make the prefix uniform (in case the bot has spaces in its display name) - let restOfBody = content['body'].substring(prefixUsed.length); - if (!restOfBody.startsWith(" ")) restOfBody = ` ${restOfBody}`; - event['content']['body'] = COMMAND_PREFIX + restOfBody; - LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`); - - await client.sendReadReceipt(roomId, event['event_id']); - - return handleCommand(roomId, event, this, this.commandTable); - } - }); - - matrixEmitter.on("room.join", (roomId: string, event: any) => { - LogService.info("Mjolnir", `Joined ${roomId}`); - return this.resyncJoinedRooms(); - }); - matrixEmitter.on("room.leave", (roomId: string, event: any) => { - LogService.info("Mjolnir", `Left ${roomId}`); - return this.resyncJoinedRooms(); - }); - - client.getUserId().then(userId => { - this.localpart = userId.split(':')[0].substring(1); - return client.getUserProfile(userId); - }).then(profile => { - if (profile['displayname']) { - this.displayName = profile['displayname']; - } - }); - - // Setup Web APIs - console.log("Creating Web APIs"); - this.reportManager = new ReportManager(this); - this.webapis = new WebAPIs(this.reportManager, this.config, this.ruleServer); - if (config.pollReports) { - this.reportPoller = new ReportPoller(this, this.reportManager); - } - // Setup join/leave listener - this.roomJoins = new RoomMemberManager(this.matrixEmitter); - this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); - - this.protectionManager = new ProtectionManager(this); - - this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config); - const protections = new ProtectionManager(this); - this.protectedRoomsTracker = new ProtectedRoomsSet(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config); - } - - public get state(): string { - return this.currentState; - } - - /** - * Returns the handler to flag a user for redaction, removing any future messages that they send. - * Typically this is used by the flooding or image protection on users that have not been banned from a list yet. - * It cannot used to redact any previous messages the user has sent, in that cas you should use the `EventRedactionQueue`. - */ - public get unlistedUserRedactionHandler(): UnlistedUserRedactionQueue { - return this.unlistedUserRedactionQueue; - } - - /** - * Start Mjölnir. - */ - public async start() { - try { - // Start the web server. - console.log("Starting web server"); - await this.webapis.start(); - - if (this.reportPoller) { - let reportPollSetting: { from: number } = { from: 0 }; - try { - reportPollSetting = await this.client.getAccountData(REPORT_POLL_EVENT_TYPE); - } catch (err) { - if (err.body?.errcode !== "M_NOT_FOUND") { - throw err; - } else { - this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet"); - } - } - this.reportPoller.start(reportPollSetting.from); - } - - // Load the state. - this.currentState = STATE_CHECKING_PERMISSIONS; - - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms..."); - await this.protectedRoomsConfig.loadProtectedRoomsFromConfig(this.config); - await this.protectedRoomsConfig.loadProtectedRoomsFromAccountData(); - this.protectedRoomsConfig.getExplicitlyProtectedRooms().forEach(this.protectRoom, this); - // We have to build the policy lists before calling `resyncJoinedRooms` otherwise mjolnir will try to protect - // every policy list we are already joined to, as mjolnir will not be able to distinguish them from normal rooms. - await this.policyListManager.start(); - await this.resyncJoinedRooms(false); - await this.protectionManager.start(); - this.reactionHandler.start(this.matrixEmitter); - - if (this.config.verifyPermissionsOnStartup) { - await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); - await this.protectedRoomsTracker.verifyPermissions(); - } - - // Start the bot. - await this.matrixEmitter.start(); - - this.currentState = STATE_SYNCING; - if (this.config.syncOnStartup) { - await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); - await this.protectedRoomsTracker.syncLists(); - } - - this.currentState = STATE_RUNNING; - await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); - if (this.config.verboseLogging) { - await this.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir@startup", "The use of verbose logging is deprecated and will be removed in a future version, check your config."); - } - } catch (err) { - try { - LogService.error("Mjolnir", "Error during startup:", err); - this.stop(); - await this.managementRoomOutput.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console"); - } catch (e) { - LogService.error("Mjolnir", `Failed to report startup error to the management room:`, e); - } - throw err; - } - } - - /** - * Stop Mjolnir from syncing and processing commands. - */ - public stop() { - LogService.info("Mjolnir", "Stopping Mjolnir..."); - this.matrixEmitter.stop(); - this.reactionHandler.stop(this.matrixEmitter); - this.webapis.stop(); - this.reportPoller?.stop(); - } - - /** - * Rooms that mjolnir is configured to explicitly protect. - * Do not use to access all of the rooms that mjolnir protects. - * FIXME: In future ProtectedRoomsSet on this mjolnir should not be public and should also be accessed via a delegator method. - */ - public get explicitlyProtectedRooms(): string[] { - return this.protectedRoomsConfig.getExplicitlyProtectedRooms() - } - - /** - * Explicitly protect this room, adding it to the account data. - * Should NOT be used to protect a room to implement e.g. `config.protectAllJoinedRooms`, - * use `protectRoom` instead. - * @param roomId The room to be explicitly protected by mjolnir and persisted in config. - */ - public async addProtectedRoom(roomId: string) { - await this.protectedRoomsConfig.addProtectedRoom(roomId); - this.protectRoom(roomId); - } - - /** - * Protect the room, but do not persist it to the account data. - * @param roomId The room to protect. - */ - private protectRoom(roomId: string): void { - this.protectedRoomsTracker.addProtectedRoom(roomId); - this.roomJoins.addRoom(roomId); - } - - /** - * Remove a room from the explicitly protect set of rooms that is persisted to account data. - * Should NOT be used to remove a room that we have left, e.g. when implementing `config.protectAllJoinedRooms`, - * use `unprotectRoom` instead. - * @param roomId The room to remove from account data and stop protecting. - */ - public async removeProtectedRoom(roomId: string) { - await this.protectedRoomsConfig.removeProtectedRoom(roomId); - this.unprotectRoom(roomId); - } - - /** - * Unprotect a room. - * @param roomId The room to stop protecting. - */ - private unprotectRoom(roomId: string): void { - this.roomJoins.removeRoom(roomId); - this.protectedRoomsTracker.removeProtectedRoom(roomId); - } - - /** - * Resynchronize the protected rooms with rooms that the mjolnir user is joined to. - * This is to implement `config.protectAllJoinedRooms` functionality. - * @param withSync Whether to synchronize all protected rooms with the watched policy lists afterwards. - */ - private async resyncJoinedRooms(withSync = true): Promise { - if (!this.config.protectAllJoinedRooms) return; - - // We filter out all policy rooms so that we only protect ones that are - // explicitly protected, so that we don't try to protect lists that we are just watching. - const filterOutManagementAndPolicyRooms = (roomId: string) => { - const policyListIds = this.policyListManager.lists.map(list => list.roomId); - return roomId !== this.managementRoomId && !policyListIds.includes(roomId); - }; - - const joinedRoomIdsToProtect = new Set([ - ...(await this.client.getJoinedRooms()).filter(filterOutManagementAndPolicyRooms), - // We do this specifically so policy lists that have been explicitly marked as protected - // will be protected. - ...this.protectedRoomsConfig.getExplicitlyProtectedRooms(), - ]); - const previousRoomIdsProtecting = new Set(this.protectedRoomsTracker.getProtectedRooms()); - // find every room that we have left (since last time) - for (const roomId of previousRoomIdsProtecting.keys()) { - if (!joinedRoomIdsToProtect.has(roomId)) { - // Then we have left this room. - this.unprotectRoom(roomId); - } - } - // find every room that we have joined (since last time). - for (const roomId of joinedRoomIdsToProtect.keys()) { - if (!previousRoomIdsProtecting.has(roomId)) { - // Then we have joined this room - this.protectRoom(roomId); - } - } - - if (withSync) { - await this.protectedRoomsTracker.syncLists(); - } - } - - private async handleEvent(roomId: string, event: any) { - // Check for UISI errors - if (roomId === this.managementRoomId) { - if (event['type'] === 'm.room.message' && event['content'] && event['content']['body']) { - if (event['content']['body'] === "** Unable to decrypt: The sender's device has not sent us the keys for this message. **") { - // UISI - await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '⚠'); - await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], 'UISI'); - await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '🚨'); - } - } - } - - // Check for updated ban lists before checking protected rooms - the ban lists might be protected - // themselves. - const policyList = this.policyListManager.lists.find(list => list.roomId === roomId); - if (policyList !== undefined) { - if (ALL_BAN_LIST_RULE_TYPES.includes(event['type']) || event['type'] === 'm.room.redaction') { - policyList.updateForEvent(event.event_id) - } - } - - if (event.sender !== this.clientUserId) { - this.protectedRoomsTracker.handleEvent(roomId, event); - } - } - - public async isSynapseAdmin(): Promise { - try { - const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`; - const response = await this.client.doRequest("GET", endpoint); - return response['admin']; - } catch (e) { - LogService.error("Mjolnir", "Error determining if Mjolnir is a server admin:", e); - return false; // assume not - } - } - - public async deactivateSynapseUser(userId: string): Promise { - const endpoint = `/_synapse/admin/v1/deactivate/${userId}`; - return await this.client.doRequest("POST", endpoint); - } - - public async shutdownSynapseRoom(roomId: string, message?: string): Promise { - const endpoint = `/_synapse/admin/v1/rooms/${roomId}`; - return await this.client.doRequest("DELETE", endpoint, null, { - new_room_user_id: await this.client.getUserId(), - block: true, - message: message /* If `undefined`, we'll use Synapse's default message. */ - }); - } - - /** - * Make a user administrator via the Synapse Admin API - * @param roomId the room where the user (or the bot) shall be made administrator. - * @param userId optionally specify the user mxID to be made administrator. - */ - public async makeUserRoomAdmin(roomId: string, userId: string): Promise { - const endpoint = `/_synapse/admin/v1/rooms/${roomId}/make_room_admin`; - return await this.client.doRequest("POST", endpoint, null, { - user_id: userId - }); - } -} diff --git a/src/ProtectedRoomsConfig.ts b/src/ProtectedRoomsConfig.ts deleted file mode 100644 index 89924602..00000000 --- a/src/ProtectedRoomsConfig.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019, 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 AwaitLock from 'await-lock'; -import { LogService } from "matrix-bot-sdk"; -import { Permalinks } from './commands/interface-manager/Permalinks'; -import { IConfig } from "./config"; -import { MatrixSendClient } from './MatrixEmitter'; -const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; - -/** - * Manages the set of rooms that the user has EXPLICITLY asked to be protected. - */ -export default class ProtectedRoomsConfig { - - /** - * These are rooms that we EXPLICITLY asked Mjolnir to protect, usually via the `rooms add` command. - * These are NOT all of the rooms that mjolnir is protecting as with `config.protectAllJoinedRooms`. - */ - private explicitlyProtectedRooms = new Set(); - /** This is to prevent clobbering the account data for the protected rooms if several rooms are explicitly protected concurrently. */ - private accountDataLock = new AwaitLock(); - - constructor(private readonly client: MatrixSendClient) { - - } - - /** - * Load any rooms that have been explicitly protected from a Mjolnir config. - * Will also ensure we are able to join all of the rooms. - * @param config The config to load the rooms from under `config.protectedRooms`. - */ - public async loadProtectedRoomsFromConfig(config: IConfig): Promise { - // Ensure we're also joined to the rooms we're protecting - LogService.info("ProtectedRoomsConfig", "Resolving protected rooms..."); - const joinedRooms = await this.client.getJoinedRooms(); - for (const roomRef of config.protectedRooms) { - const permalink = Permalinks.parseUrl(roomRef); - if (!permalink.roomIdOrAlias) continue; - - let roomId = await this.client.resolveRoom(permalink.roomIdOrAlias); - if (!joinedRooms.includes(roomId)) { - roomId = await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); - } - this.explicitlyProtectedRooms.add(roomId); - } - } - - /** - * Load any rooms that have been explicitly protected from the account data of the mjolnir user. - * Will not ensure we can join all the rooms. This so mjolnir can continue to operate if bogus rooms have been persisted to the account data. - */ - public async loadProtectedRoomsFromAccountData(): Promise { - LogService.debug("ProtectedRoomsConfig", "Loading protected rooms..."); - try { - const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); - if (data && data['rooms']) { - for (const roomId of data['rooms']) { - this.explicitlyProtectedRooms.add(roomId); - } - } - } catch (e) { - if (e.statusCode === 404) { - LogService.warn("ProtectedRoomsConfig", "Couldn't find any explicitly protected rooms from Mjolnir's account data, assuming first start.", e); - } else { - throw e; - } - } - } - - /** - * Save the room as explicitly protected. - * @param roomId The room to persist as explicitly protected. - */ - public async addProtectedRoom(roomId: string): Promise { - this.explicitlyProtectedRooms.add(roomId); - await this.saveProtectedRoomsToAccountData(); - } - - /** - * Remove the room from the explicitly protected set of rooms. - * @param roomId The room that should no longer be persisted as protected. - */ - public async removeProtectedRoom(roomId: string): Promise { - this.explicitlyProtectedRooms.delete(roomId); - await this.saveProtectedRoomsToAccountData([roomId]); - } - - /** - * Get the set of explicitly protected rooms. - * This will NOT be the complete set of protected rooms, if `config.protectAllJoinedRooms` is true and should never be treated as the complete set. - * @returns The rooms that are marked as explicitly protected in both the config and Mjolnir's account data. - */ - public getExplicitlyProtectedRooms(): string[] { - return [...this.explicitlyProtectedRooms.keys()] - } - - /** - * Persist the set of explicitly protected rooms to the client's account data. - * @param excludeRooms Rooms that should not be persisted to the account data, and removed if already present. - */ - private async saveProtectedRoomsToAccountData(excludeRooms: string[] = []): Promise { - // NOTE: this stops Mjolnir from racing with itself when saving the config - // but it doesn't stop a third party client on the same account racing with us instead. - await this.accountDataLock.acquireAsync(); - try { - const additionalProtectedRooms: string[] = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE) - .then((rooms: {rooms?: string[]}) => Array.isArray(rooms?.rooms) ? rooms.rooms : []) - .catch(e => (LogService.warn("ProtectedRoomsConfig", "Could not load protected rooms from account data", e), [])); - - const roomsToSave = new Set([...this.explicitlyProtectedRooms.keys(), ...additionalProtectedRooms]); - excludeRooms.forEach(roomsToSave.delete, roomsToSave); - await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: Array.from(roomsToSave.keys()) }); - } finally { - this.accountDataLock.release(); - } - } -} diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts deleted file mode 100644 index 1581595a..00000000 --- a/src/ProtectedRoomsSet.ts +++ /dev/null @@ -1,511 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019, 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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, MatrixGlob, UserID } from "matrix-bot-sdk"; -import { CommandExceptionKind } from "./commands/interface-manager/CommandException"; -import { IConfig } from "./config"; -import ManagementRoomOutput from "./ManagementRoomOutput"; -import { MatrixSendClient } from "./MatrixEmitter"; -import AccessControlUnit, { Access } from "./models/AccessControlUnit"; -import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; -import PolicyList, { ListRuleChange, Revision } from "./models/PolicyList"; -import { printActionResult, IRoomUpdateError, RoomUpdateException } from "./models/RoomUpdateError"; -import { ProtectionManager } from "./protections/ProtectionManager"; -import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; -import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; -import { htmlEscape } from "./utils"; - -/** - * This class aims to synchronize `m.ban` rules in a set of policy lists with - * a set of rooms by applying member bans and server ACL to them. - * - * It is important to understand that the use of `m.ban` in the lists that `ProtectedRooms` watch - * are interpreted to be the final decision about whether to ban a user and are a synchronization tool. - * This is different to watching a community curated list to be informed about reputation information and then making - * some sort of decision and is not the purpose of this class (as of writing, Mjolnir does not have a way to do this, we want it to). - * The outcome of that decision process (which should take place in other components) - * will likely be whether or not to create an `m.ban` rule in a list watched by - * your protected rooms. - * - * It is also important not to tie this to the one group of rooms that a mjolnir may watch - * as in future we might want to borrow this class to represent a space https://github.com/matrix-org/mjolnir/issues/283. - */ -export class ProtectedRoomsSet { - - private protectedRooms = new Set(); - - /** - * These are the `m.bans` we want to synchronize across this set of rooms. - */ - private policyLists: PolicyList[] = []; - - /** - * Tracks the rooms so that the most recently active rooms can be synchronized first. - */ - private protectedRoomActivityTracker: ProtectedRoomActivityTracker; - - /** - * This is a queue for redactions to process after mjolnir - * has finished applying ACL and bans when syncing. - */ - private readonly eventRedactionQueue = new EventRedactionQueue(); - - /** - * These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an - * `m.ban` recommendation against a user. - * If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact - * all of the messages from that user. - */ - private automaticRedactionReasons: MatrixGlob[] = []; - - /** - * Used to provide mutual exclusion when synchronizing rooms with the state of a policy list. - * This is because requests operating with rules from an older version of the list that are slow - * could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules, - * which would cause several requests to a room to send a new m.room.server_acl event. - * These requests could finish in any order, which has left rooms with an inconsistent server_acl event - * until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours. - */ - private aclChain: Promise = Promise.resolve(); - - /** - * A utility to test the access that users have in the set of protected rooms according to the policies of the watched lists. - */ - private readonly accessControlUnit = new AccessControlUnit([]); - - /** - * Intended to be `this.syncWithUpdatedPolicyList` so we can add it in `this.watchList` and remove it in `this.unwatchList`. - * Otherwise we would risk being informed about lists we no longer watch. - */ - private readonly listUpdateListener: (list: PolicyList, changes: ListRuleChange[], revision: Revision) => void; - - /** - * The revision of a each watched list that we have applied to protected rooms. - */ - private readonly listRevisions = new Map(); - - constructor( - private readonly client: MatrixSendClient, - private readonly clientUserId: string, - private readonly managementRoomId: string, - private readonly managementRoomOutput: ManagementRoomOutput, - /** - * The protection manager is only used to verify the permissions - * that the protection manager requires are correct for this set of rooms. - * The protection manager is not really compatible with this abstraction yet - * because of a direct dependency on the protection manager in Mjolnir commands. - */ - private readonly protectionManager: ProtectionManager, - private readonly config: IConfig, - ) { - for (const reason of this.config.automaticallyRedactForReasons) { - this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); - } - - // Setup room activity watcher - this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(); - this.listUpdateListener = this.syncWithUpdatedPolicyList.bind(this); - } - - /** - * Queue a user's messages in a room for redaction once we have stopped synchronizing bans - * over the protected rooms. - * - * @param userId The user whose messages we want to redact. - * @param roomId The room we want to redact them in. - */ - public redactUser(userId: string, roomId: string) { - this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId)); - } - - /** - * These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an - * `m.ban` recommendation against a user. - * If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact - * all of the messages from that user. - */ - public get automaticRedactGlobs(): Readonly { - return this.automaticRedactionReasons; - } - - public getProtectedRooms () { - return [...this.protectedRooms.keys()] - } - - public isProtectedRoom(roomId: string): boolean { - return this.protectedRooms.has(roomId); - } - - public watchList(policyList: PolicyList): void { - if (!this.policyLists.includes(policyList)) { - this.policyLists.push(policyList); - this.accessControlUnit.watchList(policyList); - policyList.on('PolicyList.update', this.listUpdateListener); - } - } - - public unwatchList(policyList: PolicyList): void { - this.policyLists = this.policyLists.filter(list => list.roomId !== policyList.roomId); - this.accessControlUnit.unwatchList(policyList); - policyList.off('PolicyList.update', this.listUpdateListener) - } - - /** - * Process all queued redactions, this is usually called at the end of the sync process, - * after all users have been banned and ACLs applied. - * If a redaction cannot be processed, the redaction is skipped and removed from the queue. - * We then carry on processing the next redactions. - * @param roomId Limit processing to one room only, otherwise process redactions for all rooms. - * @returns The list of errors encountered, for reporting to the management room. - */ - public async processRedactionQueue(roomId?: string): Promise { - return await this.eventRedactionQueue.process(this.client, this.managementRoomOutput, roomId); - } - - /** - * @returns The protected rooms ordered by the most recently active first. - */ - public protectedRoomsByActivity(): string[] { - return this.protectedRoomActivityTracker.protectedRoomsByActivity(); - } - - public async handleEvent(roomId: string, event: any) { - if (event['sender'] === this.clientUserId) { - throw new TypeError("`ProtectedRooms::handleEvent` should not be used to inform about events sent by mjolnir."); - } - if (!this.protectedRooms.has(roomId)) { - return; // We're not protecting this room. - } - this.protectedRoomActivityTracker.handleEvent(roomId, event); - if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') { - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId); - const errors = await this.protectionManager.verifyPermissionsIn(roomId); - await this.printActionResult(errors, { title: "There were errors verifying permissions.", noErrorsText: "All permissions look OK."}); - return; - } else if (event['type'] === "m.room.member") { - // The reason we have to apply bans on each member change is because - // we cannot eagerly ban users (that is to ban them when they have never been a member) - // as they can be force joined to a room they might not have known existed. - // Only apply bans and then redactions in the room we are currently looking at. - const errors = [ - await this.applyUserBans([roomId]), - await this.processRedactionQueue(roomId), - ].flat(); - if (errors.length > 0) { - await this.printActionResult(errors, { title: 'There were errors updating member bans.' }); - } - } - } - - /** - * Synchronize all the protected rooms with all of the policies described in the watched policy lists. - */ - private async syncRoomsWithPolicies() { - const syncErrors = ( - await Promise.all([ - this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()), - this.applyUserBans(this.protectedRoomsByActivity()), - ]) - ).flat(); - // The redaction queue has to be processed after both serverACLS and applyUserBans has been processed, - // otherwise you risk adding users to the queue after this call to process them. - const redactionErrors = await this.processRedactionQueue(); - await this.printActionResult( - [...syncErrors, ...redactionErrors], - { title: "There were errors synchronising the protected rooms." } - ); - } - - /** - * Update each watched list and then synchronize all the protected rooms with all the policies described in the watched lists, - * banning and applying any changed ACLS via `syncRoomsWithPolicies`. - */ - public async syncLists() { - for (const list of this.policyLists) { - const { revision } = await list.updateList(); - const previousRevision = this.listRevisions.get(list); - if (previousRevision === undefined || revision.supersedes(previousRevision)) { - this.listRevisions.set(list, revision); - // we rely on `this.listUpdateListener` to print the changes to the list. - } - } - await this.syncRoomsWithPolicies(); - } - - public addProtectedRoom(roomId: string): void { - if (this.protectedRooms.has(roomId)) { - // we need to protect ourselves form syncing all the lists unnecessarily - // as Mjolnir does call this method repeatedly. - return; - } - this.protectedRooms.add(roomId); - this.protectedRoomActivityTracker.addProtectedRoom(roomId); - } - - public removeProtectedRoom(roomId: string): void { - this.protectedRoomActivityTracker.removeProtectedRoom(roomId); - this.protectedRooms.delete(roomId); - } - - /** - * Updates all protected rooms with those any changes that have been made to a policy list. - * Does not fail if there are errors updating the room, these are reported to the management room. - * Do not use directly as a listener, use `this.listUpdateListener`. - * @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms. - * @returns When all of the protected rooms have been updated. - */ - private async syncWithUpdatedPolicyList(policyList: PolicyList, changes: ListRuleChange[], revision: Revision): Promise { - // avoid resyncing the rooms if we have already done so for the latest revision of this list. - const previousRevision = this.listRevisions.get(policyList); - if (previousRevision === undefined || revision.supersedes(previousRevision)) { - this.listRevisions.set(policyList, revision); - await this.syncRoomsWithPolicies(); - } - // This can fail if the change is very large and it is much less important than applying bans, so do it last. - // We always print changes because we make this listener responsible for doing it. - await this.printBanlistChanges(changes, policyList); - } - - /** - * Applies the server ACLs represented by the ban lists to the provided rooms, returning the - * room IDs that could not be updated and their error. - * Does not update the banLists before taking their rules to build the server ACL. - * @param {PolicyList[]} lists The lists to construct ACLs from. - * @param {string[]} roomIds The room IDs to apply the ACLs in. - * @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with. - */ - private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise { - // we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event - // finish out of order and therefore leave the room out of sync with the policy lists. - if (this.config.disableServerACL) { - return []; - } - return new Promise((resolve, reject) => { - this.aclChain = this.aclChain - .then(() => this._applyServerAcls(lists, roomIds)) - .then(resolve, reject); - }); - } - - private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise { - const serverName: string = new UserID(await this.client.getUserId()).domain; - - // Construct a server ACL first - const acl = this.accessControlUnit.compileServerAcl(serverName); - const finalAcl = acl.safeAclContent(); - - if (this.config.verboseLogging) { - // We specifically use sendNotice to avoid having to escape HTML - await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`); - } - - const errors: IRoomUpdateError[] = []; - for (const roomId of roomIds) { - try { - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId); - - try { - const currentAcl = await this.client.getRoomStateEvent(roomId, "m.room.server_acl", ""); - if (acl.matches(currentAcl)) { - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId); - continue; - } - } catch (e) { - // ignore - assume no ACL - } - - // We specifically use sendNotice to avoid having to escape HTML - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId); - - if (!this.config.noop) { - await this.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl); - } else { - await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId); - } - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - const kind = message && message.includes("You don't have permission to post that to the room") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown; - errors.push(new RoomUpdateException(roomId, kind, e, message)) - } - } - return errors; - } - - /** - * Applies the member bans represented by the ban lists to the provided rooms, returning the - * room IDs that could not be updated and their error. - * @param {string[]} roomIds The room IDs to apply the bans in. - * @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with. - */ - private async applyUserBans(roomIds: string[]): Promise { - // We can only ban people who are not already banned, and who match the rules. - const errors: IRoomUpdateError[] = []; - - const addErrorToReport = (roomId: string, e: any) => { - const message = e.message || (e.body ? e.body.error : ''); - errors.push(new RoomUpdateException( - roomId, - message && message.includes("You don't have permission to ban") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown, - e, - message - )); - }; - - for (const roomId of roomIds) { - try { - // We specifically use sendNotice to avoid having to escape HTML - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId); - - let members: { userId: string, membership: string }[]; - - if (this.config.fasterMembershipChecks) { - const memberIds = await this.client.getJoinedRoomMembers(roomId); - members = memberIds.map(u => { - return { userId: u, membership: "join" }; - }); - } else { - const state = await this.client.getRoomState(roomId); - members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => { - return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' }; - }); - } - - for (const member of members) { - if (member.membership === 'ban') { - continue; // user already banned - } - - // We don't want to ban people based on server ACL as this would flood the room with bans. - const memberAccess = this.accessControlUnit.getAccessForUser(member.userId, "IGNORE_SERVER"); - if (memberAccess.outcome === Access.Banned) { - const reason = memberAccess.rule ? memberAccess.rule.reason : ''; - // We specifically use sendNotice to avoid having to escape HTML - await this.managementRoomOutput.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${reason}`, roomId); - - if (!this.config.noop) { - try { - await this.client.banUser(member.userId, roomId, memberAccess.rule!.reason); - if (this.automaticRedactGlobs.find(g => g.test(reason.toLowerCase()))) { - this.redactUser(member.userId, roomId); - } - } catch (e) { - addErrorToReport(roomId, e); - } - } else { - await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId); - } - } - } - } catch (e) { - addErrorToReport(roomId, e) - } - } - - return errors; - } - - /** - * Print the changes to a banlist to the management room. - * @param changes A list of changes that have been made to a particular ban list. - * @returns true if the message was sent, false if it wasn't (because there there were no changes to report). - */ - private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList): Promise { - if (changes.length <= 0) return false; - - let html = ""; - let text = ""; - - const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:'); - const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : ''; - - html += `${htmlEscape(list.roomId)}${shortcodeInfo} ${changesInfo}
    `; - text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`; - - for (const change of changes) { - const rule = change.rule; - let ruleKind: string = rule.kind; - if (ruleKind === RULE_USER) { - ruleKind = 'user'; - } else if (ruleKind === RULE_SERVER) { - ruleKind = 'server'; - } else if (ruleKind === RULE_ROOM) { - ruleKind = 'room'; - } - html += `
  • ${htmlEscape(change.sender)} ${change.changeType} ${htmlEscape(ruleKind)} (${htmlEscape(rule.recommendation ?? "")}): ${htmlEscape(rule.entity)} (${htmlEscape(rule.reason)})
  • `; - text += `* ${change.sender} ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`; - } - - const message = { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - }; - await this.client.sendMessage(this.managementRoomId, message); - return true; - } - - private async printActionResult( - errors: IRoomUpdateError[], - renderOptions: { title?: string, noErrorsText?: string } - ): Promise { - await printActionResult(this.client, this.managementRoomId, errors, renderOptions); - } - - public async unbanUser(user: string): Promise { - const errors: IRoomUpdateError[] = []; - for (const room of this.protectedRoomActivityTracker.protectedRoomsByActivity()) { - try { - await this.client.unbanUser(user, room); - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - errors.push(new RoomUpdateException( - room, - message && message.includes("You don't have permission to ban") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown, - e, - message - )); - } - } - return errors; - } - - public requiredProtectionPermissions() { - throw new TypeError("Unimplemented, need to put protections into here too.") - } - - public async verifyPermissions() { - const errors: IRoomUpdateError[] = []; - for (const roomId of this.protectedRooms) { - errors.push(...(await this.protectionManager.verifyPermissionsIn(roomId))); - } - await this.printActionResult(errors, { - title: "There are permission errors in protected rooms.", - noErrorsText: "All permissions look OK." - }); - } -} diff --git a/src/RoomMembers.ts b/src/RoomMembers.ts deleted file mode 100644 index 7cc8dd0c..00000000 --- a/src/RoomMembers.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { MatrixEmitter } from "./MatrixEmitter"; - -enum Action { - Join, - Leave, - Other -} - -const LEAVE_OR_BAN = ['leave', 'ban']; - -/** - * Storing a join event. - * - * We use `timestamp`: - * - to avoid maintaining tens of thousands of in-memory `Date` objects; - * - to ensure immutability. - */ -export class Join { - constructor( - public readonly userId: string, - public readonly timestamp: number - ) { } -} - -/** - * A data structure maintaining a list of joins since the start of Mjölnir. - * - * This data structure is optimized for lookup up of recent joins. - */ -class RoomMembers { - /** - * The list of recent joins, ranked from oldest to most recent. - * - * Note that a user may show up in both `_joinsByTimestamp` and `_leaves`, in which case - * they have both joined and left recently. Compare the date of the latest - * leave event (in `_leaves`) to the date of the join to determine whether - * the user is still present. - * - * Note that a user may show up more than once in `_joinsByTimestamp` if they have - * left and rejoined. - */ - private _joinsByTimestamp: Join[] = []; - private _joinsByUser: Map = new Map(); - - /** - * The list of recent leaves. - * - * If a user rejoins and leaves again, the latest leave event overwrites - * the oldest. - */ - private _leaves: Map = new Map(); - - /** - * Record a join. - */ - public join(userId: string, timestamp: number) { - this._joinsByTimestamp.push(new Join(userId, timestamp)); - this._joinsByUser.set(userId, timestamp); - } - - /** - * Record a leave. - */ - public leave(userId: string, timestamp: number) { - if (!this._joinsByUser.has(userId)) { - // No need to record a leave for a user we didn't see joining. - return; - } - this._leaves.set(userId, timestamp); - this._joinsByUser.delete(userId); - } - - /** - * Run a cleanup on the data structure. - */ - public cleanup() { - if (this._leaves.size === 0) { - // Nothing to do. - return; - } - this._joinsByTimestamp = this._joinsByTimestamp.filter(join => this.isStillValid(join)); - this._leaves = new Map(); - } - - /** - * Determine whether a `join` is still valid or has been superseded by a `leave`. - * - * @returns true if the `join` is still valid. - */ - private isStillValid(join: Join): boolean { - const leaveTS = this._leaves.get(join.userId); - if (!leaveTS) { - // The user never left. - return true; - } - if (leaveTS > join.timestamp) { - // The user joined, then left, ignore this join. - return false; - } - // The user had left, but this is a more recent re-join. - return true; - } - - /** - * Return a subset of the list of all the members, with their join date. - * - * @param since Only return members who have last joined at least as - * recently as `since`. - * @param max Only return at most `max` numbers. - * @returns A list of up to `max` members joined since `since`, ranked - * from most recent join to oldest join. - */ - public members(since: Date, max: number): Join[] { - const result = []; - const ts = since.getTime(); - // Spurious joins are legal, let's deduplicate them. - const users = new Set(); - for (let i = this._joinsByTimestamp.length - 1; i >= 0; --i) { - if (result.length > max) { - // We have enough entries, let's return immediately. - return result; - } - const join = this._joinsByTimestamp[i]; - if (join.timestamp < ts) { - // We have reached an older entry, everything will be `< since`, - // we won't find any other join to return. - return result; - } - if (this.isStillValid(join) && !users.has(join.userId)) { - // This entry is still valid, we'll need to return it. - result.push(join); - users.add(join.userId); - } - } - // We have reached the startup of Mjölnir. - return result; - } - - /** - * Return the join date of a user. - * - * @returns a `Date` if the user is currently in the room and has joined - * since the start of Mjölnir, `null` otherwise. - */ - public get(userId: string): Date | null { - let ts = this._joinsByUser.get(userId); - if (!ts) { - return null; - } - return new Date(ts); - } -} - -export class RoomMemberManager { - private perRoom: Map = new Map(); - private readonly cbHandleEvent; - constructor(private client: MatrixEmitter) { - // Listen for join events. - this.cbHandleEvent = this.handleEvent.bind(this); - client.on("room.event", this.cbHandleEvent); - } - - /** - * Start listening to join/leave events in a room. - */ - public addRoom(roomId: string) { - if (this.perRoom.has(roomId)) { - // Nothing to do. - return; - } - this.perRoom.set(roomId, new RoomMembers()); - } - - /** - * Stop listening to join/leave events in a room. - * - * Cleanup any remaining data on join/leave events. - */ - public removeRoom(roomId: string) { - this.perRoom.delete(roomId); - } - - public cleanup(roomId: string) { - this.perRoom.get(roomId)?.cleanup(); - } - - /** - * Dispose of this object. - */ - public dispose() { - this.client.off("room.event", this.cbHandleEvent); - } - - /** - * Return the date at which user `userId` has joined room `roomId`, or `null` if - * that user has joined the room before Mjölnir started watching it. - * - * @param roomId The id of the room we're interested in. - * @param userId The id of the user we're interested in. - * @returns a Date if Mjölnir has witnessed the user joining the room, - * `null` otherwise. The latter may happen either if the user has joined - * the room before Mjölnir or if the user is not currently in the room. - */ - public getUserJoin(user: { roomId: string, userId: string }): Date | null { - const { roomId, userId } = user; - const ts = this.perRoom.get(roomId)?.get(userId) || null; - if (!ts) { - return null; - } - return new Date(ts); - } - - /** - * Get the users in a room, ranked by most recently joined to oldest join. - * - * Only the users who have joined since the start of Mjölnir are returned. - */ - public getUsersInRoom(roomId: string, since: Date, max = 100): Join[] { - const inRoom = this.perRoom.get(roomId); - if (!inRoom) { - return []; - } - return inRoom.members(since, max); - } - - /** - * Record join/leave events. - */ - public async handleEvent(roomId: string, event: any, now?: Date) { - if (event['type'] !== 'm.room.member') { - // Not a join/leave event. - return; - } - - const members = this.perRoom.get(roomId); - if (!members) { - // Not a room we are watching. - return; - } - const userId = event['state_key']; - if (!userId) { - // Ill-formed event. - return; - } - - const userState = event['content']['membership']; - const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || "leave"; - - // We look at the previous membership to filter out profile changes - let action; - if (userState === 'join' && prevMembership !== "join") { - action = Action.Join; - } else if (LEAVE_OR_BAN.includes(userState) && !LEAVE_OR_BAN.includes(prevMembership)) { - action = Action.Leave; - } else { - action = Action.Other; - } - switch (action) { - case Action.Other: - // Nothing to do. - return; - case Action.Join: - members.join(userId, now ? now.getTime() : Date.now()); - break; - case Action.Leave: - members.leave(userId, now ? now.getTime() : Date.now()); - break; - } - } -} diff --git a/src/appservice/AccessControl.ts b/src/appservice/AccessControl.ts index 4156a703..c04b7a69 100644 --- a/src/appservice/AccessControl.ts +++ b/src/appservice/AccessControl.ts @@ -25,11 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Bridge } from "matrix-appservice-bridge"; -import { Permalinks } from "../commands/interface-manager/Permalinks"; -import AccessControlUnit, { EntityAccess } from "../models/AccessControlUnit"; -import { EntityType, Recommendation } from "../models/ListRule"; -import PolicyList from "../models/PolicyList"; +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, @@ -39,9 +35,10 @@ import PolicyList from "../models/PolicyList"; export class AccessControl { private constructor( - private readonly accessControlList: PolicyList, - private readonly accessControlUnit: AccessControlUnit - ) { + private readonly accessControlRevisionIssuer: PolicyListRevisionIssuer, + private readonly editor: PolicyRoomEditor + ) { + // nothing to do. } /** @@ -50,37 +47,46 @@ export class AccessControl { * @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 setupAccessControl( + public static async setupAccessControlForRoom( /** The room id for the access control list. */ - accessControlListId: string, - bridge: Bridge, - ): Promise { - await bridge.getBot().getClient().joinRoom(accessControlListId); - const accessControlList = new PolicyList( - accessControlListId, - Permalinks.forRoom(accessControlListId), - bridge.getBot().getClient() - ); - const accessControlUnit = new AccessControlUnit([accessControlList]); - await accessControlList.updateList(); - return new AccessControl(accessControlList, accessControlUnit); - } - - public handleEvent(roomId: string, event: any) { - if (roomId === this.accessControlList.roomId) { - this.accessControlList.updateForEvent(event); + 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)); } - public getUserAccess(mxid: string): EntityAccess { - return this.accessControlUnit.getAccessForUser(mxid, "CHECK_SERVER"); + public getUserAccess(mxid: StringUserID): EntityAccess { + return MPSAccess.getAccessForUser(this.accessControlRevisionIssuer.currentRevision, mxid, "CHECK_SERVER"); } - public async allow(mxid: string): Promise { - await this.accessControlList.createPolicy(EntityType.RULE_USER, Recommendation.Allow, mxid); + 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: string): Promise { - await this.accessControlList.unbanEntity(EntityType.RULE_USER, mxid); + 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 bdb64c20..3170a36e 100644 --- a/src/appservice/Api.ts +++ b/src/appservice/Api.ts @@ -1,9 +1,10 @@ import request from "request"; import express from "express"; import * as bodyParser from "body-parser"; -import { MjolnirManager } from "./MjolnirManager"; import * as http from "http"; import { Logger } from "matrix-appservice-bridge"; +import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; +import { isError, isStringUserID } from "matrix-protection-suite"; const log = new Logger("Api"); /** @@ -15,7 +16,7 @@ export class Api { constructor( private homeserver: string, - private mjolnirManager: MjolnirManager, + private mjolnirManager: AppServiceDraupnirManager, ) {} /** @@ -88,22 +89,24 @@ export class Api { 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) { + if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { 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 = this.mjolnirManager.getMjolnir(mjolnirId, userId); + const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId); if (mjolnir === undefined) { response.status(400).send("unknown mjolnir mxid"); return; } - response.status(200).json({ managementRoom: mjolnir.managementRoomId }); + response.status(200).json({ managementRoom: mjolnir.managementRoomID }); } /** @@ -122,8 +125,12 @@ export class Api { response.status(401).send("unauthorised"); return; } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - const existing = this.mjolnirManager.getOwnedMjolnirs(userId) + const existing = this.mjolnirManager.getOwnedDraupnir(userId) response.status(200).json(existing); } @@ -151,12 +158,20 @@ export class Api { response.status(401).send("unauthorised"); return; } + if (!isStringUserID(userId)) { + response.status(400).send("invalid user mxid"); + return; + } - // TODO: provisionNewMjolnir will throw if it fails... - // https://github.com/matrix-org/mjolnir/issues/408 - const [mjolnirId, managementRoom] = await this.mjolnirManager.provisionNewMjolnir(userId); - - response.status(200).json({ mxid: mjolnirId, roomId: managementRoom }); + 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 + }); } /** @@ -177,9 +192,13 @@ export class Api { 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) { + if (mjolnirId === undefined || !isStringUserID(mjolnirId)) { response.status(400).send("invalid request"); return; } @@ -192,14 +211,14 @@ export class Api { // TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId. // https://github.com/matrix-org/mjolnir/issues/408 - const mjolnir = this.mjolnirManager.getMjolnir(mjolnirId, userId); + const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId); if (mjolnir === undefined) { response.status(400).send("unknown mjolnir mxid"); return; } - await mjolnir.joinRoom(roomId); - await mjolnir.addProtectedRoom(roomId); + await mjolnir.client.joinRoom(roomId); + await mjolnir.protectedRoomsSet.protectedRoomsConfig.addRoom(roomId); response.status(200).json({}); } diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index f01b176f..98cadfa2 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -26,7 +26,6 @@ limitations under the License. */ import { AppServiceRegistration, Bridge, Request, WeakEvent, MatrixUser, Logger, setBridgeVersion, PrometheusMetrics } from "matrix-appservice-bridge"; -import { MjolnirManager } from ".//MjolnirManager"; import { DataStore } from ".//datastore"; import { PgDataStore } from "./postgres/PgDataStore"; import { Api } from "./Api"; @@ -35,6 +34,9 @@ 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, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; +import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); /** @@ -53,13 +55,26 @@ export class MjolnirAppService { private constructor( public readonly config: IConfig, public readonly bridge: Bridge, - public readonly mjolnirManager: MjolnirManager, + public readonly draupnirManager: AppServiceDraupnirManager, public readonly accessControl: AccessControl, private readonly dataStore: DataStore, - private readonly prometheusMetrics: PrometheusMetrics + 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, mjolnirManager); - this.commands = new AppserviceCommandHandler(this); + 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 + ); } /** @@ -69,7 +84,7 @@ export class MjolnirAppService { * @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, registrationFilePath: string) { + public static async makeMjolnirAppService(config: IConfig, dataStore: DataStore, eventDecoder: EventDecoder, registrationFilePath: string) { const bridge = new Bridge({ homeserverUrl: config.homeserver.url, domain: config.homeserver.domain, @@ -78,15 +93,56 @@ export class MjolnirAppService { // 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") }, + onUserQuery: () => { + throw new Error("Mjolnir uninitialized") + }, + onEvent: () => { + throw new Error("Mjolnir uninitialized") + }, }, suppressEcho: false, disableStores: true, }); await bridge.initialise(); - const accessControlListId = await bridge.getBot().getClient().resolveRoom(config.adminRoom); - const accessControl = await AccessControl.setupAccessControl(accessControlListId, bridge); + 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); + 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 @@ -100,14 +156,30 @@ export class MjolnirAppService { labels: ["status", "uuid"], }); - const mjolnirManager = await MjolnirManager.makeMjolnirManager(dataStore, bridge, accessControl, instanceCountGauge); + 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, + accessControl.ok, dataStore, - prometheus + eventDecoder, + roomStateManagerFactory, + clientCapabilityFactory, + clientsInRoomMap, + prometheus, + accessControlRoom.ok.toRoomIDOrAlias(), + botUserID ); bridge.opts.controller = { onUserQuery: appService.onUserQuery.bind(appService), @@ -126,7 +198,7 @@ export class MjolnirAppService { Logger.configure(config.logging ?? { console: "debug" }); const dataStore = new PgDataStore(config.db.connectionString); await dataStore.init(); - const service = await MjolnirAppService.makeMjolnirAppService(config, dataStore, registrationFilePath); + 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; @@ -151,7 +223,10 @@ export class MjolnirAppService { if ('invite' === mxEvent.content['membership'] && mxEvent.state_key === this.bridge.botUserId) { log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a mjolnir`); try { - await this.mjolnirManager.provisionNewMjolnir(mxEvent.sender) + 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: any) { 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. @@ -164,9 +239,19 @@ export class MjolnirAppService { } } } - this.accessControl.handleEvent(mxEvent['room_id'], mxEvent); - this.mjolnirManager.onEvent(request); 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); } /** diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts new file mode 100644 index 00000000..83ac9c20 --- /dev/null +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -0,0 +1,315 @@ +/** + * Copyright (C) 2022-2024 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { Bridge, Intent, Logger } from "matrix-appservice-bridge"; +import { getProvisionedMjolnirConfig } from "../config"; +import { MatrixClient } from "matrix-bot-sdk"; +import { DataStore, MjolnirRecord } from "./datastore"; +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 { Draupnir } from "../Draupnir"; +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'); + + +/** + * The DraupnirManager is responsible for: + * * Provisioning new draupnir instances. + * * Starting draupnir when the appservice is brought online. + * * Informing draupnir about new events. + */ +export class AppServiceDraupnirManager { + + 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 + ); + } + + 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; + } + + /** + * 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); + } + + /** + * 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)); + } + + /** + * 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); + + 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 getUnstartedDraupnirs(): UnstartedDraupnir[] { + return this.baseManager.getUnstartedDraupnirs(); + } + + public findUnstartedDraupnir(clientUserID: StringUserID): UnstartedDraupnir | undefined { + return this.baseManager.findUnstartedDraupnir(clientUserID); + } + + /** + * 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)); + if (records.length === 0) { + return ActionError.Result(`There is no record of a draupnir with the mxid ${draupnirClientID}`); + } else { + return await this.startDraupnirFromRecord(records[0]); + } + } + + /** + * 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. + Task((async () => { + 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) => { + 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. + Task((async () => { + 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); + } + } +} + +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.protectedRoomsConfig.addRoom(policyRoom.ok); + if (isError(addRoomResult)) { + return addRoomResult; + } + return await draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); +} diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts deleted file mode 100644 index 741fe95e..00000000 --- a/src/appservice/MjolnirManager.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { Mjolnir } from "../Mjolnir"; -import { Request, WeakEvent, Bridge, Intent, Logger } from "matrix-appservice-bridge"; -import { getProvisionedMjolnirConfig } from "../config"; -import PolicyList from "../models/PolicyList"; -import { MatrixClient, UserID } from "matrix-bot-sdk"; -import { DataStore, MjolnirRecord } from "./datastore"; -import { AccessControl } from "./AccessControl"; -import { Access } from "../models/AccessControlUnit"; -import { randomUUID } from "crypto"; -import EventEmitter from "events"; -import { MatrixEmitter } from "../MatrixEmitter"; -import { Permalinks } from "../commands/interface-manager/Permalinks"; -import { MatrixRoomReference } from "../commands/interface-manager/MatrixRoomReference"; -import { Gauge } from "prom-client"; -import { decrementGaugeValue, incrementGaugeValue } from "../utils"; - -const log = new Logger('MjolnirManager'); - -// FIXME: AAAAAAAAaaaaaaaaaaaaa there's some inconsistency between "mjolnir id" "mjolnirRecord.localpart" and "user if of the mjolnir" -// all over this file. - -/** - * The MjolnirManager is responsible for: - * * Provisioning new mjolnir instances. - * * Starting mjolnirs when the appservice is brought online. - * * Informing mjolnirs about new events. - */ -export class MjolnirManager { - private readonly mjolnirs: Map = new Map(); - private readonly unstartedMjolnirs: Map = new Map(); - - private constructor( - private readonly dataStore: DataStore, - private readonly bridge: Bridge, - private readonly accessControl: AccessControl, - private readonly instanceCountGauge: Gauge<"status" | "uuid"> - ) { - - } - - /** - * Create the mjolnir manager from the datastore and the access control. - * @param dataStore The data store interface that has the details for provisioned mjolnirs. - * @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 makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl, instanceCountGauge: Gauge<"status" | "uuid">): Promise { - const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl, instanceCountGauge); - await mjolnirManager.startMjolnirs(await dataStore.list()); - return mjolnirManager; - } - - /** - * 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: string, managementRoomId: string, client: MatrixClient): Promise { - const mxid = await client.getUserId(); - const intentListener = new MatrixIntentListener(mxid); - const managedMjolnir = new ManagedMjolnir( - requestingUserId, - await Mjolnir.setupMjolnirFromConfig( - client, - intentListener, - getProvisionedMjolnirConfig(managementRoomId) - ), - intentListener, - ); - await managedMjolnir.start(); - this.mjolnirs.set(mxid, managedMjolnir); - this.unstartedMjolnirs.delete(mxid); - incrementGaugeValue(this.instanceCountGauge, "offline", localPart); - decrementGaugeValue(this.instanceCountGauge, "disabled", localPart); - incrementGaugeValue(this.instanceCountGauge, "online", localPart); - return managedMjolnir; - } - - /** - * Gets a mjolnir for the corresponding mxid that is owned by a specific user. - * @param mjolnirId The mxid of the mjolnir we are trying to get. - * @param ownerId The owner of the mjolnir. We ask for it explicitly to not leak access to another user's mjolnir. - * @returns The matching managed mjolnir instance. - */ - public getMjolnir(mjolnirId: string, ownerId: string): ManagedMjolnir | undefined { - const mjolnir = this.mjolnirs.get(mjolnirId); - if (mjolnir) { - if (mjolnir.ownerId !== ownerId) { - throw new Error(`${mjolnirId} is owned by a different user to ${ownerId}`); - } else { - return mjolnir; - } - } else { - return undefined; - } - } - - /** - * Find all of the mjolnirs that are owned by this specific user. - * @param ownerId An owner of multiple mjolnirs. - * @returns Any mjolnirs that they own. - */ - public getOwnedMjolnirs(ownerId: string): ManagedMjolnir[] { - // TODO we need to use the database for this but also provide the utility - // for going from a MjolnirRecord to a ManagedMjolnir. - // https://github.com/matrix-org/mjolnir/issues/409 - return [...this.mjolnirs.values()].filter(mjolnir => mjolnir.ownerId !== ownerId); - } - - /** - * Listener that should be setup and called by `MjolnirAppService` while listening to the bridge abstraction provided by matrix-appservice-bridge. - */ - public onEvent(request: Request) { - // TODO We need a way to map a room id (that the event is from) to a set of managed mjolnirs that should be informed. - // https://github.com/matrix-org/mjolnir/issues/412 - [...this.mjolnirs.values()].forEach((mj: ManagedMjolnir) => mj.onEvent(request)); - } - - /** - * provision a new mjolnir for a matrix user. - * @param requestingUserId The mxid of the user we are creating a mjolnir for. - * @returns The matrix id of the new mjolnir and its management room. - */ - public async provisionNewMjolnir(requestingUserId: string): Promise<[string, string]> { - const access = this.accessControl.getUserAccess(requestingUserId); - if (access.outcome !== Access.Allowed) { - throw new Error(`${requestingUserId} tried to provision a mjolnir 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); - - 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 - } - } - }); - - const mjolnir = await this.makeInstance(mjolnirLocalPart, requestingUserId, managementRoomId, mjIntent.matrixClient); - await mjolnir.createFirstList(requestingUserId, "list"); - - await this.dataStore.store({ - local_part: mjolnirLocalPart, - owner: requestingUserId, - management_room: managementRoomId, - }); - - return [mjIntent.userId, managementRoomId]; - } else { - throw new Error(`User: ${requestingUserId} has already provisioned ${provisionedMjolnirs.length} Draupnirs.`); - } - } - - public reportUnstartedMjolnir(code: UnstartedMjolnir.FailCode, cause: any, mjolnirRecord: MjolnirRecord, mxid: string): void { - this.unstartedMjolnirs.set(mjolnirRecord.local_part, new UnstartedMjolnir(mjolnirRecord, new UserID(mxid), code, cause)); - } - - public getUnstartedMjolnirs(): UnstartedMjolnir[] { - return [...this.unstartedMjolnirs.values()]; - } - - public findUnstartedMjolnir(localPart: string): UnstartedMjolnir | undefined { - return [...this.unstartedMjolnirs.values()].find(unstarted => unstarted.mjolnirRecord.local_part === localPart); - } - - /** - * 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; - } - - /** - * 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 startMjolnir(mjolnirRecord: MjolnirRecord): Promise { - // if a mjolnir is in `this.mjonirs` it is started, as if it is present, it is going to be given Matrix events. - if (this.mjolnirs.has(mjolnirRecord.local_part)) { - 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. - mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`); - this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.Unauthorized, access.outcome, mjolnirRecord, mjIntent.userId); - decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); - incrementGaugeValue(this.instanceCountGauge, "disabled", mjolnirRecord.local_part); - } else { - await this.makeInstance( - mjolnirRecord.local_part, - mjolnirRecord.owner, - mjolnirRecord.management_room, - mjIntent.matrixClient, - ).catch((e: any) => { - log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e); - // Don't await, we don't want to clobber initialization if this fails. - mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir could not be started. Please alert the administrator`); - this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.StartError, e, mjolnirRecord, mjIntent.userId); - decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part); - incrementGaugeValue(this.instanceCountGauge, "offline", mjolnirRecord.local_part); - }); - } - } - - // 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 startMjolnirs(mjolnirRecords: MjolnirRecord[]): Promise { - for (const mjolnirRecord of mjolnirRecords) { - await this.startMjolnir(mjolnirRecord); - } - } -} - -export class ManagedMjolnir { - public constructor( - public readonly ownerId: string, - private readonly mjolnir: Mjolnir, - private readonly matrixEmitter: MatrixIntentListener, - ) { } - - public async onEvent(request: Request) { - this.matrixEmitter.handleEvent(request.getData()); - } - - public async joinRoom(roomId: string) { - await this.mjolnir.client.joinRoom(roomId); - } - public async addProtectedRoom(roomId: string) { - await this.mjolnir.addProtectedRoom(roomId); - } - - public async createFirstList(mjolnirOwnerId: string, shortcode: string) { - const listRoomId = await PolicyList.createList( - this.mjolnir.client, - shortcode, - [mjolnirOwnerId], - { name: `${mjolnirOwnerId}'s policy room` } - ); - const roomRef = MatrixRoomReference.fromPermalink(Permalinks.forRoom(listRoomId)); - await this.mjolnir.addProtectedRoom(listRoomId); - return await this.mjolnir.policyListManager.watchList(roomRef); - } - - public get managementRoomId(): string { - return this.mjolnir.managementRoomId; - } - - /** - * Intended to be called by the MjolnirManager to make sure the mjolnir is ready to listen to events. - * This managed mjolnir should not be informed of any events via `onEvent` until `start` is called. - */ - public async start(): Promise { - await this.mjolnir.start(); - } -} - -/** - * This is used to listen for events intended for a single mjolnir that resides in the appservice. - * This exists entirely because the Mjolnir class was previously designed only to receive events - * from a syncing matrix-bot-sdk MatrixClient. Since appservices provide a transactional push - * api for all users on the appservice, almost the opposite of sync, we needed to create an - * interface for both. See `MatrixEmitter`. - */ -export class MatrixIntentListener extends EventEmitter implements MatrixEmitter { - constructor(private readonly mjolnirId: string) { - super() - } - - public handleEvent(mxEvent: WeakEvent) { - // These are ordered to be the same as matrix-bot-sdk's MatrixClient - // They shouldn't need to be, but they are just in case it matters. - if (mxEvent['type'] === 'm.room.member' && mxEvent.state_key === this.mjolnirId) { - if (mxEvent['content']['membership'] === 'leave') { - this.emit('room.leave', mxEvent.room_id, mxEvent); - } - if (mxEvent['content']['membership'] === 'invite') { - this.emit('room.invite', mxEvent.room_id, mxEvent); - } - if (mxEvent['content']['membership'] === 'join') { - this.emit('room.join', mxEvent.room_id, mxEvent); - } - } - if (mxEvent.type === 'm.room.message') { - this.emit('room.message', mxEvent.room_id, mxEvent); - } - if (mxEvent.type === 'm.room.tombstone' && mxEvent.state_key === '') { - this.emit('room.archived', mxEvent.room_id, mxEvent); - } - this.emit('room.event', mxEvent.room_id, mxEvent); - - } - - /** - * To be called by `Mjolnir`. - */ - public async start() { - // Nothing to do. - } - - /** - * To be called by `Mjolnir`. - */ - public stop() { - // Nothing to do. - } -} - -export class UnstartedMjolnir { - constructor( - public readonly mjolnirRecord: MjolnirRecord, - public readonly mxid: UserID, - public readonly failCode: UnstartedMjolnir.FailCode, - public readonly cause: any, - ) { - - } -} - -export namespace UnstartedMjolnir { - export enum FailCode { - Unauthorized = "Unauthorized", - StartError = "StartError", - } -} diff --git a/src/appservice/bot/AccessCommands.tsx b/src/appservice/bot/AccessCommands.tsx index 6854c50a..ad40f597 100644 --- a/src/appservice/bot/AccessCommands.tsx +++ b/src/appservice/bot/AccessCommands.tsx @@ -5,9 +5,8 @@ import { defineInterfaceCommand, findTableCommand } from "../../commands/interface-manager/InterfaceCommand"; import { findPresentationType, parameters, ParsedKeywords } from "../../commands/interface-manager/ParameterParsing"; -import { CommandResult } from "../../commands/interface-manager/Validation"; import { AppserviceContext } from "./AppserviceCommandHandler"; -import { UserID } from "matrix-bot-sdk"; +import { UserID, ActionResult } from "matrix-protection-suite" import { defineMatrixInterfaceAdaptor } from "../../commands/interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "../../commands/interface-manager/MatrixHelpRenderer"; @@ -21,9 +20,8 @@ defineInterfaceCommand({ description: 'The user that should be allowed to provision a bot' } ]), - command: async function (this: AppserviceContext, _keywords: ParsedKeywords, user: UserID): Promise> { - await this.appservice.accessControl.allow(user.toString()); - return CommandResult.Ok(undefined); + 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." }) @@ -43,9 +41,8 @@ defineInterfaceCommand({ description: 'The user which shall not be allowed to provision bots anymore' } ]), - command: async function (this: AppserviceContext, _keywords: ParsedKeywords, user: UserID): Promise> { - await this.appservice.accessControl.remove(user.toString()); - return CommandResult.Ok(undefined); + 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." }) diff --git a/src/appservice/bot/AppserviceBotEmitter.ts b/src/appservice/bot/AppserviceBotEmitter.ts deleted file mode 100644 index f24070f8..00000000 --- a/src/appservice/bot/AppserviceBotEmitter.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (C) 2023 Gnuxie - * All rights reserved. - */ - -import EventEmitter from "events"; -import { MatrixEmitter } from "../../MatrixEmitter"; - - -// See https://github.com/Gnuxie/Draupnir/issues/13. -// The appservice bot does not support waiting for events yet. -export class AppserviceBotEmitter extends EventEmitter implements MatrixEmitter { - start(): Promise { - throw new Error("Method not implemented."); - } - stop(): void { - throw new Error("Method not implemented."); - } -} diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 887c53fd..5563a71d 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -9,8 +9,11 @@ import { defineCommandTable, defineInterfaceCommand, findCommandTable, findTable import { defineMatrixInterfaceAdaptor, findMatrixInterfaceAdaptor, MatrixContext } from '../../commands/interface-manager/MatrixInterfaceAdaptor'; import { ArgumentStream, RestDescription, findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; import { MjolnirAppService } from '../AppService'; -import { CommandResult } from '../../commands/interface-manager/Validation'; import { renderHelp } from '../../commands/interface-manager/MatrixHelpRenderer'; +import { ActionResult, ClientPlatform, Ok, RoomMessage, StringRoomID, StringUserID, 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"); @@ -18,18 +21,18 @@ export interface AppserviceContext extends MatrixContext { appservice: MjolnirAppService; } -export type AppserviceBaseExecutor = (this: AppserviceContext, ...args: any[]) => Promise>; +export type AppserviceBaseExecutor = (this: AppserviceContext, ...args: unknown[]) => Promise>; import '../../commands/interface-manager/MatrixPresentations'; import './ListCommand'; import './AccessCommands'; -import { AppserviceBotEmitter } from './AppserviceBotEmitter'; - defineInterfaceCommand({ parameters: parameters([], new RestDescription('command parts', findPresentationType("any"))), table: "appservice bot", - command: async function () { return CommandResult.Ok(findCommandTable("appservice bot")) }, + command: async function () { + return Ok(findCommandTable("appservice bot")) + }, designator: ["help"], summary: "Display this message" }) @@ -41,17 +44,55 @@ defineMatrixInterfaceAdaptor({ export class AppserviceCommandHandler { private readonly commandTable = findCommandTable("appservice bot"); + private commandContext: Omit; + private readonly reactionHandler: MatrixReactionHandler; constructor( - private readonly appservice: MjolnirAppService + 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 + ); + 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.type !== 'm.room.message' && mxEvent.room_id !== this.appservice.config.adminRoom) { + 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" @@ -60,11 +101,8 @@ export class AppserviceCommandHandler { if (command) { const adaptor = findMatrixInterfaceAdaptor(command); const context: AppserviceContext = { - appservice: this.appservice, - roomId: mxEvent.room_id, - event: mxEvent, - client: this.appservice.bridge.getBot().getClient(), - emitter: new AppserviceBotEmitter(), + ...this.commandContext, + event: parsedEvent, }; adaptor.invoke(context, context, ...argumentStream.rest()); return; diff --git a/src/appservice/bot/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index deae22d0..f6a2a4fd 100644 --- a/src/appservice/bot/ListCommand.tsx +++ b/src/appservice/bot/ListCommand.tsx @@ -4,16 +4,15 @@ */ import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from '../../commands/interface-manager/MatrixInterfaceAdaptor'; -import { MatrixSendClient } from '../../MatrixEmitter'; -import { UnstartedMjolnir } from '../MjolnirManager'; import { BaseFunction, defineInterfaceCommand } from '../../commands/interface-manager/InterfaceCommand'; import { findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; -import { AppserviceBaseExecutor, AppserviceContext } from './AppserviceCommandHandler'; -import { UserID } from 'matrix-bot-sdk'; -import { CommandError, CommandResult } from '../../commands/interface-manager/Validation'; +import { AppserviceBaseExecutor } from './AppserviceCommandHandler'; import { tickCrossRenderer } from '../../commands/interface-manager/MatrixHelpRenderer'; import { JSXFactory } from '../../commands/interface-manager/JSXFactory'; import { renderMatrixAndSend } from '../../commands/interface-manager/DeadDocumentMatrix'; +import { ActionError, ActionResult, isError, Ok, 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, @@ -29,18 +28,18 @@ const listUnstarted = defineInterfaceCommand({ table: "appservice bot", parameters: parameters([]), command: async function () { - return CommandResult.Ok(this.appservice.mjolnirManager.getUnstartedMjolnirs()); + return Ok(this.appservice.draupnirManager.getUnstartedDraupnirs()); }, - summary: "List any Mjolnir that failed to start." + 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: any, result: CommandResult) { + renderer: async function (this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: any, result: ActionResult) { tickCrossRenderer.call(this, client, commandRoomId, event, result); // don't await, it doesn't really matter. - if (result.isErr()) { + if (isError(result)) { return; // just let the default handler deal with it. } const unstarted = result.ok; @@ -48,13 +47,12 @@ defineMatrixInterfaceAdaptor({ Unstarted Mjolnir: {unstarted.length}
      - {unstarted.map(mjolnir => { + {unstarted.map(draupnir => { return
    • - {mjolnir.mjolnirRecord.owner}, - {mjolnir.mxid.toString()} - {mjolnir.failCode}: + {draupnir.clientUserID} + {draupnir.failType}:
      - {mjolnir.cause} + {draupnir.cause}
    • })}
    @@ -75,19 +73,18 @@ const restart = defineInterfaceCommand({ table: "appservice bot", parameters: parameters([ { - name: "mjolnir", + name: "draupnir", acceptor: findPresentationType("UserID"), - description: 'The userid of the mjolnir to restart' + description: 'The userid of the draupnir to restart' } ]), - command: async function (this: AppserviceContext, _keywords, mjolnirId: UserID): Promise> { - const mjolnirManager = this.appservice.mjolnirManager; - const mjolnir = mjolnirManager.findUnstartedMjolnir(mjolnirId.localpart); - if (mjolnir?.mjolnirRecord === undefined) { - return CommandError.Result(`We can't find the unstarted mjolnir ${mjolnirId}, is it running?`); + 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}, is it already running?`); } - await mjolnirManager.startMjolnir(mjolnir?.mjolnirRecord); - return CommandResult.Ok(true); + return await draupnirManager.startDraupnirFromMXID(draupnirUser.toString()); }, summary: "Attempt to restart a Mjolnir." }) diff --git a/src/appservice/datastore.ts b/src/appservice/datastore.ts index 3f9e029d..64f4c86d 100644 --- a/src/appservice/datastore.ts +++ b/src/appservice/datastore.ts @@ -25,10 +25,12 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ +import { StringRoomID, StringUserID } from "matrix-protection-suite"; + export interface MjolnirRecord { local_part: string, - owner: string, - management_room: string, + owner: StringUserID, + management_room: StringRoomID, } /** diff --git a/src/backingstore/better-sqlite3/BetterSqliteStore.ts b/src/backingstore/better-sqlite3/BetterSqliteStore.ts new file mode 100644 index 00000000..dd51bf5b --- /dev/null +++ b/src/backingstore/better-sqlite3/BetterSqliteStore.ts @@ -0,0 +1,166 @@ +// Copyright 2024 Gnuxie +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from matrix-appservice-bridge +// https://github.com/matrix-org/matrix-appservice-bridge +// + +import BetterSqlite3, { Database } from "better-sqlite3"; +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 ( + version INTEGER UNIQUE NOT NULL + ) 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; +}; + +export type SchemaUpdateFunction = (db: Database) => void; + +/** + * BetterSqliteStore datastore abstraction which can be inherited by a specialised bridge class. + * Please note, that the client library provides synchronous access to sqlite, due to the nature of + * node.js FFI to C I imagine. + * + * @example + * class MyBridgeStore extends BetterSqliteStore { + * constructor(myurl) { + * super([schemav1, schemav2, schemav3], { url: myurl }); + * } + * + * async getData() { + * return this.sql`SELECT * FROM mytable` + * } + * } + * + * // Which can then be used by doing + * const store = new MyBridgeStore("data.db"); + * store.ensureSchema(); + * const data = await store.getData(); + */ +export abstract class BetterSqliteStore { + private hasEnded = false; + public readonly db: Database; + + 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(); + + 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; + } + } + + // 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]; + 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}`); + } + + /** + * 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"); + } +} diff --git a/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts b/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts new file mode 100644 index 00000000..75b989b4 --- /dev/null +++ b/src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts @@ -0,0 +1,144 @@ +// Copyright (C) 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +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 schema = [ + `CREATE TABLE room_info ( + room_id TEXT PRIMARY KEY NOT NULL, + last_complete_writeback INTEGER NOT NULL + ) STRICT;`, + `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;` +]; + +type RoomStateEventReplaceValue = [StringRoomID, string, string, string]; + +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) { + db.prepare(text).run(); + }), { + 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)); + } + } + + 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)); + } 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)); + } + } + + 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, + }) + ); + } + 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); + } +} diff --git a/src/capabilities/CommonRenderers.tsx b/src/capabilities/CommonRenderers.tsx new file mode 100644 index 00000000..76d6634e --- /dev/null +++ b/src/capabilities/CommonRenderers.tsx @@ -0,0 +1,88 @@ +// Copyright 2022-2024 Gnuxie +// Copyright 2019 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { ActionError, ActionException, ActionResult, DescriptionMeta, MatrixRoomReference, RoomSetResult, StringRoomID, isOk } from "matrix-protection-suite"; +import { DocumentNode } from "../commands/interface-manager/DeadDocument"; +import { JSXFactory } from "../commands/interface-manager/JSXFactory"; +import { renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; + +export function renderElaborationTrail(error: ActionError): DocumentNode { + 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. +

    +} + +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()}
    +
    +} + +export function renderFailedSingularConsequence( + description: DescriptionMeta, + error: ActionError +): DocumentNode { + return +
    + {description.name}: {error.mostRelevantElaboration} + {renderDetailsNotice(error)} + {renderElaborationTrail(error)} + {renderExceptionTrail(error)} +
    +
    +} + +function renderRoomOutcomeOk(roomID: StringRoomID): DocumentNode { + return {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - OK +} +function renderRoomOutcomeError(roomID: StringRoomID, error: ActionError): DocumentNode { + return +
    + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - Error: {error.mostRelevantElaboration} + {renderDetailsNotice(error)} + {renderElaborationTrail(error)} + {renderExceptionTrail(error)} +
    +
    +} + +export function renderRoomOutcome(roomID: StringRoomID, result: ActionResult): DocumentNode { + if (isOk(result)) { + return renderRoomOutcomeOk(roomID); + } else { + return renderRoomOutcomeError(roomID, result.error); + } +} + +export function renderRoomSetResult(roomResults: RoomSetResult, { summary }: { summary: DocumentNode }): DocumentNode { + return
    + {summary} +
      {[...roomResults.map.entries()].map(([roomID, outcome]) => { + return
    • {renderRoomOutcome(roomID, outcome)}
    • + })}
    +
    +} diff --git a/src/capabilities/DraupnirRendererMessageCollector.tsx b/src/capabilities/DraupnirRendererMessageCollector.tsx new file mode 100644 index 00000000..30c4d8c6 --- /dev/null +++ b/src/capabilities/DraupnirRendererMessageCollector.tsx @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +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 { JSXFactory } 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 { + 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 new file mode 100644 index 00000000..ca23e95b --- /dev/null +++ b/src/capabilities/RendererMessageCollector.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { DescriptionMeta } from "matrix-protection-suite"; +import { DocumentNode } from "../commands/interface-manager/DeadDocument"; + +export enum MessageType { + Document = 'Document', + OneLine = 'OneLine', + SingleEffectError = 'SingleEffectError', +} + +export interface RendererMessageCollector { + addMessage(protection: DescriptionMeta, message: DocumentNode): void; + addOneliner(protection: DescriptionMeta, message: DocumentNode): void; + getMessages(): RendererMessage[]; +} + +export interface RendererMessage { + 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, + }); + } + + 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 new file mode 100644 index 00000000..16533ccd --- /dev/null +++ b/src/capabilities/ServerACLConsequencesRenderer.tsx @@ -0,0 +1,88 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// 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 { RendererMessageCollector } from "./RendererMessageCollector"; +import { renderFailedSingularConsequence, renderRoomSetResult } from "./CommonRenderers"; +import { JSXFactory } 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 async consequenceForServerInRoom(roomID: StringRoomID, revision: PolicyListRevision): Promise> { + const capabilityResult = await this.capability.consequenceForServerInRoom(roomID, revision); + if (isError(capabilityResult)) { + this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)) + return capabilityResult; + } + this.messageCollector.addOneliner(this.description, + Setting server ACL in {Permalinks.forRoom(roomID)} as it is out of sync with watched policies. + ) + return Ok(undefined); + } + public async consequenceForServerInRoomSet(revision: PolicyListRevision): Promise> { + const capabilityResult = await this.capability.consequenceForServerInRoomSet(revision); + if (isError(capabilityResult)) { + this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)) + return capabilityResult; + } + this.messageCollector.addMessage( + this.description, renderRoomSetResult(capabilityResult.ok, { + summary: {this.description.name}: Updating server ACL in protected rooms. + }) + ); + return capabilityResult; + } + public async unbanServerFromRoomSet(serverName: string, reason: string): Promise> { + const capabilityResult = await this.capability.unbanServerFromRoomSet(serverName, reason); + if (isError(capabilityResult)) { + this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)); + return capabilityResult; + } + this.messageCollector.addMessage( + this.description, renderRoomSetResult(capabilityResult.ok, { + summary: {this.description.name}: Removing {serverName} from denied servers in protected rooms. + }) + ); + return capabilityResult; + } + +} + +describeCapabilityRenderer({ + 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 + }) + } +}) diff --git a/src/capabilities/StandardEventConsequencesRenderer.tsx b/src/capabilities/StandardEventConsequencesRenderer.tsx new file mode 100644 index 00000000..d17876c6 --- /dev/null +++ b/src/capabilities/StandardEventConsequencesRenderer.tsx @@ -0,0 +1,56 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +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 { JSXFactory } 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 async consequenceForEvent(roomID: StringRoomID, eventID: StringEventID, reason: string): Promise> { + const capabilityResult = await this.capability.consequenceForEvent(roomID, eventID, reason); + if (isError(capabilityResult)) { + this.messageCollector.addOneliner(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)) + return capabilityResult; + } + this.messageCollector.addOneliner(this.description, Redacting {Permalinks.forEvent(roomID, eventID)}.) + 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 + ); + } +}) + +describeCapabilityContextGlue({ + 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 new file mode 100644 index 00000000..c1e62e4e --- /dev/null +++ b/src/capabilities/StandardUserConsequencesRenderer.tsx @@ -0,0 +1,116 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { JSXFactory } from "../commands/interface-manager/JSXFactory"; +import { ActionResult, Capability, DescriptionMeta, Ok, Permalinks, PolicyListRevision, ResultForUserInSetMap, StandardUserConsequencesContext, StringRoomID, StringUserID, UserConsequences, describeCapabilityContextGlue, describeCapabilityRenderer, isError } from "matrix-protection-suite"; +import { RendererMessageCollector } from "./RendererMessageCollector"; +import { renderFailedSingularConsequence, renderRoomSetResult } from "./CommonRenderers"; +import { DocumentNode } from "../commands/interface-manager/DeadDocument"; +import { Draupnir } from "../Draupnir"; + +// yeah i know this is a bit insane but whatever, it can be our secret. +function renderResultForUserInSetMap(usersInSetMap: ResultForUserInSetMap, { + ingword, + nnedword, + description +}: { + ingword: string, + nnedword: string, + description: DescriptionMeta, +}): DocumentNode { + return
    + {description.name}: {ingword} {usersInSetMap.size} {usersInSetMap.size === 1 ? 'user' : 'users'} from protected rooms. + {[...usersInSetMap.entries()].map(([userID, roomResults]) => { + return renderRoomSetResult(roomResults, { summary: {userID} will be {nnedword} from {roomResults.map.size} rooms. }) + })} +
    +} + + +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 async consequenceForUserInRoom(roomID: StringRoomID, userID: StringUserID, reason: string): Promise> { + const capabilityResult = await this.capability.consequenceForUserInRoom(roomID, userID, reason); + if (isError(capabilityResult)) { + this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)) + return capabilityResult; + } + this.messageCollector.addOneliner(this.description, + Banning user {userID} in {Permalinks.forRoom(roomID)} for {reason}. + ) + return Ok(undefined); + + } + public async consequenceForUserInRoomSet(revision: PolicyListRevision): Promise> { + const capabilityResult = await this.capability.consequenceForUserInRoomSet(revision); + if (isError(capabilityResult)) { + this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)) + return capabilityResult; + } + const usersInSetMap = capabilityResult.ok; + if (usersInSetMap.size === 0) { + return capabilityResult; + } + this.messageCollector.addMessage(this.description, renderResultForUserInSetMap(usersInSetMap, { + ingword: 'Banning', + nnedword: 'banned', + description: this.description, + })); + return capabilityResult; + + } + public async unbanUserFromRoomSet(userID: StringUserID, reason: string): Promise> { + const capabilityResult = await this.capability.unbanUserFromRoomSet(userID, reason); + if (isError(capabilityResult)) { + this.messageCollector.addMessage(this.description, renderFailedSingularConsequence(this.description, capabilityResult.error)) + return capabilityResult; + } + const usersInSetMap = capabilityResult.ok; + if (usersInSetMap.size === 0) { + return capabilityResult; + } + this.messageCollector.addMessage(this.description, renderResultForUserInSetMap(usersInSetMap, { + ingword: 'Unbanning', + nnedword: '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) + } +}) + +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 + }) + } +}) diff --git a/src/capabilities/capabilityIndex.ts b/src/capabilities/capabilityIndex.ts new file mode 100644 index 00000000..26cf86e7 --- /dev/null +++ b/src/capabilities/capabilityIndex.ts @@ -0,0 +1,3 @@ +import "./StandardEventConsequencesRenderer"; +import "./ServerACLConsequencesRenderer"; +import "./StandardUserConsequencesRenderer"; diff --git a/src/commands/AddRemoveRoomFromDirectoryCommand.ts b/src/commands/AddRemoveRoomFromDirectoryCommand.ts deleted file mode 100644 index c30db736..00000000 --- a/src/commands/AddRemoveRoomFromDirectoryCommand.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { MjolnirContext } from "./CommandHandler"; -import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; -import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { parameters, findPresentationType, ParsedKeywords } from "./interface-manager/ParameterParsing"; -import { CommandResult, CommandError } from "./interface-manager/Validation"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; - -async function addRemoveFromDirectory(mjolnir: Mjolnir, roomRef: MatrixRoomReference, visibility: "public" | "private"): Promise> { - const isAdmin = await mjolnir.isSynapseAdmin(); - if (!isAdmin) { - return CommandError.Result('I am not a Synapse administrator, or the endpoint to remove/add to the room directory is blocked'); - } - const targetRoomId = (await roomRef.resolve(mjolnir.client)).toRoomIdOrAlias(); - await mjolnir.client.setDirectoryVisibility(targetRoomId, visibility); - return CommandResult.Ok(undefined); -} - -// Note: While synapse admin API is not required for these endpoints, -// I believe they were added to manage rooms other than the ones you are admin in. -defineInterfaceCommand({ - table: "synapse admin", - designator: ["directory", "add"], - summary: "Publishes a room in the server's room directory.", - parameters: parameters([ - { - name: 'room', - acceptor: findPresentationType("MatrixRoomReference"), - } - ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, targetRoom: MatrixRoomReference): Promise> { - return await addRemoveFromDirectory(this.mjolnir, targetRoom, "public"); - }, -}) - -defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "directory", "add"), - renderer: tickCrossRenderer, -}) - -defineInterfaceCommand({ - table: "synapse admin", - designator: ["directory", "remove"], - summary: "Removes a room from the server's room directory.", - parameters: parameters([ - { - name: 'room', - acceptor: findPresentationType("MatrixRoomReference"), - } - ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, targetRoom: MatrixRoomReference): Promise> { - return await addRemoveFromDirectory(this.mjolnir, targetRoom, "private"); - }, -}) - -defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("synapse admin", "directory", "remove"), - renderer: tickCrossRenderer, -}) diff --git a/src/commands/AliasCommands.ts b/src/commands/AliasCommands.ts index ec58ae46..cb85c93e 100644 --- a/src/commands/AliasCommands.ts +++ b/src/commands/AliasCommands.ts @@ -27,11 +27,11 @@ limitations under the License. import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { MjolnirContext } from "./CommandHandler"; -import { MatrixRoomAlias, MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; +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 { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; // TODO: we should probably add an --admin keyword to these commands // since they don't actually need admin. Mjolnir had them as admin though. @@ -53,15 +53,18 @@ defineInterfaceCommand({ description: 'The room to move the alias to.' } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, movingAlias: MatrixRoomAlias, room: MatrixRoomReference): Promise> { - const isAdmin = await this.mjolnir.isSynapseAdmin(); - if (!isAdmin) { - return CommandError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); + 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 room.resolve(this.mjolnir.client); - await this.mjolnir.client.deleteRoomAlias(movingAlias.toRoomIdOrAlias()); - await this.mjolnir.client.createRoomAlias(movingAlias.toRoomIdOrAlias(), newRoomId.toRoomIdOrAlias()); - return CommandResult.Ok(undefined); + 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); }, }) @@ -86,14 +89,17 @@ defineInterfaceCommand({ description: 'The room to add the alias to.' } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, movingAlias: MatrixRoomAlias, room: MatrixRoomReference): Promise> { - const isAdmin = await this.mjolnir.isSynapseAdmin(); - if (!isAdmin) { - return CommandError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); + 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; } - const roomId = await room.resolve(this.mjolnir.client); - await this.mjolnir.client.createRoomAlias(movingAlias.toRoomIdOrAlias(), roomId.toRoomIdOrAlias()); - return CommandResult.Ok(undefined); + await this.draupnir.client.createRoomAlias(movingAlias.toRoomIDOrAlias(), roomID.ok.toRoomIDOrAlias()); + return Ok(undefined); }, }) @@ -113,13 +119,13 @@ defineInterfaceCommand({ description: 'The alias that should be deleted.' } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, alias: MatrixRoomAlias): Promise> { - const isAdmin = await this.mjolnir.isSynapseAdmin(); - if (!isAdmin) { - return CommandError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); + 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.mjolnir.client.deleteRoomAlias(alias.toRoomIdOrAlias()); - return CommandResult.Ok(undefined); + await this.draupnir.client.deleteRoomAlias(alias.toRoomIDOrAlias()); + return Ok(undefined); }, }) diff --git a/src/commands/Ban.tsx b/src/commands/Ban.tsx index f3659a79..673206b2 100644 --- a/src/commands/Ban.tsx +++ b/src/commands/Ban.tsx @@ -25,89 +25,57 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { UserID } from "matrix-bot-sdk"; -import { MjolnirContext } from "./CommandHandler"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; -import { Mjolnir } from "../Mjolnir"; -import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../models/ListRule"; -import PolicyList from "../models/PolicyList"; -import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, makePresentationType, ParameterDescription, parameters, ParsedKeywords, RestDescription, simpleTypeValidator, union } from "./interface-manager/ParameterParsing"; +import { DraupnirContext } from "./CommandHandler"; +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 { definePresentationRenderer } from "./interface-manager/DeadDocumentPresentation"; -import { DocumentNode } from "./interface-manager/DeadDocument"; -import { JSXFactory } from "./interface-manager/JSXFactory"; +import { Draupnir } from "../Draupnir"; +import { ActionResult, MatrixRoomReference, PolicyRoomEditor, PolicyRuleType, isError, UserID } from "matrix-protection-suite"; +import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -makePresentationType({ - name: "PolicyList", - validator: simpleTypeValidator("PolicyList", (readItem: unknown) => readItem instanceof PolicyList) -}) - -definePresentationRenderer(findPresentationType("PolicyList"), function(list: PolicyList): DocumentNode { - return

    - {list.listShortcode} {list.roomId} -

    -}) - - -export async function findPolicyListFromRoomReference(mjolnir: Mjolnir, policyListReference: MatrixRoomReference): Promise> { - const policyListRoomId = (await policyListReference.resolve(mjolnir.client)).toRoomIdOrAlias(); - const policyList = mjolnir.policyListManager.lists.find(list => list.roomId === policyListRoomId); - if (policyList !== undefined) { - return CommandResult.Ok(policyList); - } else { - // Would it be acceptable to create an anonymous policy list that is not being watched - // by mjolnir for the purposes of banning / unbanning? unbanning requires loading the rules - // but banning doesn't.. so this means you'd want two types of lists - // one of which can only be made by a factory which watches them via sync - // and makes sure mjolnir is joined. - // This refactor would be important for previewing lists regardless. - return CommandError.Result(`There is no policy list that Mjolnir is watching for ${policyListReference.toPermalink()}`); - } -} -export async function findPolicyListFromShortcode(mjolnir: Mjolnir, designator: string): Promise> { - const list = mjolnir.policyListManager.resolveListShortcode(designator); - if (list !== undefined) { - return CommandResult.Ok(list); - } else { - return CommandError.Result(`There is no policy list with the shortcode ${designator} and a default list couldn't be found`); +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: MjolnirContext, + this: DraupnirContext, _keywords: ParsedKeywords, entity: UserID|MatrixRoomReference|string, - policyListReference: MatrixRoomReference|string|PolicyList, + policyRoomReference: MatrixRoomReference, ...reasonParts: string[] - ): Promise> { - // first step is to resolve the policy list - const policyListResult = typeof policyListReference === 'string' - ? await findPolicyListFromShortcode(this.mjolnir, policyListReference) - : policyListReference instanceof PolicyList - ? CommandResult.Ok(policyListReference) - : await findPolicyListFromRoomReference(this.mjolnir, policyListReference); - if (policyListResult.isErr()) { - return policyListResult; - } - const policyList = policyListResult.ok; - - const reason = reasonParts.join(' '); - - if (entity instanceof UserID) { - await policyList.banEntity(RULE_USER, entity.toString(), reason); - } else if (entity instanceof MatrixRoomReference) { - await policyList.banEntity(RULE_ROOM, entity.toRoomIdOrAlias(), reason); - } else { - await policyList.banEntity(RULE_SERVER, entity, reason); +): Promise> { + const policyListEditorResult = await findPolicyRoomEditorFromRoomReference( + this.draupnir, + policyRoomReference + ); + 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 CommandResult.Ok(undefined); + return await policyListEditor.banEntity(PolicyRuleType.Server, resolvedRoomReference.ok.toRoomIDOrAlias(), reason); } +} defineInterfaceCommand({ designator: ["ban"], @@ -124,23 +92,24 @@ defineInterfaceCommand({ { name: "list", acceptor: union( - findPresentationType("MatrixRoomReference"), - findPresentationType("string"), - findPresentationType("PolicyList"), + findPresentationType("MatrixRoomReference") ), - prompt: async function (this: MjolnirContext, parameter: ParameterDescription): Promise { + prompt: async function (this: DraupnirContext, _parameter: ParameterDescription): Promise { return { - suggestions: this.mjolnir.policyListManager.lists + suggestions: this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( + this.draupnir.clientUserID, + PolicyRuleType.User + ) }; } }, ], - new RestDescription( + new RestDescription( "reason", findPresentationType("string"), async function(_parameter) { return { - suggestions: this.mjolnir.config.commands.ban.defaultReasons + suggestions: this.draupnir.config.commands.ban.defaultReasons } }), ), diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f2212db3..34c1c815 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -25,123 +25,107 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; -import { showJoinsStatus } from "./JoinsCommand"; import { LogService, RichReply } from "matrix-bot-sdk"; -import { execSyncCommand } from "./SyncCommand"; -import { execPermissionCheckCommand } from "./PermissionCheckCommand"; -import { execCreateListCommand } from "./CreateBanListCommand"; -import { execRedactCommand } from "./RedactCommand"; -import { execImportCommand } from "./ImportCommand"; -import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; -import { - execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection, - execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection -} from "./ProtectionsCommands"; -import { execSetPowerLevelCommand } from "./SetPowerLevelCommand"; -import { execResolveCommand } from "./ResolveAlias"; -import { execKickCommand } from "./KickCommand"; -import { parse as tokenize } from "shell-quote"; -import { execSinceCommand } from "./SinceCommand"; import { readCommand } from "./interface-manager/CommandReader"; import { BaseFunction, CommandTable, defineCommandTable, findCommandTable, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findMatrixInterfaceAdaptor, MatrixContext } from "./interface-manager/MatrixInterfaceAdaptor"; import { ArgumentStream } from "./interface-manager/ParameterParsing"; -import { CommandResult } from "./interface-manager/Validation"; -import { CommandException, CommandExceptionKind } from "./interface-manager/CommandException"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import "./interface-manager/MatrixPresentations"; +import { ActionException, ActionExceptionKind, ActionResult, ResultError, RoomMessage, StringRoomID } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; -export interface MjolnirContext extends MatrixContext { - mjolnir: Mjolnir, +export interface DraupnirContext extends MatrixContext { + draupnir: Draupnir, } -export type MjolnirBaseExecutor = (this: MjolnirContext, ...args: any[]) => Promise>; +export type DraupnirBaseExecutor = (this: DraupnirContext, ...args: any[]) => Promise>; +// Plesae keep these in alphabetical order. defineCommandTable("synapse admin"); +import "./AliasCommands"; +import "./DeactivateCommand"; import "./HijackRoomCommand"; import "./ShutdownRoomCommand"; -import "./DeactivateCommand"; -import "./AliasCommands"; defineCommandTable("mjolnir").importTable(findCommandTable("synapse admin")); import "./Ban"; -import "./Unban"; -import "./StatusCommand"; +import "./CreateBanListCommand"; +import "./Help"; +import "./ImportCommand"; +import "./KickCommand"; +import "./PermissionCheckCommand"; +import "./ProtectionsCommands"; +import "./RedactCommand"; +import "./ResolveAlias"; import "./Rooms"; import "./Rules"; +import "./SetDisplayNameCommand" +import "./SetPowerLevelCommand"; +import "./StatusCommand"; +import "./Unban"; import "./WatchUnwatchCommand"; -import "./Help"; -import "./SetDisplayNameCommand"; - -export const COMMAND_PREFIX = "!mjolnir"; - -export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir, commandTable: CommandTable) { - const cmd = event['content']['body']; - const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); - // A shell-style parser that can parse `"a b c"` (with quotes) as a single argument. - // We do **not** want to parse `#` as a comment start, though. - const tokens = tokenize(cmd.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); +export const COMMAND_PREFIX = "!draupnir"; +export async function handleCommand( + roomID: StringRoomID, + event: RoomMessage, + normalisedCommand: string, + draupnir: Draupnir, + commandTable: CommandTable +) { try { - if (parts[1] === 'joins') { - return await showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 2)); - } else if (parts[1] === 'sync') { - return await execSyncCommand(roomId, event, mjolnir); - } else if (parts[1] === 'verify') { - return await execPermissionCheckCommand(roomId, event, mjolnir); - } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { - return await execCreateListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'redact' && parts.length > 1) { - return await execRedactCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'import' && parts.length > 2) { - return await execImportCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'default' && parts.length > 2) { - return await execSetDefaultListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'protections') { - return await execListProtections(roomId, event, mjolnir, parts); - } else if (parts[1] === 'enable' && parts.length > 1) { - return await execEnableProtection(roomId, event, mjolnir, parts); - } else if (parts[1] === 'disable' && parts.length > 1) { - return await execDisableProtection(roomId, event, mjolnir, parts); - } else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) { - return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) { - return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) { - return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'config' && parts[2] === 'get') { - return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'resolve' && parts.length > 2) { - return await execResolveCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'powerlevel' && parts.length > 3) { - return await execSetPowerLevelCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'since') { - return await execSinceCommand(roomId, event, mjolnir, tokens); - } else if (parts[1] === 'kick' && parts.length > 2) { - return await execKickCommand(roomId, event, mjolnir, parts); - } else { - const readItems = readCommand(cmd).slice(1); // remove "!mjolnir" - const stream = new ArgumentStream(readItems); - const command = commandTable.findAMatchingCommand(stream) - ?? findTableCommand("mjolnir", "help"); - const adaptor = findMatrixInterfaceAdaptor(command); - const mjolnirContext: MjolnirContext = { - mjolnir, roomId, event, client: mjolnir.client, emitter: mjolnir.matrixEmitter, - }; - try { - return await adaptor.invoke(mjolnirContext, mjolnirContext, ...stream.rest()); - } catch (e) { - const commandError = new CommandException(CommandExceptionKind.Unknown, e, 'Unknown Unexpected Error'); - await tickCrossRenderer.call(mjolnirContext, mjolnir.client, roomId, event, CommandResult.Err(commandError)); - } + const readItems = readCommand(normalisedCommand) + const stream = new ArgumentStream(readItems); + const command = commandTable.findAMatchingCommand(stream) + ?? findTableCommand("mjolnir", "help"); + const adaptor = findMatrixInterfaceAdaptor(command); + const draupnirContext: DraupnirContext = { + ...draupnir.commandContext, + event + }; + try { + return 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)); } } 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); + const reply = RichReply.createFor(roomID, event, text, text); reply["msgtype"] = "m.notice"; - return await mjolnir.client.sendMessage(roomId, reply); + return 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}`; } diff --git a/src/commands/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 78723f30..48dd68f4 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -25,31 +25,54 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; -import PolicyList from "../models/PolicyList"; -import { RichReply } from "matrix-bot-sdk"; -import { Permalinks } from "./interface-manager/Permalinks"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; - -// !mjolnir list create -export async function execCreateListCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const shortcode = parts[3]; - const aliasLocalpart = parts[4]; - - const listRoomId = await PolicyList.createList( - mjolnir.client, +import { ActionResult, MatrixRoomID, 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 { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; +import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; + +export async function createList( + this: DraupnirContext, + _keywords: ParsedKeywords, + shortcode: string, + aliasName: string, +): Promise> { + const newList = await this.draupnir.policyRoomManager.createPolicyRoom( shortcode, - [event['sender']], - { room_alias_name: aliasLocalpart } + [this.event.sender], + { + 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; + } + return newList; +} - const roomRef = MatrixRoomReference.fromPermalink(Permalinks.forRoom(listRoomId)); - await mjolnir.policyListManager.watchList(roomRef); - await mjolnir.addProtectedRoom(listRoomId); +defineInterfaceCommand({ + designator: ["list", "create"], + table: "mjolnir", + 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" +}) - const html = `Created new list (${listRoomId}). This list is now being watched.`; - const text = `Created new list (${roomRef}). This list is now being watched.`; - const reply = RichReply.createFor(roomId, event, text, html); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "list", "create"), + renderer: tickCrossRenderer +}) diff --git a/src/commands/DeactivateCommand.ts b/src/commands/DeactivateCommand.ts index 1b8d8a93..b1d05dfd 100644 --- a/src/commands/DeactivateCommand.ts +++ b/src/commands/DeactivateCommand.ts @@ -25,13 +25,12 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { UserID } from "matrix-bot-sdk"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; +import { DraupnirContext } from "./CommandHandler"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { CommandResult, CommandError } from "./interface-manager/Validation"; +import { ActionError, ActionResult, Ok, UserID, isError } from "matrix-protection-suite"; defineInterfaceCommand({ table: "synapse admin", @@ -43,13 +42,16 @@ defineInterfaceCommand({ acceptor: findPresentationType("UserID"), } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, targetUser: UserID): Promise> { - const isAdmin = await this.mjolnir.isSynapseAdmin(); - if (!isAdmin) { - return CommandError.Result('I am not a Synapse administrator, or the endpoint to deactivate a user is blocked'); + 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'); } - await this.mjolnir.deactivateSynapseUser(targetUser.toString()); - return CommandResult.Ok(undefined); + 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); }, }) diff --git a/src/commands/Help.tsx b/src/commands/Help.tsx index fbf911e9..558f4395 100644 --- a/src/commands/Help.tsx +++ b/src/commands/Help.tsx @@ -30,33 +30,9 @@ import { CommandTable, defineInterfaceCommand, findCommandTable, findTableComman import { renderCommandSummary } from "./interface-manager/MatrixHelpRenderer"; import { JSXFactory } from "./interface-manager/JSXFactory"; import { findPresentationType, parameters, RestDescription } from "./interface-manager/ParameterParsing"; -import { CommandResult } from "./interface-manager/Validation"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; - -const oldHelpMenu = "" + -"!mjolnir - Print status information\n" + -"!mjolnir status - Print status information\n" + -"!mjolnir status protection [subcommand] - Print status information for a protection\n" + -"!mjolnir redact [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" + -"!mjolnir redact - Redacts a message by permalink\n" + -"!mjolnir kick [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" + -"!mjolnir sync - Force updates of all lists and re-apply rules\n" + -"!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + -"!mjolnir list create - Creates a new ban list with the given shortcode and alias\n" + -"!mjolnir import - Imports bans and ACLs into the given list\n" + -"!mjolnir default - Sets the default list for commands\n" + -"!mjolnir protections - List all available protections\n" + -"!mjolnir enable - Enables a particular protection\n" + -"!mjolnir disable - Disables a particular protection\n" + -"!mjolnir config set . [value] - Change a protection setting\n" + -"!mjolnir config add . [value] - Add a value to a list protection setting\n" + -"!mjolnir config remove . [value] - Remove a value from a list protection setting\n" + -"!mjolnir config get [protection] - List protection settings\n" + -"!mjolnir resolve - Resolves a room alias to a room ID\n" + -"!mjolnir since / [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since / (up to users)\n" + -"!mjolnir powerlevel [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms)\n" + -"!mjolnir help - This menu\n"; +import { ActionResult, Ok, isError } from "matrix-protection-suite"; function renderTableHelp(table: CommandTable): DocumentNode { // FIXME: is it possible to force case of table names? @@ -71,10 +47,6 @@ function renderTableHelp(table: CommandTable): DocumentNode { function renderMjolnirHelp(mjolnirTable: CommandTable): DocumentNode { return -
    - Old Commands: -
    {oldHelpMenu}
    -
    {renderTableHelp(mjolnirTable)}
    } @@ -82,15 +54,17 @@ function renderMjolnirHelp(mjolnirTable: CommandTable): DocumentNode { defineInterfaceCommand({ parameters: parameters([], new RestDescription('command parts', findPresentationType("any"))), table: "mjolnir", - command: async function() { return CommandResult.Ok(findCommandTable("mjolnir")) }, + command: async function() { + return Ok(findCommandTable("mjolnir")) + }, designator: ["help"], summary: "Display this message" }) defineMatrixInterfaceAdaptor({ interfaceCommand: findTableCommand("mjolnir", "help"), - renderer: async function(client, commandRoomId, event, result) { - if (result.isErr()) { + renderer: async function(client, commandRoomId, event, result: ActionResult) { + if (isError(result)) { throw new TypeError("This command isn't supposed to fail"); } await renderMatrixAndSend( diff --git a/src/commands/HijackRoomCommand.ts b/src/commands/HijackRoomCommand.ts index 6f67eeda..3752f0dd 100644 --- a/src/commands/HijackRoomCommand.ts +++ b/src/commands/HijackRoomCommand.ts @@ -27,25 +27,31 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters } from "./interface-manager/ParameterParsing"; -import { MjolnirBaseExecutor, MjolnirContext } from "./CommandHandler"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { UserID } from "matrix-bot-sdk"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; +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 { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; async function hijackRoomCommand( - this: MjolnirContext, _keywords: void, room: MatrixRoomReference, user: UserID -): Promise> { - const isAdmin = await this.mjolnir.isSynapseAdmin(); - if (!this.mjolnir.config.admin?.enableMakeRoomAdminCommand || !isAdmin) { - return CommandError.Result("Either the command is disabled or Mjolnir is not running as homeserver administrator.") + this: DraupnirContext, _keywords: void, 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.") } - await this.mjolnir.makeUserRoomAdmin(room.toRoomIdOrAlias(), user.toString()); - return CommandResult.Ok(undefined); + 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({ +defineInterfaceCommand({ designator: ["hijack", "room"], table: "synapse admin", parameters: parameters([ diff --git a/src/commands/ImportCommand.ts b/src/commands/ImportCommand.ts index a23e81a3..5fe9fbb5 100644 --- a/src/commands/ImportCommand.ts +++ b/src/commands/ImportCommand.ts @@ -25,26 +25,36 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; -import { EntityType } from "../models/ListRule"; -import PolicyList from "../models/PolicyList"; +import { DraupnirBaseExecutor, DraupnirContext } from "./CommandHandler"; +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 } from "./interface-manager/ParameterParsing"; +import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; +import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; -// !mjolnir import -export async function execImportCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const importRoomId = await mjolnir.client.resolveRoom(parts[2]); - const list = mjolnir.policyListManager.lists.find(b => b.listShortcode === parts[3]) as PolicyList; - if (!list) { - const errMessage = "Unable to find list - check your shortcode."; - const errReply = RichReply.createFor(roomId, event, errMessage, errMessage); - errReply["msgtype"] = "m.notice"; - mjolnir.client.sendMessage(roomId, errReply); - return; +export async function importCommand( + this: DraupnirContext, + _keywords: void, + importFromRoomReference: MatrixRoomReference, + policyRoomReference: MatrixRoomReference +): Promise> { + const importFromRoom = await resolveRoomReferenceSafe(this.client, importFromRoomReference); + if (isError(importFromRoom)) { + return importFromRoom; } - - let importedRules = 0; - - const state = await mjolnir.client.getRoomState(importRoomId); + 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; @@ -53,27 +63,44 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo // Member event - check for ban if (content['membership'] === 'ban') { const reason = content['reason'] || ''; - - await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding user ${stateEvent['state_key']} to ban list`); - await list.banEntity(EntityType.RULE_USER, stateEvent['state_key'], reason); - importedRules++; + 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 = ""; - - await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`); - - await list.banEntity(EntityType.RULE_SERVER, server, reason); - importedRules++; + const result = await policyRoomEditor.ok.banEntity(PolicyRuleType.Server, server, reason); + if (isError(result)) { + errors.push(RoomActionError.fromActionError(policyRoom.ok, result.error)); + } } } } - - const message = `Imported ${importedRules} rules to ban list`; - const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); + return MultipleErrors.Result(`There were multiple errors when importing bans from the room ${importFromRoomReference.toPermalink()} to ${policyRoomReference.toPermalink()}`, { errors }); } + +defineInterfaceCommand({ + designator: ["import"], + table: "mjolnir", + 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("mjolnir", "import"), + renderer: tickCrossRenderer +}) diff --git a/src/commands/JoinsCommand.ts b/src/commands/JoinsCommand.ts deleted file mode 100644 index 3d145ca8..00000000 --- a/src/commands/JoinsCommand.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; -import { htmlEscape, parseDuration } from "../utils"; -import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; - -const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); -const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); - - -/** - * Show the most recent joins to a room. - * - * Seems like this command never worked how it was expected to - * "100 day" without quotes is 2 parts, so if you wrote them like the examples - * then you would have 4 parts? - * - * For now I will copy this as it were, but this needs fixing - * https://github.com/Gnuxie/Draupnir/issues/19 - */ -export async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: Mjolnir, args: string[]) { - const targetRoomAliasOrId = args[0]; - const maxAgeArg = args[1] || "1 day"; - const maxEntriesArg = args[2] = "200"; - const { html, text } = await (async () => { - if (!targetRoomAliasOrId) { - return { - html: "Missing arg: room id", - text: "Missing arg: `room id`" - }; - } - const maxAgeMS = parseDuration(maxAgeArg); - if (!maxAgeMS) { - return { - html: "Invalid duration. Example: 1.5 days or 10 minutes", - text: "Invalid duration. Example: `1.5 days` or `10 minutes`", - } - } - const maxEntries = Number.parseInt(maxEntriesArg, 10); - if (!maxEntries) { - return { - html: "Invalid number of entries. Example: 200", - text: "Invalid number of entries. Example: `200`", - } - } - const minDate = new Date(Date.now() - maxAgeMS); - const HUMANIZER_OPTIONS = { - // Reduce "1 day" => "1day" to simplify working with CSV. - spacer: "", - // Reduce "1 day, 2 hours" => "1.XXX day" to simplify working with CSV. - largest: 1, - }; - const maxAgeHumanReadable = HUMANIZER.humanize(maxAgeMS, HUMANIZER_OPTIONS); - let targetRoomId; - try { - targetRoomId = await mjolnir.client.resolveRoom(targetRoomAliasOrId); - } catch (ex) { - return { - html: `Cannot resolve room ${htmlEscape(targetRoomAliasOrId)}.`, - text: `Cannot resolve room \`${targetRoomAliasOrId}\`.` - } - } - const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); - const htmlFragments = []; - const textFragments = []; - for (let join of joins) { - const durationHumanReadable = HUMANIZER.humanize(Date.now() - join.timestamp, HUMANIZER_OPTIONS); - htmlFragments.push(`
  • ${htmlEscape(join.userId)}: ${durationHumanReadable}
  • `); - textFragments.push(`- ${join.userId}: ${durationHumanReadable}`); - } - return { - html: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):
      ${htmlFragments.join()}
    `, - text: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}` - } - })(); - const reply = RichReply.createFor(destinationRoomId, event, text, html); - reply["msgtype"] = "m.notice"; - return mjolnir.client.sendMessage(destinationRoomId, reply); -} diff --git a/src/commands/KickCommand.ts b/src/commands/KickCommand.ts deleted file mode 100644 index 5d307288..00000000 --- a/src/commands/KickCommand.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; -import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk"; - -// !mjolnir kick [room] [reason] -export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let force = false; - - const glob = parts[2]; - let rooms = mjolnir.protectedRoomsTracker.getProtectedRooms(); - - if (parts[parts.length - 1] === "--force") { - force = true; - parts.pop(); - } - - if (mjolnir.config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) { - let replyMessage = "Wildcard bans require an addition `--force` argument to confirm"; - const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); - return; - } - - const kickRule = new MatrixGlob(glob); - - let reason: string | undefined; - if (parts.length > 3) { - let reasonIndex = 3; - if (parts[3].startsWith("#") || parts[3].startsWith("!")) { - rooms = [await mjolnir.client.resolveRoom(parts[3])]; - reasonIndex = 4; - } - reason = parts.slice(reasonIndex).join(' ') || ''; - } - if (!reason) reason = ''; - - for (const protectedRoomId of rooms) { - const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]); - - for (const member of members) { - const victim = member.membershipFor; - - if (kickRule.test(victim)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); - - if (!mjolnir.config.noop) { - try { - await mjolnir.taskQueue.push(async () => { - return mjolnir.client.kickUser(victim, protectedRoomId, reason); - }); - } catch (e) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); - } - } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); - } - } - } - } - - return mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} diff --git a/src/commands/KickCommand.tsx b/src/commands/KickCommand.tsx new file mode 100644 index 00000000..d23eeef9 --- /dev/null +++ b/src/commands/KickCommand.tsx @@ -0,0 +1,153 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { 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 { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DocumentNode } from "./interface-manager/DeadDocument"; +import { JSXFactory } from "./interface-manager/JSXFactory"; +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 renderUsersToKick(usersToKick: UsersToKick): DocumentNode { + return +
    + + Kicking {usersToKick.size} unique users from protected rooms. + + {[...usersToKick.entries()].map(([userID, rooms]) => { +
    + Kicking {userID} from {rooms.length} rooms. +
      + {rooms.map(room =>
    • {room}
    • )} +
    +
    + })} +
    +
    +} + +export async function kickCommand( + 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) { + this.draupnir.taskQueue.push(async () => { + return this.client.kickUser(member.userID, revision.room.toRoomIDOrAlias(), reason); + }); + } + } + } + return Ok(usersToKick); +} + + +defineInterfaceCommand({ + designator: ["kick"], + table: "mjolnir", + 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.' + } + }) + ), + 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("mjolnir", "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 765c2d85..b9173604 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -25,9 +25,34 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; +import { ActionError, ActionResult } from "matrix-protection-suite"; +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"; -// !mjolnir verify -export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.protectedRoomsTracker.verifyPermissions(); -} +defineInterfaceCommand({ + designator: ["verify"], + table: "mjolnir", + 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("mjolnir", "verify"), + renderer: tickCrossRenderer +}) diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts deleted file mode 100644 index f6c23b9e..00000000 --- a/src/commands/ProtectionsCommands.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { htmlEscape } from "../utils"; -import { Mjolnir } from "../Mjolnir"; -import { LogService, RichReply } from "matrix-bot-sdk"; -import { isListSetting } from "../protections/ProtectionSettings"; - -// !mjolnir enable -export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - try { - await mjolnir.protectionManager.enableProtection(parts[2]); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); - } catch (e) { - LogService.error("ProtectionsCommands", e); - - const message = `Error enabling protection '${parts[0]}' - check the name and try again.`; - const reply = RichReply.createFor(roomId, event, message, message); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); - } -} - -enum ConfigAction { - Set, - Add, - Remove -} - -/* - * Process a given ConfigAction against a given protection setting - * - * @param mjolnir Current Mjolnir instance - * @param parts Arguments given to the command being processed - * @param action Which ConfigAction to do to the provided protection setting - * @returns Command success or failure message - */ -async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise { - const [protectionName, ...settingParts] = parts[0].split("."); - const protection = mjolnir.protectionManager.getProtection(protectionName); - if (!protection) { - return `Unknown protection ${protectionName}`; - } - - const defaultSettings = protection.settings - const settingName = settingParts[0]; - const stringValue = parts[1]; - - if (!(settingName in defaultSettings)) { - return `Unknown setting ${settingName}`; - } - - const parser = defaultSettings[settingName]; - // we don't need to validate `value`, because mjolnir.setProtectionSettings does - // it for us (and raises an exception if there's a problem) - let value = parser.fromString(stringValue); - - if (action === ConfigAction.Add) { - if (!isListSetting(parser)) { - return `Setting ${settingName} isn't a list`; - } else { - value = parser.addValue(value); - } - } else if (action === ConfigAction.Remove) { - if (!isListSetting(parser)) { - return `Setting ${settingName} isn't a list`; - } else { - value = parser.removeValue(value); - } - } - - try { - await mjolnir.protectionManager.setProtectionSettings(protectionName, { [settingName]: value }); - } catch (e) { - return `Failed to set setting: ${e.message}`; - } - - const oldValue = protection.settings[settingName].value; - protection.settings[settingName].setValue(value); - - return `Changed ${protectionName}.${settingName} to ${value} (was ${oldValue})`; -} - -/* - * Change a protection setting - * - * !mjolnir set . - */ -export async function execConfigSetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Set); - - const reply = RichReply.createFor(roomId, event, message, message); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} - -/* - * Add a value to a protection list setting - * - * !mjolnir add . - */ -export async function execConfigAddProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Add); - - const reply = RichReply.createFor(roomId, event, message, message); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} - -/* - * Remove a value from a protection list setting - * - * !mjolnir remove . - */ -export async function execConfigRemoveProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Remove); - - const reply = RichReply.createFor(roomId, event, message, message); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} - -/* - * Get all protection settings or get all settings for a given protection - * - * !mjolnir get [protection name] - */ -export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let pickProtections = Array.from(mjolnir.protectionManager.protections.keys()); - - if (parts.length === 0) { - // no specific protectionName provided, show all of them. - - // sort output by protection name - pickProtections.sort(); - } else { - if (!pickProtections.includes(parts[0])) { - const errMsg = `Unknown protection: ${parts[0]}`; - const errReply = RichReply.createFor(roomId, event, errMsg, errMsg); - errReply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, errReply); - return; - } - pickProtections = [parts[0]]; - } - - let text = "Protection settings\n"; - let html = "Protection settings
      "; - - let anySettings = false; - - for (const protectionName of pickProtections) { - const protectionSettings = mjolnir.protectionManager.getProtection(protectionName)?.settings ?? {}; - - if (Object.keys(protectionSettings).length === 0) { - continue; - } - - const settingNames = Object.keys(protectionSettings); - // this means, within each protection name, setting names are sorted - settingNames.sort(); - for (const settingName of settingNames) { - anySettings = true; - - let value = protectionSettings[settingName].value.toString(); - text += `* ${protectionName}.${settingName}: ${value}`; - // `protectionName` and `settingName` are user-provided but - // validated against the names of existing protections and their - // settings, so XSS is avoided for these already - html += `
    • ${protectionName}.${settingName}: ${htmlEscape(value)}
    • `; - } - } - - html += "
    "; - - if (!anySettings) - html = text = "No settings found"; - - const reply = RichReply.createFor(roomId, event, text, html); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} - -// !mjolnir disable -export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - await mjolnir.protectionManager.disableProtection(parts[2]); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} - -// !mjolnir protections -export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const enabledProtections = mjolnir.protectionManager.enabledProtections.map(p => p.name); - - let html = "Available protections:
      "; - let text = "Available protections:\n"; - - for (const [protectionName, protection] of mjolnir.protectionManager.protections) { - const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)'; - html += `
    • ${emoji} ${protectionName} - ${protection.description}
    • `; - text += `* ${emoji} ${protectionName} - ${protection.description}\n`; - } - - html += "
    "; - - const reply = RichReply.createFor(roomId, event, text, html); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} diff --git a/src/commands/ProtectionsCommands.tsx b/src/commands/ProtectionsCommands.tsx new file mode 100644 index 00000000..c8d9f018 --- /dev/null +++ b/src/commands/ProtectionsCommands.tsx @@ -0,0 +1,388 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { 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"; +import { Draupnir } from "../Draupnir"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { JSXFactory } from "./interface-manager/JSXFactory"; +import { DocumentNode } from "./interface-manager/DeadDocument"; +import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; + +defineInterfaceCommand({ + designator: ["protections", "enable"], + table: "mjolnir", + 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." +}) + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "protections", "enable"), + renderer: tickCrossRenderer +}); + +defineInterfaceCommand({ + designator: ["protections", "disable"], + table: "mjolnir", + 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 + ); + }, + summary: "Disable a protection." +}) + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "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." +}]; + +interface SettingChangeSummary = UnknownSettings> { + readonly oldValue: unknown, + readonly newValue: unknown, + readonly description: ProtectionSetting, +} + +defineInterfaceCommand({ + designator: ["protections", "config", "set"], + table: "mjolnir", + 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 + ) + }, + 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: "mjolnir", + 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 + ); + }, + summary: "Add an item to a collection protection setting." +}) + +defineInterfaceCommand({ + designator: ["protections", "config", "remove"], + table: "mjolnir", + 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 + ); + }, + 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 {oldJSON} to {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 + ) + } +} + +for (const designator of ["add", "set", "remove"]) { + defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "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 findSettingDescriptionForCommand(settings: ProtectionSettings, settingName: string): ActionResult>> { + const setting = settings.descriptions[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 +} + +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 + }) +} + +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 + ); + 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 +} + +defineInterfaceCommand({ + designator: ["protections"], + table: "mjolnir", + 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("mjolnir", "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} +
    • ) + )} +
    +
    +} diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index d1c185f5..63670c29 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -25,43 +25,98 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; +import { ActionResult, MatrixEventReference, MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomReference, Ok, UserID, isError } from "matrix-protection-suite"; import { redactUserMessagesIn } from "../utils"; -import { Permalinks } from "./interface-manager/Permalinks"; +import { KeywordsDescription, ParsedKeywords, RestDescription, findPresentationType, parameters, union } from "./interface-manager/ParameterParsing"; +import { DraupnirContext } from "./CommandHandler"; +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"; -// !mjolnir redact [room alias] [limit] -export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const userId = parts[2]; - let roomAlias: string|null = null; - let limit = Number.parseInt(parts.length > 3 ? parts[3] : "", 10); // default to NaN for later - if (parts.length > 3 && isNaN(limit)) { - roomAlias = await mjolnir.client.resolveRoom(parts[3]); - if (parts.length > 4) { - limit = Number.parseInt(parts[4], 10); - } +export async function redactEvent( + 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); +} - // Make sure we always have a limit set - if (isNaN(limit)) limit = 1000; - - const processingReactionId = await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], 'In Progress'); - - if (userId[0] !== '@') { - // Assume it's a permalink - const parsed = Permalinks.parseUrl(parts[2]); - if (parsed.roomIdOrAlias === undefined || parsed.eventId === undefined) { - throw new TypeError(`Got a malformed permalink ${parsed}`) - } - const targetRoomId = await mjolnir.client.resolveRoom(parsed.roomIdOrAlias); - await mjolnir.client.redactEvent(targetRoomId, parsed.eventId); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); - await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing command'); - return; +export async function redactCommand( + 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.protectedRoomsConfig.allRooms : [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 targetRoomIds = roomAlias ? [roomAlias] : mjolnir.protectedRoomsTracker.getProtectedRooms(); - await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, limit); +defineInterfaceCommand({ + designator: ["redact"], + table: "mjolnir", + 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 + } + } + ), + 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." +}); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); - await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); -} +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "redact"), + renderer: tickCrossRenderer +}) diff --git a/src/commands/ResolveAlias.ts b/src/commands/ResolveAlias.ts deleted file mode 100644 index e1d9806d..00000000 --- a/src/commands/ResolveAlias.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; -import { htmlEscape } from "../utils"; - -// !mjolnir resolve -export async function execResolveCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const toResolve = parts[2]; - - const resolvedRoomId = await mjolnir.client.resolveRoom(toResolve); - - const message = `Room ID for ${toResolve} is ${resolvedRoomId}`; - const html = `Room ID for ${htmlEscape(toResolve)} is ${htmlEscape(resolvedRoomId)}`; - const reply = RichReply.createFor(roomId, event, message, html); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} diff --git a/src/commands/ResolveAlias.tsx b/src/commands/ResolveAlias.tsx new file mode 100644 index 00000000..a0dca9d8 --- /dev/null +++ b/src/commands/ResolveAlias.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { 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 { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; +import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; +import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; +import { JSXFactory } from "./interface-manager/JSXFactory"; + +async function resolveAliasCommand( + this: DraupnirContext, + _keywords: ParsedKeywords, + alias: MatrixRoomAlias +): Promise> { + return await resolveRoomReferenceSafe(this.client, alias); +} + +defineInterfaceCommand({ + table: "mjolnir", + designator: ["resolve"], + parameters: parameters([{ + name: "alias", + acceptor: findPresentationType("MatrixRoomAlias") + }]), + command: resolveAliasCommand, + summary: "Resolve a room alias." +}) + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "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}, + commandRoomID, + event, + client + ) + } +}) diff --git a/src/commands/Rooms.tsx b/src/commands/Rooms.tsx index eaf80613..aabbaccd 100644 --- a/src/commands/Rooms.tsx +++ b/src/commands/Rooms.tsx @@ -27,33 +27,31 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; -import { MatrixRoomID, MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { CommandResult } from "./interface-manager/Validation"; -import { CommandException, CommandExceptionKind } from "./interface-manager/CommandException"; +import { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { DocumentNode } from "./interface-manager/DeadDocument"; import { JSXFactory } from "./interface-manager/JSXFactory"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; -import { Permalinks } from "./interface-manager/Permalinks"; +import { ActionException, ActionExceptionKind, ActionResult, MatrixRoomID, MatrixRoomReference, Ok, isError } from "matrix-protection-suite"; +import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; defineInterfaceCommand({ table: "mjolnir", designator: ["rooms"], summary: "List all of the protected rooms.", parameters: parameters([]), - command: async function (this: MjolnirContext, _keywrods): Promise> { - return CommandResult.Ok(this.mjolnir.protectedRoomsTracker.getProtectedRooms()); + command: async function (this: DraupnirContext, _keywrods): Promise> { + return Ok(this.draupnir.protectedRoomsSet.protectedRoomsConfig.allRooms); } }) -function renderProtectedRooms(rooms: string[]): DocumentNode { +function renderProtectedRooms(rooms: MatrixRoomID[]): DocumentNode { return
    Protected Rooms ({rooms.length}):
    @@ -61,9 +59,9 @@ function renderProtectedRooms(rooms: string[]): DocumentNode { defineMatrixInterfaceAdaptor({ interfaceCommand: findTableCommand("mjolnir", "rooms"), - renderer: async function (client, commandRoomId, event, result) { + renderer: async function (client, commandRoomId, event, result: ActionResult) { tickCrossRenderer.call(this, ...arguments); - if (result.isErr()) { + if (isError(result)) { return; // tickCrossRenderer will handle it. } await renderMatrixAndSend( @@ -84,23 +82,16 @@ defineInterfaceCommand({ description: 'The room to protect.' } ]), - command: async function (this: MjolnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { - const roomIDOrError = await (async () => { - try { - return CommandResult.Ok(await roomRef.joinClient(this.mjolnir.client)); - } catch (e) { - return CommandException.Result( - `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.`, - { exception: e, exceptionKind: CommandExceptionKind.Unknown } - ); - } - })(); - if (roomIDOrError.isErr()) { - return CommandResult.Err(roomIDOrError.err); + 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.`, + ); } - await this.mjolnir.addProtectedRoom(roomIDOrError.ok.toRoomIdOrAlias()); - return CommandResult.Ok(undefined); + return await this.draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(room.ok); }, }) @@ -115,18 +106,27 @@ defineInterfaceCommand({ description: 'The room to stop protecting.' } ]), - command: async function (this: MjolnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { - const roomID = await roomRef.resolve(this.mjolnir.client); - await this.mjolnir.removeProtectedRoom(roomID.toRoomIdOrAlias()); + 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.protectedRoomsConfig.removeRoom(room.ok); + if (isError(removeResult)) { + return removeResult; + } try { - await this.mjolnir.client.leaveRoom(roomID.toRoomIdOrAlias()); + await this.client.leaveRoom(room.ok.toRoomIDOrAlias()); } catch (exception) { - return CommandException.Result( + return ActionException.Result( `Failed to leave ${roomRef.toPermalink()} - the room is no longer being protected, but the bot could not leave.`, - { exceptionKind: CommandExceptionKind.Unknown, exception } + { exceptionKind: ActionExceptionKind.Unknown, exception } ); } - return CommandResult.Ok(undefined); + return Ok(undefined); }, }) diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index 5116d7fe..eb150c76 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -25,30 +25,28 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MjolnirContext } from "./CommandHandler"; +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 { JSXFactory } from "./interface-manager/JSXFactory"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; import { findPresentationType, parameters, union } from "./interface-manager/ParameterParsing"; -import { CommandResult } from "./interface-manager/Validation"; -import { UserID } from "matrix-bot-sdk"; -import { MatrixSendClient } from "../MatrixEmitter"; -import { ListRule } from "../models/ListRule"; +import { ActionResult, MatrixRoomID, MatrixRoomReference, Ok, PolicyRoomWatchProfile, PolicyRule, StringRoomID, isError, UserID } from "matrix-protection-suite"; +import { listInfo } from "./StatusCommand"; async function renderListMatches( - this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: any, result: CommandResult + this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: any, result: ActionResult ) { - if (result.isErr()) { + if (isError(result)) { return await tickCrossRenderer.call(this, ...arguments); } const lists = result.ok; if (lists.length === 0) { return await renderMatrixAndSend( No policy lists configured, - commandRoomId, event, client + commandRoomID, event, client ) } return await renderMatrixAndSend( @@ -56,49 +54,48 @@ async function renderListMatches( Rules currently in use:
    {lists.map(list => renderListRules(list))}
    , - commandRoomId, event, client + commandRoomID, event, client ) } export function renderListRules(list: ListMatches) { - const renderRuleSummary = (rule: ListRule, entityDescription: string) => { + const renderRuleSummary = (rule: PolicyRule) => { return
  • - {entityDescription} ({rule.recommendation}): {rule.entity} ({rule.reason}) + {rule.kind} ({rule.recommendation}): {rule.entity} ({rule.reason})
  • }; return - {list.roomId} - {list.shortcode ? `(shortcode: ${list.shortcode})` : ''}:
    + {list.roomID} propagation: {list.profile.propagation}
      {list.matches.length === 0 ?
    • No rules
    • - : list.matches.map(rule => renderRuleSummary(rule, rule.kind))} + : list.matches.map(rule => renderRuleSummary(rule))}
    } -interface ListMatches { - shortcode: string, - roomRef: string, - roomId: string, - matches: ListRule[] +export interface ListMatches { + room: MatrixRoomID, + roomID: StringRoomID, + profile: PolicyRoomWatchProfile, + matches: PolicyRule[] } defineInterfaceCommand({ designator: ["rules"], table: "mjolnir", parameters: parameters([]), - command: async function (this: MjolnirContext) { - return CommandResult.Ok( - this.mjolnir.policyListManager.lists - .map(list => { - return { - shortcode: list.listShortcode, - roomRef: list.roomRef, - roomId: list.roomId, - matches: list.allRules - } + 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." @@ -123,16 +120,17 @@ defineInterfaceCommand({ } ]), command: async function ( - this: MjolnirContext, _keywords, entity: string|UserID|MatrixRoomReference - ): Promise> { - return CommandResult.Ok( - this.mjolnir.policyListManager.lists - .map(list => { + this: DraupnirContext, _keywords, entity: string|UserID|MatrixRoomReference + ): Promise> { + const policyRooms = await listInfo(this.draupnir); + return Ok( + policyRooms + .map(policyRoom => { return { - shortcode: list.listShortcode, - roomRef: list.roomRef, - roomId: list.roomId, - matches: list.rulesMatchingEntity(entity.toString()) + room: policyRoom.revision.room, + roomID: policyRoom.revision.room.toRoomIDOrAlias(), + matches: policyRoom.revision.allRulesMatchingEntity(entity.toString()), + profile: policyRoom.watchedListProfile } }) ); diff --git a/src/commands/SetDefaultBanListCommand.ts b/src/commands/SetDefaultBanListCommand.ts deleted file mode 100644 index c4e73939..00000000 --- a/src/commands/SetDefaultBanListCommand.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; - -export const DEFAULT_LIST_EVENT_TYPE = "org.matrix.mjolnir.default_list"; - -// !mjolnir default -export async function execSetDefaultListCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const shortcode = parts[2]; - const list = mjolnir.policyListManager.lists.find(b => b.listShortcode === shortcode); - if (!list) { - const replyText = "No ban list with that shortcode was found."; - const reply = RichReply.createFor(roomId, event, replyText, replyText); - reply["msgtype"] = "m.notice"; - mjolnir.client.sendMessage(roomId, reply); - return; - } - - await mjolnir.client.setAccountData(DEFAULT_LIST_EVENT_TYPE, { shortcode }); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} diff --git a/src/commands/SetDisplayNameCommand.ts b/src/commands/SetDisplayNameCommand.ts index ae6e905b..97355782 100644 --- a/src/commands/SetDisplayNameCommand.ts +++ b/src/commands/SetDisplayNameCommand.ts @@ -1,9 +1,9 @@ import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { ParsedKeywords, RestDescription, findPresentationType, parameters } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; +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({ @@ -12,7 +12,7 @@ defineInterfaceCommand({ summary: "Sets the displayname of the draupnir instance to the specified value in all rooms.", parameters: parameters( [], - new RestDescription( + new RestDescription( "displayname", findPresentationType("string"), ), @@ -21,16 +21,16 @@ defineInterfaceCommand({ }) // !draupnir displayname -export async function execSetDisplayNameCommand(this: MjolnirContext, _keywords: ParsedKeywords, ...displaynameParts: string[]): Promise> { +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 CommandError.Result(`Failed to set displayname to ${displayname}: ${message}`) + return ActionError.Result(`Failed to set displayname to ${displayname}: ${message}`) } - return CommandResult.Ok(undefined); + return Ok(undefined); } defineMatrixInterfaceAdaptor({ diff --git a/src/commands/SetPowerLevelCommand.ts b/src/commands/SetPowerLevelCommand.ts index 759258e3..369b862e 100644 --- a/src/commands/SetPowerLevelCommand.ts +++ b/src/commands/SetPowerLevelCommand.ts @@ -25,26 +25,50 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, LogService } from "matrix-bot-sdk"; +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 { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { defineInterfaceCommand } from "./interface-manager/InterfaceCommand"; -// !mjolnir powerlevel [room] -export async function execSetPowerLevelCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const victim = parts[2]; - const level = Math.round(Number(parts[3])); - const inRoom = parts[4]; - - let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : mjolnir.protectedRoomsTracker.getProtectedRooms(); - - for (const targetRoomId of targetRooms) { - try { - await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level); - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - LogService.error("SetPowerLevelCommand", e); - await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); +async function setPowerLevelCommand( + 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); } } - - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + const rooms = givenRooms.length === 0 ? this.draupnir.protectedRoomsSet.protectedRoomsConfig.allRooms : resolvedGivenRooms; + for (const room of rooms) { + await this.draupnir.client.setUserPowerLevel(user.toString(), room.toRoomIDOrAlias(), parsedLevel); + } + return Ok(undefined); } + +defineInterfaceCommand({ + table: "mjolnir", + 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" +}) diff --git a/src/commands/ShutdownRoomCommand.ts b/src/commands/ShutdownRoomCommand.ts index 54fcf3e6..2e67905d 100644 --- a/src/commands/ShutdownRoomCommand.ts +++ b/src/commands/ShutdownRoomCommand.ts @@ -27,11 +27,11 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters, ParsedKeywords, RestDescription } from "./interface-manager/ParameterParsing"; -import { CommandResult, CommandError } from "./interface-manager/Validation"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { MjolnirContext } from "./CommandHandler"; +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 { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; defineInterfaceCommand({ table: "synapse admin", @@ -41,17 +41,32 @@ defineInterfaceCommand({ { name: 'room', acceptor: findPresentationType("MatrixRoomReference"), - } + }, ], new RestDescription("reason", findPresentationType("string"))), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, targetRoom: MatrixRoomReference, ...reasonParts: string[]): Promise> { - const isAdmin = await this.mjolnir.isSynapseAdmin(); - if (!isAdmin) { - return CommandError.Result('I am not a Synapse administrator, or the endpoint to shutdown a room is blocked'); + 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.mjolnir.shutdownSynapseRoom((await targetRoom.resolve(this.client)).toRoomIdOrAlias(), reason); - return CommandResult.Ok(undefined); + await this.draupnir.synapseAdminClient.deleteRoom( + resolvedRoom.ok.toRoomIDOrAlias(), + { + message: reason, + new_room_user_id: this.draupnir.clientUserID, + block: true, + } + + ); + return Ok(undefined); }, }) diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts deleted file mode 100644 index 33442fe4..00000000 --- a/src/commands/SinceCommand.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; -import { LogLevel, LogService, RichReply } from "matrix-bot-sdk"; -import { htmlEscape, parseDuration } from "../utils"; -import { ParseEntry } from "shell-quote"; -import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; -import { Join } from "../RoomMembers"; - -const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); -const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); - -enum Action { - Kick = "kick", - Ban = "ban", - Mute = "mute", - Unmute = "unmute", - Show = "show" -} - -type Result = {ok: T} | {error: string}; - -type userId = string; -type Summary = { succeeded: userId[], failed: userId[] }; - -/** - * Attempt to parse a `ParseEntry`, as provided by the shell-style parser, using a parsing function. - * - * @param name The name of the object being parsed. Used for error messages. - * @param token The `ParseEntry` provided by the shell-style parser. It will be converted - * to string if possible. Otherwise, this returns an error. - * @param parser A function that attempts to parse `token` (converted to string) into - * its final result. It should provide an error fit for the end-user if it fails. - * @returns An error fit for the end-user if `token` could not be converted to string or - * if `parser` failed. - */ -function parseToken(name: string, token: ParseEntry, parser: (source: string) => Result): Result { - if (!token) { - return { error: `Missing ${name}`}; - } - if (typeof token === "object") { - if ("pattern" in token) { - // In future versions, we *might* be smarter about patterns, but not yet. - token = token.pattern; - } - } - - if (typeof token !== "string") { - return { error: `Invalid ${name}` }; - } - const result = parser(token); - if ("error" in result) { - if (result.error) { - return { error: `Invalid ${name} ${htmlEscape(token)}: ${result.error}`}; - } else { - return { error: `Invalid ${name} ${htmlEscape(token)}`}; - } - } - return result; -} - -/** - * Attempt to convert a token into a string. - * @param name The name of the object being parsed. Used for error messages. - * @param token The `ParseEntry` provided by the shell-style parser. It will be converted - * to string if possible. Otherwise, this returns an error. - * @returns An error fit for the end-user if `token` could not be converted to string, otherwise - * `{ok: string}`. - */ -function getTokenAsString(name: string, token: ParseEntry): {error: string}|{ok: string} { - if (!token) { - return { error: `Missing ${name}`}; - } - if (typeof token === "object" && "pattern" in token) { - // In future versions, we *might* be smarter patterns, but not yet. - token = token.pattern; - } - if (typeof token === "string") { - return {ok: token}; - } - return { error: `Invalid ${name}` }; -} - -// !mjolnir since / [...rooms] [...reason] -export async function execSinceCommand(destinationRoomId: string, event: any, mjolnir: Mjolnir, tokens: ParseEntry[]) { - let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens); - if ("error" in result) { - mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌'); - mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", result.error); - const reply = RichReply.createFor(destinationRoomId, event, result.error, htmlEscape(result.error)); - reply["msgtype"] = "m.notice"; - /* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply); - } else { - // Details have already been printed. - mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '✅'); - } -} - -function formatResult(action: string, targetRoomId: string, recentJoins: Join[], summary: Summary): {html: string, text: string} { - const html = `Attempted to ${action} ${recentJoins.length} users from room ${targetRoomId}.
    Succeeded ${summary.succeeded.length}:
      ${summary.succeeded.map(x => `
    • ${htmlEscape(x)}
    • `).join("\n")}
    .
    Failed ${summary.failed.length}:
      ${summary.succeeded.map(x => `
    • ${htmlEscape(x)}
    • `).join("\n")}
    `; - const text = `Attempted to ${action} ${recentJoins.length} users from room ${targetRoomId}.\nSucceeded ${summary.succeeded.length}: ${summary.succeeded.map(x => `*${htmlEscape(x)}`).join("\n")}\n Failed ${summary.failed.length}:\n${summary.succeeded.map(x => ` * ${htmlEscape(x)}`).join("\n")}`; - return { - html, - text - }; -} - -// Implementation of `execSinceCommand`, counts on caller to print errors. -// -// This method: -// - decodes all the arguments; -// - resolves any room alias into a room id; -// - attempts to execute action; -// - in case of success, returns `{ok: undefined}`, in case of error, returns `{error: string}`. -async function execSinceCommandAux(destinationRoomId: string, event: any, mjolnir: Mjolnir, tokens: ParseEntry[]): Promise> { - const [dateOrDurationToken, actionToken, maxEntriesToken, ...optionalTokens] = tokens; - - // Parse origin date or duration. - const minDateResult = parseToken("/", dateOrDurationToken, source => { - // Attempt to parse `/` as a date. - let maybeMinDate = new Date(source); - let maybeMaxAgeMS = Date.now() - maybeMinDate.getTime() as number; - if (!Number.isNaN(maybeMaxAgeMS)) { - return { ok: { minDate: maybeMinDate, maxAgeMS: maybeMaxAgeMS} }; - } - - //...or as a duration - maybeMaxAgeMS = parseDuration(source); - if (maybeMaxAgeMS && !Number.isNaN(maybeMaxAgeMS)) { - maybeMaxAgeMS = Math.abs(maybeMaxAgeMS); - return { ok: { minDate: new Date(Date.now() - maybeMaxAgeMS), maxAgeMS: maybeMaxAgeMS } } - } - return { error: "" }; - }); - if ("error" in minDateResult) { - return minDateResult; - } - const { minDate, maxAgeMS } = minDateResult.ok!; - - // Parse max entries. - const maxEntriesResult = parseToken("", maxEntriesToken, source => { - const maybeMaxEntries = Number.parseInt(source, 10); - if (Number.isNaN(maybeMaxEntries)) { - return { error: "Not a number" }; - } else { - return { ok: maybeMaxEntries }; - } - }); - if ("error" in maxEntriesResult) { - return maxEntriesResult; - } - const maxEntries = maxEntriesResult.ok!; - - // Attempt to parse `` as Action. - const actionResult = parseToken("", actionToken, source => { - for (let key in Action) { - const maybeAction = Action[key as keyof typeof Action]; - if (key === source) { - return { ok: maybeAction } - } else if (maybeAction === source) { - return { ok: maybeAction } - } - } - return {error: `Expected one of ${JSON.stringify(Action)}`}; - }) - if ("error" in actionResult) { - return actionResult; - } - const action: Action = actionResult.ok!; - - // Now list affected rooms. - const rooms: Set = new Set(); - let reasonParts: string[] | undefined; - const protectedRooms = new Set(mjolnir.protectedRoomsTracker.getProtectedRooms()); - for (let token of optionalTokens) { - const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token); - if ("error" in maybeArg) { - return maybeArg; - } - const maybeRoom = maybeArg.ok; - if (!reasonParts) { - // If we haven't reached the reason yet, attempt to use `maybeRoom` as a room. - if (maybeRoom === "*") { - for (let roomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) { - rooms.add(roomId); - } - continue; - } else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) { - const roomId = await mjolnir.client.resolveRoom(maybeRoom); - if (!protectedRooms.has(roomId)) { - return mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); - } - rooms.add(roomId); - continue; - } - // If we reach this step, it's not a room, so it must be a reason. - // All further arguments are now part of `reason`. - reasonParts = []; - } - reasonParts.push(maybeRoom); - } - - if (rooms.size === 0) { - return { - error: "Missing rooms. Use `*` if you wish to apply to every protected room.", - }; - } - - const progressEventId = await mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '⏳'); - const reason: string | undefined = reasonParts?.join(" "); - - for (let targetRoomId of rooms) { - let {html, text} = await (async () => { - let results: Summary = { succeeded: [], failed: []}; - const recentJoins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); - - switch (action) { - case Action.Show: { - return makeJoinStatus(mjolnir, targetRoomId, maxEntries, minDate, maxAgeMS, recentJoins); - } - case Action.Kick: { - for (let join of recentJoins) { - try { - await mjolnir.client.kickUser(join.userId, targetRoomId, reason); - results.succeeded.push(join.userId); - } catch (ex) { - LogService.warn("SinceCommand", "Error while attempting to kick user", ex); - results.failed.push(join.userId); - } - } - - return formatResult("kick", targetRoomId, recentJoins, results); - } - case Action.Ban: { - for (let join of recentJoins) { - try { - await mjolnir.client.banUser(join.userId, targetRoomId, reason); - results.succeeded.push(join.userId); - } catch (ex) { - LogService.warn("SinceCommand", "Error while attempting to ban user", ex); - results.failed.push(join.userId); - } - } - - return formatResult("ban", targetRoomId, recentJoins, results); - } - case Action.Mute: { - const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record}; - - for (let join of recentJoins) { - powerLevels.users[join.userId] = -1; - } - try { - await mjolnir.client.sendStateEvent(targetRoomId, "m.room.power_levels", "", powerLevels); - for (let join of recentJoins) { - results.succeeded.push(join.userId); - } - } catch (ex) { - LogService.warn("SinceCommand", "Error while attempting to mute users", ex); - for (let join of recentJoins) { - results.failed.push(join.userId); - } - } - - return formatResult("mute", targetRoomId, recentJoins, results); - } - case Action.Unmute: { - const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record, users_default?: number}; - for (let join of recentJoins) { - // Restore default powerlevel. - delete powerLevels.users[join.userId]; - } - try { - await mjolnir.client.sendStateEvent(targetRoomId, "m.room.power_levels", "", powerLevels); - for (let join of recentJoins) { - results.succeeded.push(join.userId); - } - } catch (ex) { - LogService.warn("SinceCommand", "Error while attempting to unmute users", ex); - for (let join of recentJoins) { - results.failed.push(join.userId); - } - } - - return formatResult("unmute", targetRoomId, recentJoins, results); - } - } - })(); - - const reply = RichReply.createFor(destinationRoomId, event, text, html); - reply["msgtype"] = "m.notice"; - /* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply); - } - - await mjolnir.client.redactEvent(destinationRoomId, progressEventId); - return {ok: undefined}; -} - -function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: number, minDate: Date, maxAgeMS: number, recentJoins: Join[]): {html: string, text: string} { - const HUMANIZER_OPTIONS = { - // Reduce "1 day" => "1day" to simplify working with CSV. - spacer: "", - // Reduce "1 day, 2 hours" => "1.XXX day" to simplify working with CSV. - largest: 1, - }; - const maxAgeHumanReadable = HUMANIZER.humanize(maxAgeMS); - const htmlFragments = []; - const textFragments = []; - for (let join of recentJoins) { - const durationHumanReadable = HUMANIZER.humanize(Date.now() - join.timestamp, HUMANIZER_OPTIONS); - htmlFragments.push(`
  • ${htmlEscape(join.userId)}: ${durationHumanReadable}
  • `); - textFragments.push(`- ${join.userId}: ${durationHumanReadable}`); - } - return { - html: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):
      ${htmlFragments.join()}
    `, - text: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}` - } -} diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index a8399db3..e3d8d398 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -25,40 +25,33 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { STATE_CHECKING_PERMISSIONS, STATE_NOT_STARTED, STATE_RUNNING, STATE_SYNCING } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; -import PolicyList from "../models/PolicyList"; import { PACKAGE_JSON, SOFTWARE_VERSION } from "../config"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, RestDescription } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; -import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { MatrixSendClient } from "../MatrixEmitter"; +import { parameters } from "./interface-manager/ParameterParsing"; +import { DraupnirContext } from "./CommandHandler"; +import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { JSXFactory } from "./interface-manager/JSXFactory"; -import { Protection } from "../protections/Protection"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; +import { ActionResult, Ok, PolicyRoomRevision, PolicyRoomWatchProfile, PolicyRuleType, isError } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; defineInterfaceCommand({ designator: ["status"], table: "mjolnir", parameters: parameters([]), - command: async function () { return CommandResult.Ok(mjolnirStatusInfo.call(this)) }, + command: async function (this: DraupnirContext): Promise> { + return Ok(await draupnirStatusInfo(this.draupnir)) + }, summary: "Show the status of the bot." }) export interface ListInfo { - shortcode: string, - roomRef: string, - roomId: string, - serverRules: number, - userRules: number, - roomRules: number, + watchedListProfile: PolicyRoomWatchProfile, + revision: PolicyRoomRevision } export interface StatusInfo { - state: string, // a small description of the state of Mjolnir numberOfProtectedRooms: number, subscribedLists: ListInfo[], subscribedAndProtectedLists: ListInfo[], @@ -66,27 +59,36 @@ export interface StatusInfo { repository: string } - -function mjolnirStatusInfo(this: MjolnirContext): StatusInfo { - const listInfo = (list: PolicyList): ListInfo => { +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 { - shortcode: list.listShortcode, - roomRef: list.roomRef, - roomId: list.roomId, - serverRules: list.serverRules.length, - userRules: list.userRules.length, - roomRules: list.roomRules.length, + watchedListProfile: associatedProfile, + revision: revision } - } + }) +} + +// FIXME: need a shoutout to dependencies in here and NOTICE info. +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 { - state: this.mjolnir.state, - numberOfProtectedRooms: this.mjolnir.protectedRoomsTracker.getProtectedRooms().length, - subscribedLists: this.mjolnir.policyListManager.lists - .filter(list => !this.mjolnir.explicitlyProtectedRooms.includes(list.roomId)) - .map(listInfo), - subscribedAndProtectedLists: this.mjolnir.policyListManager.lists - .filter(list => this.mjolnir.explicitlyProtectedRooms.includes(list.roomId)) - .map(listInfo), + numberOfProtectedRooms: draupnir.protectedRoomsSet.protectedRoomsConfig.allRooms.length, + subscribedLists: unprotectedListProfiles, + subscribedAndProtectedLists: protectedWatchedLists, version: SOFTWARE_VERSION, repository: PACKAGE_JSON['repository'] ?? 'Unknown' } @@ -94,94 +96,36 @@ function mjolnirStatusInfo(this: MjolnirContext): StatusInfo { defineMatrixInterfaceAdaptor({ interfaceCommand: findTableCommand("mjolnir", "status"), - renderer: async function (this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: any, result: CommandResult): Promise { - const renderState = (state: StatusInfo['state']) => { - const notRunning = (text: string) => { - return Running: ❌ (${text})
    - }; - switch (state) { - case STATE_NOT_STARTED: - return notRunning('not started'); - case STATE_CHECKING_PERMISSIONS: - return notRunning('checking own permission'); - case STATE_SYNCING: - return notRunning('syncing lists'); - case STATE_RUNNING: - return Running:
    - default: - return notRunning('unknown state'); - } - }; + renderer: async function (this, client, commandRoomID, event, result: ActionResult): Promise { const renderPolicyLists = (header: string, lists: ListInfo[]) => { - const listInfo = lists.map(list => { + const renderedLists = lists.map(list => { return
  • - {list.shortcode} @ {list.roomId} - (rules: {list.serverRules} servers, {list.userRules} users, {list.roomRules} rooms) + {list.revision.room.toRoomIDOrAlias()} 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}
      - {listInfo.length === 0 ?
    • None
    • : listInfo} + {renderedLists.length === 0 ?
    • None
    • : renderedLists}
    }; + if (isError(result)) { + await tickCrossRenderer.call(this, client, commandRoomID, event, result); + return; + } const info = result.ok; await renderMatrixAndSend( - {renderState(info.state)} Protected Rooms: {info.numberOfProtectedRooms}
    - {renderPolicyLists('Subscribed policy lists', info.subscribedLists)} - {renderPolicyLists('Subscribed and protected policy lists', info.subscribedAndProtectedLists)} + {renderPolicyLists('Subscribed policy rooms', info.subscribedLists)} + {renderPolicyLists('Subscribed and protected policy rooms', info.subscribedAndProtectedLists)} Version: {info.version}
    Repository: {info.repository}
    , - commandRoomId, + commandRoomID, event, client); } }); - -defineInterfaceCommand({ - designator: ["status", "protection"], - table: "mjolnir", - parameters: parameters([ - { - name: "protection name", - acceptor: findPresentationType("string") - }, - ], - new RestDescription( - "subcommand", - findPresentationType("any") - )), - command: async function ( - this: MjolnirContext, _keywords, protectionName: string, ...subcommands: string[] - ): Promise>>> { - const protection = this.mjolnir.protectionManager.getProtection(protectionName); - if (!protection) { - return CommandError.Result(`Unknown protection ${protectionName}`); - } - return CommandResult.Ok(await protection.statusCommand(this.mjolnir, subcommands)) - }, - summary: "Show the status of a protection." -}) - -defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("mjolnir", "status", "protection"), - renderer: async function(client, commandRoomId, event, result) { - tickCrossRenderer.call(this, ...arguments); - if (result.isErr()) { - return; // tickCrossRenderer will handle it. - } - const status = result.ok; - const reply = RichReply.createFor( - commandRoomId, - event, - status?.text ?? "", - status?.html ?? "<no status>" - ); - reply["msgtype"] = "m.notice"; - await client.sendMessage(commandRoomId, reply); - } -}) diff --git a/src/commands/SyncCommand.ts b/src/commands/SyncCommand.ts deleted file mode 100644 index f4642fce..00000000 --- a/src/commands/SyncCommand.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Mjolnir } from "../Mjolnir"; - -// !mjolnir sync -export async function execSyncCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.protectedRoomsTracker.syncLists(); -} diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index e9f8fc21..13f7b187 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -25,86 +25,80 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MjolnirContext } from "./CommandHandler"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; +import { DraupnirContext } from "./CommandHandler"; import { findPresentationType, KeywordsDescription, parameters, ParsedKeywords, union } from "./interface-manager/ParameterParsing"; import { UserID, MatrixGlob, LogLevel } from "matrix-bot-sdk"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; -import { findPolicyListFromRoomReference, findPolicyListFromShortcode } from "./Ban"; -import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../models/ListRule"; -import { Mjolnir } from "../Mjolnir"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; -import PolicyList from "../models/PolicyList"; +import { Draupnir } from "../Draupnir"; +import { ActionResult, isError, isStringUserID, MatrixRoomReference, Ok, PolicyRuleType } from "matrix-protection-suite"; +import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -async function unbanUserFromRooms(mjolnir: Mjolnir, rule: MatrixGlob) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "Unban", "Unbanning users that match glob: " + rule.regex); - let unbannedSomeone = false; - for (const protectedRoomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) { - const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined); - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "Unban", `Found ${members.length} banned user(s)`); - for (const member of members) { - const victim = member.membershipFor; - if (member.membership !== 'ban') continue; - if (rule.test(victim)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "Unban", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); - - if (!mjolnir.config.noop) { - await mjolnir.client.unbanUser(victim, protectedRoomId); +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 mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "Unban", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); + 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()); } - - unbannedSomeone = true; } } } - - if (unbannedSomeone) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "Unban", `Syncing lists to ensure no users were accidentally unbanned`); - await mjolnir.protectedRoomsTracker.syncLists(); - } } async function unban( - this: MjolnirContext, + this: DraupnirContext, keywords: ParsedKeywords, entity: UserID|MatrixRoomReference|string, - policyListReference: MatrixRoomReference|string, -): Promise> { - // first step is to resolve the policy list - const policyListResult = typeof policyListReference === 'string' - ? await findPolicyListFromShortcode(this.mjolnir, policyListReference) - : policyListReference instanceof PolicyList - ? CommandResult.Ok(policyListReference) - : await findPolicyListFromRoomReference(this.mjolnir, policyListReference); - if (policyListResult.isErr()) { - return policyListResult; + policyRoomReference: MatrixRoomReference, +): Promise> { + const policyRoom = await resolveRoomReferenceSafe(this.client, policyRoomReference); + if (isError(policyRoom)) { + return policyRoom; } - const policyList = policyListResult.ok; - - if (entity instanceof UserID) { - await policyList.unbanEntity(RULE_USER, entity.toString()); - } else if (entity instanceof MatrixRoomReference) { - await policyList.unbanEntity(RULE_ROOM, entity.toRoomIdOrAlias()); - } else { - await policyList.unbanEntity(RULE_SERVER, entity); + 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()) - this.mjolnir.unlistedUserRedactionHandler.removeUser(entity.toString()); + if (isStringUserID(rawEnttiy)) { + this.draupnir.unlistedUserRedactionQueue.removeUser(rawEnttiy); + } if (!isGlob(rawEnttiy) || keywords.getKeyword("true", "false") === "true") { - await unbanUserFromRooms(this.mjolnir, rule); + await unbanUserFromRooms(this.draupnir, rule); } else { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "Unban", "Running unban without `unban true` will not override existing room level bans"); + await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "Unban", "Running unban without `unban true` will not override existing room level bans"); } } - return CommandResult.Ok(undefined); + return Ok(undefined); } defineInterfaceCommand({ @@ -123,12 +117,13 @@ defineInterfaceCommand({ name: "list", acceptor: union( findPresentationType("MatrixRoomReference"), - findPresentationType("string"), - findPresentationType("PolicyList"), ), - prompt: async function (this: MjolnirContext) { + prompt: async function (this: DraupnirContext) { return { - suggestions: this.mjolnir.policyListManager.lists + suggestions: this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( + this.draupnir.clientUserID, + PolicyRuleType.User + ) }; } }, diff --git a/src/commands/WatchUnwatchCommand.ts b/src/commands/WatchUnwatchCommand.ts index 9d51b8b1..a4affbc5 100644 --- a/src/commands/WatchUnwatchCommand.ts +++ b/src/commands/WatchUnwatchCommand.ts @@ -27,11 +27,11 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; +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 { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; defineInterfaceCommand({ table: "mjolnir", @@ -43,9 +43,12 @@ defineInterfaceCommand({ acceptor: findPresentationType("MatrixRoomReference"), } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, list: MatrixRoomReference): Promise> { - await this.mjolnir.policyListManager.watchList(list); - return CommandResult.Ok(undefined); + 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, {}); }, }) @@ -64,9 +67,12 @@ defineInterfaceCommand({ acceptor: findPresentationType("MatrixRoomReference"), } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, list: MatrixRoomReference): Promise> { - await this.mjolnir.policyListManager.unwatchList(list); - return CommandResult.Ok(undefined); + 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); }, }) diff --git a/src/commands/interface-manager/CommandException.ts b/src/commands/interface-manager/CommandException.ts deleted file mode 100644 index 88f00b33..00000000 --- a/src/commands/interface-manager/CommandException.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (C) 2023 Gnuxie - * All rights reserved. - */ - -import { randomUUID } from "crypto"; -import { CommandError, CommandResult } from "./Validation"; -import { LogService } from "matrix-bot-sdk"; - -export enum CommandExceptionKind { - /** - * This class is for exceptions that need to be reported to the user, - * but are mostly irrelevant to the developers because the behaviour is well - * understood and expected. These exceptions will never be logged to the error - * level. - */ - Known = 'Known', - /** - * This class is to be used for reporting unexpected or unknown exceptions - * that the developers need to know about. - */ - Unknown = 'Unknown', -} - -// FIXME: I wonder if we could allow message to be JSX? -// Then room references could be put into the DM and actually mean something. -export class CommandException extends CommandError { - public readonly uuid = randomUUID(); - - constructor( - public readonly exceptionKind: CommandExceptionKind, - public readonly exception: Error|unknown, - message: string) { - super(message) - this.log(); - } - - public static Result(message: string, options: { exception: Error, exceptionKind: CommandExceptionKind }): CommandResult { - return CommandResult.Err(new CommandException(options.exceptionKind, options.exception, message)); - } - - protected log(): void { - const logArguments: Parameters = ["CommandException", this.exceptionKind, this.uuid, this.message, this.exception]; - if (this.exceptionKind === CommandExceptionKind.Known) { - LogService.info(...logArguments); - } else { - LogService.error(...logArguments); - } - } -} diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index 7cd12b0a..45f46767 100644 --- a/src/commands/interface-manager/CommandReader.ts +++ b/src/commands/interface-manager/CommandReader.ts @@ -3,9 +3,7 @@ * All rights reserved. */ -import { UserID } from "matrix-bot-sdk"; -import { MatrixRoomReference } from "./MatrixRoomReference"; -import { Permalinks } from "./Permalinks"; +import { MatrixEventReference, MatrixRoomReference, Permalinks, UserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; export interface ISuperCoolStream { readonly source: T @@ -138,7 +136,7 @@ function readItem(stream: StringStream): ReadItem { type ReadMacro = (stream: StringStream) => ReadItem; const WORD_DISPATCH_CHARACTERS = new Map(); -export type ReadItem = string | MatrixRoomReference | UserID | Keyword; +export type ReadItem = string | MatrixRoomReference | UserID | Keyword | MatrixEventReference; /** * Defines a read macro to produce a read item. @@ -211,7 +209,11 @@ function readRoomIDOrAlias(stream: StringStream): MatrixRoomReference|string { return word.join(''); } readUntil(/\s/, stream, word); - return MatrixRoomReference.fromRoomIdOrAlias(word.join('')); + const wholeWord = word.join(''); + if (!isStringRoomID(wholeWord) && !isStringRoomAlias(wholeWord)) { + return wholeWord; + } + return MatrixRoomReference.fromRoomIDOrAlias(wholeWord); } /** @@ -267,11 +269,27 @@ defineReadItem('-', readKeyword); defineReadItem(':', readKeyword); definePostReadReplace(/^https:\/\/matrix\.to/, input => { - const url = Permalinks.parseUrl(input); - if (url.eventId !== undefined) { - // don't know what to turn event references into yet. + 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; + } + } else if (url.userID !== undefined) { + return new UserID(url.userID); } else { - return MatrixRoomReference.fromPermalink(input); + const roomResult = MatrixRoomReference.fromPermalink(input); + if (isError(roomResult)) { + return input; + } else { + return roomResult.ok; + } } }) diff --git a/src/commands/interface-manager/DeadDocument.ts b/src/commands/interface-manager/DeadDocument.ts index 08a27f57..15cfb134 100644 --- a/src/commands/interface-manager/DeadDocument.ts +++ b/src/commands/interface-manager/DeadDocument.ts @@ -46,6 +46,10 @@ export interface LeafNode extends AbstractNode { readonly leafNode: true, } +// TODO: https://www.typescriptlang.org/docs/handbook/jsx.html#intrinsic-elements +// we should figure out how to type not only the attributes but also importantly +// the result of embedded expressions + // These are NOT necessarily HTML tags. export enum NodeTag { TextNode = 'text', @@ -67,6 +71,7 @@ export enum NodeTag { Details = 'details', Summary = 'summary', Font = 'font', + Span = 'span', } /** @@ -322,7 +327,7 @@ export class FringeWalker { 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); + (annotatedNode.node.tag, annotatedNode.node as unknown as LeafNode, this.context); break; default: throw new TypeError(`Uknown fringe type ${annotatedNode.type}`); diff --git a/src/commands/interface-manager/DeadDocumentHtml.ts b/src/commands/interface-manager/DeadDocumentHtml.ts index 1b2e202a..5f5a13fc 100644 --- a/src/commands/interface-manager/DeadDocumentHtml.ts +++ b/src/commands/interface-manager/DeadDocumentHtml.ts @@ -83,4 +83,6 @@ HTML_RENDERER.registerRenderer'), staticString('') -); +).registerInnerNode(NodeTag.Span, + staticString(''), + staticString('')); diff --git a/src/commands/interface-manager/DeadDocumentMarkdown.ts b/src/commands/interface-manager/DeadDocumentMarkdown.ts index 19651dba..1a37c0c4 100644 --- a/src/commands/interface-manager/DeadDocumentMarkdown.ts +++ b/src/commands/interface-manager/DeadDocumentMarkdown.ts @@ -55,7 +55,9 @@ MARKDOWN_RENDERER.registerRenderer { diff --git a/src/commands/interface-manager/InterfaceCommand.ts b/src/commands/interface-manager/InterfaceCommand.ts index 263435ca..582365be 100644 --- a/src/commands/interface-manager/InterfaceCommand.ts +++ b/src/commands/interface-manager/InterfaceCommand.ts @@ -29,8 +29,8 @@ limitations under the License. * I'd like to remove the dependency on matrix-bot-sdk. */ +import { ActionResult, isError } from "matrix-protection-suite"; import { ParameterParser, IArgumentStream, IArgumentListParser, ParsedKeywords, ArgumentStream } from "./ParameterParsing"; -import { CommandResult } from "./Validation"; /** * 💀 . o O ( at least I don't have to remember the types ) @@ -38,7 +38,7 @@ import { CommandResult } from "./Validation"; * Probably am "doing something wrong", and no, trying to make this protocol isn't it. */ -export type BaseFunction = (keywords: ParsedKeywords, ...args: any) => Promise>; +export type BaseFunction = (keywords: ParsedKeywords, ...args: unknown[]) => Promise>; type CommandLookupEntry = { next?: Map>, @@ -190,7 +190,7 @@ export class InterfaceCommand // 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. - public async parseArguments(stream: IArgumentStream): ReturnType { + private async parseArguments(stream: IArgumentStream): ReturnType { return await this.argumentListParser.parse(stream); } @@ -198,16 +198,16 @@ export class InterfaceCommand return this.command.apply(context, args); } - public async parseThenInvoke(context: ThisParameterType, stream: IArgumentStream): Promise> { - const parameterDescription = await this.parseArguments(stream); - if (parameterDescription.isErr()) { + 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 parameterDescription as ReturnType>; + return ParsedArguments; } return await this.command.apply(context, [ - parameterDescription.ok.keywords, - ...parameterDescription.ok.immediateArguments, - ...parameterDescription.ok.rest ?? [] + ParsedArguments.ok.keywords, + ...ParsedArguments.ok.immediateArguments, + ...ParsedArguments.ok.rest ?? [] ]); } } diff --git a/src/commands/interface-manager/JSXFactory.ts b/src/commands/interface-manager/JSXFactory.ts index 2c890e14..40f2ce04 100644 --- a/src/commands/interface-manager/JSXFactory.ts +++ b/src/commands/interface-manager/JSXFactory.ts @@ -9,7 +9,7 @@ import { presentationTypeOf } from "./ParameterParsing"; type rawJSX = DocumentNode|LeafNode|string|number|Array; -export function JSXFactory(tag: NodeTag, properties: any, ...rawChildren: (DocumentNode|LeafNode|string)[]) { +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)) { @@ -45,6 +45,7 @@ export function JSXFactory(tag: NodeTag, properties: any, ...rawChildren: (Docum } +// eslint-disable-next-line no-redeclare namespace JSXFactory { export interface IntrinsicElements { [elemName: string]: any; diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 9a91cbc1..4d17715b 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -2,16 +2,15 @@ * Copyright (C) 2022 Gnuxie */ -import { MatrixSendClient } from "../../MatrixEmitter"; import { BaseFunction, CommandTable, InterfaceCommand } from "./InterfaceCommand"; -import { MatrixContext, MatrixInterfaceAdaptor } from "./MatrixInterfaceAdaptor"; +import { MatrixContext, MatrixInterfaceAdaptor, RendererSignature } from "./MatrixInterfaceAdaptor"; import { ArgumentParseError, ParameterDescription, RestDescription } from "./ParameterParsing"; -import { CommandError, CommandResult } from "./Validation"; import { JSXFactory } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; -import { CommandException } from "./CommandException"; import { LogService } from "matrix-bot-sdk"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ActionException, ActionResult, MatrixRoomReference, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; function requiredArgument(argumentName: string): string { return `<${argumentName}>`; @@ -77,45 +76,45 @@ function renderTableHelp(table: CommandTable): DocumentNode { } -export async function renderHelp(client: MatrixSendClient, commandRoomId: string, event: any, result: CommandResult): Promise { - if (result.isErr()) { +export async function renderHelp(client: MatrixSendClient, commandRoomID: StringRoomID, event: any, result: ActionResult): Promise { + if (isError(result)) { throw new TypeError("This command isn't supposed to fail"); } await renderMatrixAndSend( renderTableHelp(result.ok), - commandRoomId, + commandRoomID, event, client ); } -export async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: any, result: CommandResult): Promise { +export const tickCrossRenderer: RendererSignature = async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult): Promise { const react = async (emote: string) => { try { - await client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], emote); + await client.unstableApis.addReactionToEvent(commandRoomID, event['event_id'], emote); } catch (e) { LogService.error("tickCrossRenderer", "Couldn't react to the event", event['event_id'], e); } } - if (result.isOk()) { + if (result.isOkay) { await react('✅') } else { - if (result.err instanceof ArgumentParseError) { + if (result.error instanceof ArgumentParseError) { await renderMatrixAndSend( - renderArgumentParseError(this.interfaceCommand, result.err), - commandRoomId, + renderArgumentParseError(this.interfaceCommand, result.error), + commandRoomID, event, client); - } else if (result.err instanceof CommandException) { - const commandError = result.err; + } 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.err), - commandRoomId, + renderCommandException(this.interfaceCommand, result.error), + commandRoomID, event, client); } else { - await client.replyNotice(commandRoomId, event, result.err.message); + await client.replyNotice(commandRoomID, event, result.error.message); } // reacting is way less important than communicating what happened, do it last. await react('❌'); @@ -146,7 +145,7 @@ function renderArgumentParseError(command: InterfaceCommand, error } -function renderCommandException(command: InterfaceCommand, error: CommandException): DocumentNode { +function renderCommandException(command: InterfaceCommand, error: ActionException): DocumentNode { return There was an unexpected error when processing this command:
    {error.message}
    @@ -159,3 +158,7 @@ export function renderMentionPill(mxid: string, displayName: string): DocumentNo const url = `https://matrix.to/#/${mxid}`; return {displayName} } + +export function renderRoomPill(room: MatrixRoomReference): DocumentNode { + return {room.toRoomIDOrAlias()} +} diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 02b5fae6..0e6debfe 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -29,36 +29,44 @@ limitations under the License. * I'd like to remove the dependency on matrix-bot-sdk. */ -import { CommandError, CommandResult } from "./Validation"; -import { LogService, MatrixClient } from "matrix-bot-sdk"; +import { LogService } from "matrix-bot-sdk"; import { ReadItem } from "./CommandReader"; -import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; import { tickCrossRenderer } from "./MatrixHelpRenderer"; -import { CommandInvocationRecord, InterfaceAcceptor, PromptableArgumentStream, PromptOptions } from "./PromptForAccept"; +import { InterfaceAcceptor, PromptOptions, PromptableArgumentStream } from "./PromptForAccept"; import { ParameterDescription } from "./ParameterParsing"; -import { matrixPromptForAccept } from "./MatrixPromptForAccept"; +import { promptDefault, promptSuggestions } from "./MatrixPromptForAccept"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ActionError, ActionResult, ClientPlatform, ResultError, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; +import { MatrixReactionHandler } from "./MatrixReactionHandler"; +import { PromptRequiredError } from "./PromptRequiredError"; export interface MatrixContext { + reactionHandler: MatrixReactionHandler, client: MatrixSendClient, - emitter: MatrixEmitter, - roomId: string, - event: any, + // 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, } -type RendererSignature = ( +export type RendererSignature = ( this: MatrixInterfaceAdaptor, - client: MatrixClient, - commandRoomId: string, - event: any, - result: Awaited>) => Promise; + 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: MatrixClient, roomId: string, event: any, validationError: CommandError) => Promise + private readonly validationErrorHandler?: (client: MatrixSendClient, roomID: StringRoomID, event: RoomEvent, validationError: ActionError) => Promise ) { } @@ -72,16 +80,32 @@ export class MatrixInterfaceAdaptor, matrixContext: C, ...args: ReadItem[]): Promise { - const invocationRecord = new MatrixInvocationRecord>(this.interfaceCommand, executorContext, matrixContext); - const stream = new PromptableArgumentStream(args, this, invocationRecord); + const stream = new PromptableArgumentStream(args, this); const executorResult: Awaited> = await this.interfaceCommand.parseThenInvoke(executorContext, stream); - if (executorResult.isErr()) { - this.reportValidationError(matrixContext.client, matrixContext.roomId, matrixContext.event, executorResult.err); - return; + // 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); + } + } else { + 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]); + 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? @@ -89,42 +113,16 @@ export class MatrixInterfaceAdaptor { + 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.apply(this, arguments); return; } - await tickCrossRenderer.call(this, client, roomId, event, CommandResult.Err(validationError)); - } - - public async promptForAccept(parameter: ParameterDescription, invocationRecord: CommandInvocationRecord): Promise> { - if (!(invocationRecord instanceof MatrixInvocationRecord)) { - throw new TypeError("The MatrixInterfaceAdaptor only supports invocation records that were produced by itself."); - } - if (parameter.prompt === undefined) { - throw new TypeError(`parameter ${parameter.name} in command ${JSON.stringify(invocationRecord.command.designator)} is not promptable, yet the MatrixInterfaceAdaptor is being prompted`); - } - // Slowly starting to think that we're making a mistake by using `this` so much.... - // First extract the prompt results in the command executor context - const promptOptions: PromptOptions = await parameter.prompt.call(invocationRecord.executorContext, parameter); - // Then present the prompt. - const promptResult: Awaited>> = await matrixPromptForAccept.call(invocationRecord.matrixContext, parameter, invocationRecord.command, promptOptions); - return promptResult; + await tickCrossRenderer.call(this, client, roomID, event, ResultError(validationError)); } } -export class MatrixInvocationRecord implements CommandInvocationRecord { - constructor( - public readonly command: InterfaceCommand, - public readonly executorContext: ExecutorContext, - public readonly matrixContext: MatrixContext, - ) { - - } -} - - const MATRIX_INTERFACE_ADAPTORS = new Map, MatrixInterfaceAdaptor>(); function internMatrixInterfaceAdaptor(interfaceCommand: InterfaceCommand, adapator: MatrixInterfaceAdaptor): void { @@ -150,9 +148,9 @@ export function findMatrixInterfaceAdaptor(interfaceCommand: InterfaceCommand Promise>(details: { - interfaceCommand: InterfaceCommand, - renderer: RendererSignature - }) { + interfaceCommand: InterfaceCommand, + renderer: RendererSignature +}) { internMatrixInterfaceAdaptor( details.interfaceCommand, new MatrixInterfaceAdaptor( diff --git a/src/commands/interface-manager/MatrixPresentations.tsx b/src/commands/interface-manager/MatrixPresentations.tsx index 1a3bb109..8729caf1 100644 --- a/src/commands/interface-manager/MatrixPresentations.tsx +++ b/src/commands/interface-manager/MatrixPresentations.tsx @@ -5,11 +5,10 @@ import { ReadItem } from "./CommandReader"; import { findPresentationType, makePresentationType, simpleTypeValidator } from "./ParameterParsing"; -import { UserID } from "matrix-bot-sdk"; -import { MatrixRoomAlias, MatrixRoomID, MatrixRoomReference } from "./MatrixRoomReference"; import { definePresentationRenderer } from "./DeadDocumentPresentation"; import { JSXFactory } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; +import { MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomAlias, MatrixRoomID, UserID } from "matrix-protection-suite"; makePresentationType({ @@ -19,7 +18,7 @@ makePresentationType({ makePresentationType({ name: 'MatrixRoomReference', - validator: simpleTypeValidator('MatrixRoomReference', (item: ReadItem) => item instanceof MatrixRoomReference), + validator: simpleTypeValidator('MatrixRoomReference', (item: ReadItem) => item instanceof MatrixRoomID || item instanceof MatrixRoomAlias), }) makePresentationType({ @@ -38,3 +37,8 @@ definePresentationRenderer(findPresentationType('UserID'), function (presentatio {presentation.toString()} }) + +makePresentationType({ + 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 cbc444ad..e3f2785d 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -3,57 +3,202 @@ * All rights reserved. */ +import { ClientPlatform, Logger, RoomEvent, StringRoomID, Task, Value, isError } from "matrix-protection-suite"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; -import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; +import { BaseFunction, CommandTable, InterfaceCommand } from "./InterfaceCommand"; import { JSXFactory } from "./JSXFactory"; -import { MatrixContext } from "./MatrixInterfaceAdaptor"; -import { PromptResponseListener } from "./MatrixPromptUX"; -import { ParameterDescription } from "./ParameterParsing"; -import { PromptOptions } from "./PromptForAccept"; -import { CommandResult } from "./Validation"; - -async function promptDefault(this: MatrixContext, parameter: ParameterDescription, command: InterfaceCommand, defaultPrompt: PresentationType) { - await renderMatrixAndSend( +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 { StaticDecode, Type } from "@sinclair/typebox"; +import { ReadItem, readCommand } from "./CommandReader"; + +const log = new Logger('MatrixPromptForAccept'); + +type PromptContext = StaticDecode; +// FIXME: Remove no-redeclare entirely, it is wrong. +// eslint-disable-next-line no-redeclare +const PromptContext = Type.Object({ + command_designator: Type.Array(Type.String()), + read_items: Type.Array(Type.String()), +}); + +type DefaultPromptContext = StaticDecode; +// FIXME: Remove no-redeclare entirely, it is wrong. +// eslint-disable-next-line no-redeclare +const DefaultPromptContext = Type.Composite([ + 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([ + ...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, + }; + 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 +): 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 + ); + } +} + +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 +): 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 + ); + } +} + +export async function promptDefault( + 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?
    {defaultPrompt}
    , - this.roomId, this.event, this.client - ) + this.roomID, this.event, this.client, + this.reactionHandler.createAnnotation( + DEFAUILT_ARGUMENT_PROMPT_LISTENER, + reactionMap, + { + command_designator: command.designator, + read_items: existingArguments.map(item => item.toString()), + default: defaultPrompt.toString() + } + ) + ); + 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. - -async function promptSuggestions( - this: MatrixContext, parameter: ParameterDescription, command: InterfaceCommand, suggestions: PresentationType[] -): Promise { - return (await renderMatrixAndSend( +// 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[], +): Promise { + const reactionMap = MatrixReactionHandler.createItemizedReactionMap( + suggestions.map(item => item.toString()) + ); + const events = await renderMatrixAndSend( Please select one of the following options to provide as an argument for the parameter {parameter.name}:
        - {suggestions.map((suggestion: PresentationType) => { + {suggestions.map((suggestion) => { return
      1. {suggestion}
      2. })}
      , - this.roomId, this.event, this.client - )).at(0) as string; - -} - -export async function matrixPromptForAccept ( - this: MatrixContext, parameter: ParameterDescription, command: InterfaceCommand, promptOptions: PromptOptions -): Promise> { - const promptHelper = new PromptResponseListener(this.emitter, await this.client.getUserId(), this.client); - if (promptOptions.default) { - await promptDefault.call(this, parameter, command, promptOptions.default); - throw new TypeError("default prompts are not implemented yet."); - } - return await promptHelper.waitForPresentationList( - promptOptions.suggestions, - this.roomId, - promptSuggestions.call(this, parameter, command, promptOptions.suggestions) + this.roomID, this.event, this.client, + this.reactionHandler.createAnnotation( + ARGUMENT_PROMPT_LISTENER, + reactionMap, + { + read_items: existingArguments, + command_designator: command.designator + } + ) + ); + await this.reactionHandler.addReactionsToEvent( + this.client, + this.roomID, + events[0], + reactionMap ); } diff --git a/src/commands/interface-manager/MatrixPromptUX.ts b/src/commands/interface-manager/MatrixPromptUX.ts deleted file mode 100644 index 8886932d..00000000 --- a/src/commands/interface-manager/MatrixPromptUX.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright (C) 2023 Gnuxie - * All rights reserved. - */ - -import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; -import { CommandError, CommandResult } from "./Validation"; -import { LogService } from "matrix-bot-sdk"; - -type PresentationByReactionKey = Map; - -// Returns true if the listener should be kept. -type ReactionPromptListener = (presentation: any) => boolean|void; - -// Instead of providing a map of reaciton keys to presentations, should instead -// there be provided an object that can quickly be interned and uninterened from the table -// by testing EQ? It's important to wrap the map if you consider that -// multiple invocations can have seperate listeners but share the same map. -class ReactionPromptRecord { - constructor( - public readonly presentationByReaction: PresentationByReactionKey, - public readonly listener: ReactionPromptListener, - ) { - // nothing to do. - } -} - -class ReactionHandler { - private readonly promptRecordByEvent: Map> = new Map(); - - constructor( - matrixEmitter: MatrixEmitter, - private readonly userId: string, - private readonly client: MatrixSendClient, - ) { - matrixEmitter.on('room.event', this.handleEvent.bind(this)) - } - - private addPresentationsForEvent(eventId: string, promptRecord: ReactionPromptRecord): void { - const promptRecords = (() => { - let entry = this.promptRecordByEvent.get(eventId); - if (entry === undefined) { - entry = new Set(); - this.promptRecordByEvent.set(eventId, entry); - } - return entry; - })(); - promptRecords.add(promptRecord); - } - - private removePromptRecordForEvent(eventId: string, promptRecord: ReactionPromptRecord): void { - const promptRecords = this.promptRecordByEvent.get(eventId); - if (promptRecords !== undefined) { - promptRecords.delete(promptRecord); - if (promptRecords.size === 0) { - this.promptRecordByEvent.delete(eventId); - } - } - } - - private async addBaseReactionsToEvent( - roomId: string, eventId: string, presentationsByKey: PresentationByReactionKey, limit = 7 - ) { - return await [...presentationsByKey.keys()].slice(0, limit) - .reduce((acc, key) => acc.then(_ => this.client.unstableApis.addReactionToEvent(roomId, eventId, key)), - Promise.resolve()); - } - - private handleEvent(roomId: string, event: { event_id: string, type: string, content: any, sender: string }): void { - // Horrid, would be nice to have some pattern matchy thingo - if (event.type !== 'm.reaction') { - return; - } - const relatesTo = event['content']?.['m.relates_to']; - if (relatesTo === undefined) { - return; - } - if (relatesTo['rel_type'] !== 'm.annotation') { - return; - } - const relatedEventId = relatesTo['event_id']; - const reactionKey = relatesTo['key']; - if (!(typeof relatedEventId === 'string' && typeof reactionKey === 'string')) { - return; - } - if (event['sender'] === this.userId) { - return; - } - const entry = this.promptRecordByEvent.get(relatedEventId); - if (entry !== undefined) { - for (const record of entry) { - const presentation = record.presentationByReaction.get(reactionKey); - if (presentation === undefined) { - LogService.warn("MatrixPromptUX", `Got an unknown reaction key for the event ${relatedEventId}: ${reactionKey}`) - } else { - const keepListener = record.listener(presentation); - if (!Boolean(keepListener)) { - this.removePromptRecordForEvent(event.event_id, record); - } - } - } - } - } - - public async waitForReactionToPrompt( - roomId: string, eventId: string, presentationByReaction: PresentationByReactionKey, timeout = 600_000 // ten minutes - ): Promise> { - let record; - let timeoutId; - const presentationOrTimeout = await Promise.race([ - new Promise(resolve => { - record = new ReactionPromptRecord(presentationByReaction, resolve); - this.addPresentationsForEvent(eventId, record); - this.addBaseReactionsToEvent(roomId, eventId, presentationByReaction); - }), - new Promise(resolve => timeoutId = setTimeout(resolve, timeout)), - ]); - clearTimeout(timeoutId); - if (presentationOrTimeout === undefined) { - if (record !== undefined) { - this.removePromptRecordForEvent(eventId, record); - } - return CommandError.Result(`Timed out while waiting for a response to the prompt`); - } else { - return CommandResult.Ok(presentationOrTimeout as T); - } - } -} - -// How this would work -// Give token to go into event -// Each presentationByKey is stored against the token, not the event. -// Simultaneous: -// * when a reaction event is sent, we lookup the original event id and find its key -// if we can't find its key, then what do we do? -// Either lookup the event via the endpoint or have some way to wait for it -// to come from the event send promise. Or just wait for the event send promise? -// I think if the event isn't in the store, then we just have to use the get event endpoint -// or try again after 10 seconds or something. -// -// * resolve the presentation by looking at the presentationsByKey for that token. -// This is ovbiously complicated as hell, so i think we will just do without for now. - - -// Shouldn't be a reaciton listener, since it needs to be able to notice reply fallbacks -// like Yes/No and 1. or 2. etc. - -// For ban command can we suggest reasons? I think that'd be a good idea. - -// Prompt takes priority over presentations e.g. imagine the prompt -// requiring a string, but we give one as a presentation in a reply -// reactions should be checked first before being given to the command. -export class PromptResponseListener { - private readonly reactionHandler: ReactionHandler; - - constructor( - matrixEmitter: MatrixEmitter, - userId: string, - client: MatrixSendClient, - ) { - this.reactionHandler = new ReactionHandler(matrixEmitter, userId, client); - } - - private indexToReactionKey(index: number): string { - return `${(index + 1).toString()}.`; - } - - // This won't work, we have to have a special key in the original event - // that means we should be waiting for it, that can't be abused/forged. - // As we can't have the event id AOT. - public async waitForPresentationList(presentations: T[], roomId: string, eventPromise: Promise): Promise> { - const presentationByReactionKey = presentations.reduce( - (map: PresentationByReactionKey, presentation: T, index: number) => { - return map.set(this.indexToReactionKey(index), presentation); - }, - new Map() - ); - return await this.reactionHandler.waitForReactionToPrompt(roomId, await eventPromise, presentationByReactionKey); - } -} diff --git a/src/commands/interface-manager/MatrixReactionHandler.ts b/src/commands/interface-manager/MatrixReactionHandler.ts index d6ad9ae8..a204401d 100644 --- a/src/commands/interface-manager/MatrixReactionHandler.ts +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -4,28 +4,32 @@ */ import { EventEmitter } from "stream"; -import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; import { LogService } from "matrix-bot-sdk"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ReactionEvent, RoomEvent, StringRoomID, StringUserID, Value } from "matrix-protection-suite"; const REACTION_ANNOTATION_KEY = 'ge.applied-langua.ge.draupnir.reaction_handler'; type ItemByReactionKey = Map; -export type ReactionListener = (key: string, item: any, additionalContext: unknown, reactionMap: ItemByReactionKey) => void; +export type ReactionListener = ( + key: string, + item: any, + additionalContext: unknown, + reactionMap: ItemByReactionKey, + annotatedEvent: RoomEvent +) => 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 { - - private listener: MatrixReactionHandler['handleEvent']; - public constructor( /** * The room the handler is for. Cannot be enabled for every room as the - * OG event lookup is very slow. + * OG event lookup is very slow. So usually draupnir's management room. */ - public readonly roomId: string, + public readonly roomID: StringRoomID, /** * A client to lookup the related events to reactions. */ @@ -33,31 +37,30 @@ export class MatrixReactionHandler extends EventEmitter { /** * The user id of the client. Ignores reactions from this user */ - private readonly clientUserId: string + private readonly clientUserID: StringUserID ) { super(); - this.listener = this.handleEvent.bind(this); } /** * 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 roomID The room the event took place in. * @param event The Matrix event. */ - private async handleEvent(roomId: string, event: any): Promise { - if (roomId !== this.roomId) { + public async handleEvent(roomID: StringRoomID, event: RoomEvent): Promise { + if (roomID !== this.roomID) { return; } - const relatesTo = event['content']?.['m.relates_to']; - if (relatesTo === undefined) { + if (event.sender === this.clientUserID) { return; } - if (relatesTo['rel_type'] !== 'm.annotation') { + if (!Value.Check(ReactionEvent, event)) { return; } - if (event['sender'] === this.clientUserId) { + const relatesTo = event.content?.["m.relates_to"]; + if (relatesTo === undefined) { return; } const reactionKey = relatesTo['key']; @@ -65,42 +68,27 @@ export class MatrixReactionHandler extends EventEmitter { if (!(typeof relatedEventId === 'string' && typeof reactionKey === 'string')) { return; } - const annotatedEvent = await this.client.getEvent(roomId, relatedEventId); + 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) { - LogService.warn('MatrixReactionHandler', `Missing reaction_map for the annotated event ${relatedEventId} in ${roomId}`); + LogService.warn('MatrixReactionHandler', `Missing reaction_map for the annotated event ${relatedEventId} in ${roomID}`); return; } const listenerName = annotation['name']; if (typeof listenerName !== 'string') { - LogService.warn('MatrixReactionHandler', `The event ${relatedEventId} in ${roomId} is missing the name of the annotation`); + LogService.warn('MatrixReactionHandler', `The event ${relatedEventId} in ${roomID} is missing the name of the annotation`); return; } const association = reactionMap[reactionKey]; if (association === undefined) { - LogService.info('MatrixReactionHandler', `There wasn't a defined key for ${reactionKey} on event ${relatedEventId} in ${roomId}`); + LogService.info('MatrixReactionHandler', `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))); - } - - /** - * Start listening for reactions to events. - * Called normally by an associated mjolnir instance when it is started. - */ - public start(emitter: MatrixEmitter): void { - emitter.on('room.event', this.listener); - } - - /** - * Stop listening for reactions to events. - */ - public stop(emitter: MatrixEmitter): void { - emitter.off('room.event', this.listener); + this.emit(listenerName, reactionKey, association, annotation['additional_context'], new Map(Object.entries(reactionMap)), annotatedEvent); } /** @@ -133,4 +121,32 @@ export class MatrixReactionHandler extends EventEmitter { Promise.resolve() ).catch(e => (LogService.error('MatrixReactionHandler', `Could not add reaction to event ${eventId}`, e), Promise.reject(e))); } + + 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/MatrixRoomReference.ts b/src/commands/interface-manager/MatrixRoomReference.ts deleted file mode 100644 index d317b26a..00000000 --- a/src/commands/interface-manager/MatrixRoomReference.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - */ - -import { RoomAlias } from "matrix-bot-sdk"; -import { Permalinks } from "./Permalinks"; - -type JoinRoom = (roomIdOrAlias: string, viaServers?: string[]) => Promise; -type ResolveRoom = (roomIdOrAlias: string) => Promise - -/** - * This is a universal reference for a matrix room. - * This is really useful because there are at least 3 ways of referring to a Matrix room, - * and some of them require extra steps to be useful in certain contexts (aliases, permalinks). - */ -export abstract class MatrixRoomReference { - protected constructor( - protected readonly reference: string, - protected readonly viaServers: string[] = [] - ) { - - } - - public toPermalink(): string { - return Permalinks.forRoom(this.reference, this.viaServers); - } - - public static fromAlias(alias: string): MatrixRoomReference { - return new MatrixRoomAlias(alias); - } - - public static fromRoomId(roomId: string, viaServers: string[] = []): MatrixRoomID { - return new MatrixRoomID(roomId, viaServers); - } - - public static fromRoomIdOrAlias(roomIdOrAlias: string, viaServers: string[] = []): MatrixRoomReference { - if (roomIdOrAlias.startsWith('!')) { - return new MatrixRoomID(roomIdOrAlias, viaServers); - } else { - return new MatrixRoomAlias(roomIdOrAlias, viaServers); - } - } - - /** - * Create a reference from a permalink. - * @param permalink A permalink to a matrix room. - * @returns A MatrixRoomReference. - */ - public static fromPermalink(permalink: string): MatrixRoomReference { - const parts = Permalinks.parseUrl(permalink); - if (parts.roomIdOrAlias === undefined) { - throw new TypeError(`There is no room id or alias in the permalink ${permalink}`); - } - return MatrixRoomReference.fromRoomIdOrAlias(parts.roomIdOrAlias, parts.viaServers); - } - - /** - * Resolves the reference if necessary (ie it is a room alias) and return a new `MatrixRoomReference`. - * Maybe in the future this should return a subclass that can only be a RoomID, that will be useful for the config - * problems we're having... - * @param client A client that we can use to resolve the room alias. - * @returns A new MatrixRoomReference that contains the room id. - */ - public async resolve(client: { resolveRoom: ResolveRoom }): Promise { - if (this instanceof MatrixRoomID) { - return this; - } else { - const alias = new RoomAlias(this.reference); - const roomId = await client.resolveRoom(this.reference); - return new MatrixRoomID(roomId, [alias.domain]); - } - } - - /** - * Join the room using the client provided. - * @param client A matrix client that should join the room. - * @returns A MatrixRoomReference with the room id of the room which was joined. - */ - public async joinClient(client: { joinRoom: JoinRoom }): Promise { - if (this.reference.startsWith('!')) { - await client.joinRoom(this.reference, this.viaServers); - return this; - } else { - const roomId = await client.joinRoom(this.reference); - const alias = new RoomAlias(this.reference); - // best we can do with the information we have. - return new MatrixRoomID(roomId, [alias.domain]); - } - } - - /** - * We don't include a `toRoomId` that uses `forceResolveAlias` as this would erase `viaServers`, - * which will be necessary to use if our homeserver hasn't joined the room yet. - * @returns A string representing a room id or alias. - */ - public toRoomIdOrAlias(): string { - return this.reference; - } -} - -export class MatrixRoomID extends MatrixRoomReference { - public constructor( - reference: string, - viaServers: string[] = [] - ) { - super(reference, viaServers); - } -} - -export class MatrixRoomAlias extends MatrixRoomReference { - public constructor( - reference: string, - viaServers: string[] = [] - ) { - super(reference, viaServers); - } -} diff --git a/src/commands/interface-manager/ParameterParsing.ts b/src/commands/interface-manager/ParameterParsing.ts index fa7c3250..8a3ee232 100644 --- a/src/commands/interface-manager/ParameterParsing.ts +++ b/src/commands/interface-manager/ParameterParsing.ts @@ -24,15 +24,18 @@ 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 { ISuperCoolStream, Keyword, ReadItem, SuperCoolStream } from "./CommandReader"; import { PromptOptions } from "./PromptForAccept"; -import { CommandError, CommandResult } from "./Validation"; +import { PromptRequiredError } from "./PromptRequiredError"; export interface IArgumentStream extends ISuperCoolStream { 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>, + prompt(parameterDescription: ParameterDescription): Promise>, } export class ArgumentStream extends SuperCoolStream implements IArgumentStream { @@ -40,11 +43,15 @@ export class ArgumentStream extends SuperCoolStream implements IArgu 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> { + prompt(parameterDescription: ParameterDescription): Promise> { throw new TypeError("This argument stream is NOT promptable, did you even check isPromptable()."); } } @@ -52,7 +59,7 @@ export class ArgumentStream extends SuperCoolStream implements IArgu // TODO: Presentation types should be extracted to their own file. // FIXME: PresentationTypes should not be limited to ReadItems. -export type PredicateIsParameter = (readItem: ReadItem) => CommandResult; +export type PredicateIsParameter = (readItem: ReadItem) => ActionResult; export interface PresentationType { validator: PredicateIsParameter, @@ -86,10 +93,10 @@ export function simpleTypeValidator(name: string, predicate: (readItem: ReadItem return (readItem: ReadItem) => { const result = predicate(readItem); if (result) { - return CommandResult.Ok(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 CommandError.Result(`Was expecting a match for the presentation type: ${name} but got ${readItem}.`); + return ActionError.Result(`Was expecting a match for the presentation type: ${name} but got ${readItem}.`); } } } @@ -98,7 +105,7 @@ export function presentationTypeOf(presentation: unknown): PresentationType|unde // 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).isOk() + .filter(possibleType => possibleType.validator(presentation as ReadItem).isOkay && possibleType.name !== 'any' ); if (candidates.length === 0) { @@ -154,34 +161,37 @@ export class RestDescription implements ParameterDesc * 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 CommandResult of ReadItems associated with 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> { + public async parseRest(stream: IArgumentStream, promptForRest: boolean, keywordParser: KeywordParser): Promise> { const items: ReadItem[] = []; if (this.prompt && promptForRest && stream.isPromptable() && stream.peekItem() === undefined) { - const result = await stream.prompt(this); - if (result.isErr()) { - return result as any; - } + return PromptRequiredError.Result( + `A prompt is required for the missing argument for the ${this.name} parameter`, + { + promptParameter: this, + stream, + } + ); } while (stream.peekItem() !== undefined) { const keywordResult = keywordParser.parseKeywords(stream); - if (keywordResult.isErr()) { - return CommandResult.Err(keywordResult.err); + if (isError(keywordResult)) { + return keywordResult; } if (stream.peekItem() !== undefined) { const validationResult = this.acceptor.validator(stream.peekItem()); - if (validationResult.isErr()) { + if (isError(validationResult)) { return ArgumentParseError.Result( - validationResult.err.message, + validationResult.error.message, { parameter: this, stream } ); } items.push(stream.readItem()); } } - return CommandResult.Ok(items); + return Ok(items); } } @@ -263,24 +273,24 @@ class KeywordParser { } - private readKeywordAssociatedProperty(keyword: KeywordPropertyDescription, itemStream: IArgumentStream): CommandResult { + private readKeywordAssociatedProperty(keyword: KeywordPropertyDescription, itemStream: IArgumentStream): ActionResult { if (itemStream.peekItem() !== undefined && !(itemStream.peekItem() instanceof Keyword)) { const validationResult = keyword.acceptor.validator(itemStream.peekItem()); - if (validationResult.isOk()) { - return CommandResult.Ok(itemStream.readItem()); + if (validationResult.isOkay) { + return Ok(itemStream.readItem()); } else { - return ArgumentParseError.Result(validationResult.err.message, { parameter: keyword, stream: itemStream }); + 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 CommandResult.Ok(true); + return Ok(true); } } } - public parseKeywords(itemStream: IArgumentStream): CommandResult { + 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]; @@ -298,7 +308,7 @@ class KeywordParser { } } else { const associatedPropertyResult = this.readKeywordAssociatedProperty(description, itemStream); - if (associatedPropertyResult.isErr()) { + if (isError(associatedPropertyResult)) { return associatedPropertyResult; } else { this.arguments.set(description.name, associatedPropertyResult.ok); @@ -306,21 +316,21 @@ class KeywordParser { } } - return CommandResult.Ok(this); + return Ok(this); } - public async parseRest(stream: IArgumentStream, shouldPromptForRest = false, restDescription?: RestDescription): Promise> { + 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 (result.isErr()) { - return CommandResult.Err(result.err); + if (isError(result)) { + return result; } if (stream.peekItem() !== undefined) { - return CommandError.Result(`There is an unexpected non-keyword argument ${JSON.stringify(stream.peekItem())}`); + return ActionError.Result(`There is an unexpected non-keyword argument ${JSON.stringify(stream.peekItem())}`); } else { - return CommandResult.Ok(undefined); + return Ok(undefined); } } } @@ -347,11 +357,11 @@ export interface ParameterDescription { prompt?: Prompt, } -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 CommandResult that accepts a ParamterDescription, and can render what's wrong (e.g. missing parameter). +// 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 { @@ -377,23 +387,25 @@ class ArgumentListParser implements IArgumentListParser { ) { } - public async parse(stream: IArgumentStream): Promise> { + public async parse(stream: IArgumentStream): Promise> { let 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 (keywordResult.isErr()) { - return CommandResult.Err(keywordResult.err); + if (isError(keywordResult)) { + return keywordResult; } if (stream.peekItem() === undefined) { if (parameter.prompt && stream.isPromptable()) { - const promptResult = await stream.prompt(parameter); - if (promptResult.isErr()) { - return promptResult as any; - } - hasPrompted = true; + 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.`, @@ -402,20 +414,20 @@ class ArgumentListParser implements IArgumentListParser { } } const result = parameter.acceptor.validator(stream.peekItem()); - if (result.isErr()) { - return ArgumentParseError.Result(result.err.message, { parameter, stream }); + if (isError(result)) { + return ArgumentParseError.Result(result.error.message, { parameter, stream }); } stream.readItem(); } const restResult = await keywordsParser.parseRest(stream, hasPrompted, this.rest); - if (restResult.isErr()) { - return CommandResult.Err(restResult.err); + if (isError(restResult)) { + return restResult; } const immediateArguments = restResult.ok === undefined || restResult.ok.length === 0 ? stream.source : stream.source.slice(0, stream.source.indexOf(restResult.ok[0])) - return CommandResult.Ok({ + return Ok({ immediateArguments: immediateArguments, keywords: keywordsParser.getKeywords(), rest: restResult.ok @@ -423,15 +435,15 @@ class ArgumentListParser implements IArgumentListParser { } } -export class AbstractArgumentParseError extends CommandError { +export class AbstractArgumentParseError extends ActionError { constructor( public readonly stream: IArgumentStream, message: string) { super(message) } - public static Result(message: string, options: { stream: IArgumentStream }): CommandResult { - return CommandResult.Err(new AbstractArgumentParseError(options.stream, message)); + public static Result(message: string, options: { stream: IArgumentStream }): ActionResult { + return ResultError(new AbstractArgumentParseError(options.stream, message)); } } @@ -443,14 +455,14 @@ export class ArgumentParseError extends AbstractArgumentParseError { super(stream, message) } - public static Result(message: string, options: { parameter: ParameterDescription, stream: IArgumentStream }): CommandResult { - return CommandResult.Err(new ArgumentParseError(options.parameter, options.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 }): CommandResult { - return CommandResult.Err(new UnexpectedArgumentError(options.stream, message)); + public static Result(message: string, options: { stream: IArgumentStream }): ActionResult { + return ResultError(new UnexpectedArgumentError(options.stream, message)); } } @@ -464,10 +476,10 @@ export function union(...presentationTypes: PresentationType[]): PresentationTyp return { name, validator: (readItem: ReadItem) => { - if (presentationTypes.some(p => p.validator(readItem).isOk())) { - return CommandResult.Ok(true); + if (presentationTypes.some(p => p.validator(readItem).isOkay)) { + return Ok(true); } else { - return CommandError.Result(`Read item didn't match any of the presentaiton types ${name}`); + return ActionError.Result(`Read item didn't match any of the presentaiton types ${name}`); } } } diff --git a/src/commands/interface-manager/Permalinks.ts b/src/commands/interface-manager/Permalinks.ts deleted file mode 100644 index 152f4d14..00000000 --- a/src/commands/interface-manager/Permalinks.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Copyright (C) 2023 Gnuxie - * All rights reserved. - * - * This file is only really here while we wait for - * https://github.com/turt2live/matrix-bot-sdk/pull/300 - * to be merged and be used by matrix-appservice-bridge too. - * - * This file incorperates work from matrix-bot-sdk - * https://github.com/turt2live/matrix-bot-sdk - * which included the following license notice: -MIT License - -Copyright (c) 2018 - 2022 Travis Ralston - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - */ - -/** - * The parts of a permalink. - * @see Permalinks - * @category Utilities - */ -export interface PermalinkParts { - /** - * The room ID or alias the permalink references. May be undefined. - */ - roomIdOrAlias?: string; - - /** - * The user ID the permalink references. May be undefined. - */ - userId?: string; - - /** - * The event ID the permalink references. May be undefined. - */ - eventId?: string; - - /** - * The servers the permalink is routed through. - */ - viaServers: string[]; -} - -/** - * Functions for handling permalinks - * @category Utilities - */ -export class Permalinks { - private constructor() { - } - - private static encodeViaArgs(servers: string[]): string { - if (!servers || !servers.length) return ""; - - return `?via=${servers.join("&via=")}`; - } - - /** - * Creates a room permalink. - * @param {string} roomIdOrAlias The room ID or alias to create a permalink for. - * @param {string[]} viaServers The servers to route the permalink through. - * @returns {string} A room permalink. - */ - public static forRoom(roomIdOrAlias: string, viaServers: string[] = []): string { - return `https://matrix.to/#/${encodeURIComponent(roomIdOrAlias)}${Permalinks.encodeViaArgs(viaServers)}`; - } - - /** - * Creates a user permalink. - * @param {string} userId The user ID to create a permalink for. - * @returns {string} A user permalink. - */ - public static forUser(userId: string): string { - return `https://matrix.to/#/${encodeURIComponent(userId)}`; - } - - /** - * Creates an event permalink. - * @param {string} roomIdOrAlias The room ID or alias to create a permalink in. - * @param {string} eventId The event ID to reference in the permalink. - * @param {string[]} viaServers The servers to route the permalink through. - * @returns {string} An event permalink. - */ - public static forEvent(roomIdOrAlias: string, eventId: string, viaServers: string[] = []): string { - return `https://matrix.to/#/${encodeURIComponent(roomIdOrAlias)}/${encodeURIComponent(eventId)}${Permalinks.encodeViaArgs(viaServers)}`; - } - - /** - * Parses a permalink URL into usable parts. - * @param {string} matrixTo The matrix.to URL to parse. - * @returns {PermalinkParts} The parts of the permalink. - */ - public static parseUrl(matrixTo: string): PermalinkParts { - const matrixToRegexp = /^https:\/\/matrix\.to\/#\/(?[^/?]+)\/?(?[^?]+)?(?\?[^]*)?$/; - - const url = matrixToRegexp.exec(matrixTo)?.groups; - if (!url) { - throw new Error("Not a valid matrix.to URL"); - } - - const entity = decodeURIComponent(url.entity); - if (entity[0] === '@') { - return { userId: entity, roomIdOrAlias: undefined, eventId: undefined, viaServers: [] }; - } else if (entity[0] === '#' || entity[0] === '!') { - return { - userId: undefined, - roomIdOrAlias: entity, - eventId: url.eventId && decodeURIComponent(url.eventId), - viaServers: new URLSearchParams(url.query).getAll('via'), - }; - } else { - throw new Error("Unexpected entity"); - } - } -} diff --git a/src/commands/interface-manager/PromptForAccept.ts b/src/commands/interface-manager/PromptForAccept.ts index c87e0374..b9bdcf7a 100644 --- a/src/commands/interface-manager/PromptForAccept.ts +++ b/src/commands/interface-manager/PromptForAccept.ts @@ -4,9 +4,7 @@ */ import { ReadItem } from "./CommandReader"; -import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; -import { ArgumentStream, ParameterDescription } from "./ParameterParsing"; -import { CommandResult } from "./Validation"; +import { ArgumentStream } from "./ParameterParsing"; export interface PromptOptions { readonly suggestions: PresentationType[] @@ -19,18 +17,12 @@ export interface PromptOptions { */ export interface InterfaceAcceptor { readonly isPromptable: boolean - promptForAccept(parameter: ParameterDescription, invocationRecord: CommandInvocationRecord): Promise> -} - -export interface CommandInvocationRecord { - readonly command: InterfaceCommand, } export class PromptableArgumentStream extends ArgumentStream { constructor( source: ReadItem[], private readonly interfaceAcceptor: InterfaceAcceptor, - private readonly invocationRecord: CommandInvocationRecord, start = 0, ) { super([...source], start); @@ -42,15 +34,4 @@ export class PromptableArgumentStream extends ArgumentStream { public isPromptable(): boolean { return this.interfaceAcceptor.isPromptable } - - public async prompt(parameterDescription: ParameterDescription): Promise> { - const result = await this.interfaceAcceptor.promptForAccept( - parameterDescription, - this.invocationRecord - ); - if (result.isOk()) { - this.source.push(result.ok); - } - return result; - } } diff --git a/src/commands/interface-manager/PromptRequiredError.ts b/src/commands/interface-manager/PromptRequiredError.ts new file mode 100644 index 00000000..fea7fb56 --- /dev/null +++ b/src/commands/interface-manager/PromptRequiredError.ts @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2023-2024 Gnuxie + * All rights reserved. + */ + +import { ActionError, ActionResult, ResultError } from "matrix-protection-suite"; +import { IArgumentStream, ParameterDescription } from "./ParameterParsing"; +import { ReadItem } from "./CommandReader"; + +export interface PromptContext { + 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); + } + + public static Result( + message: string, + { promptParameter, stream }: { promptParameter: ParameterDescription, stream: IArgumentStream } + ): ActionResult { + return ResultError(new PromptRequiredError(message, [], promptParameter, stream.priorItems())); + } +} diff --git a/src/commands/interface-manager/Validation.ts b/src/commands/interface-manager/Validation.ts deleted file mode 100644 index 3d17bd33..00000000 --- a/src/commands/interface-manager/Validation.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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. - */ - -type ValidationMatchExpression = { ok?: (ok: Ok) => any, err?: (err: Err) => any}; - -const noValue = Symbol('noValue'); - -/** - * This is a utility specifically for validating user input, and reporting - * what was wrong back to the end user in a way that makes sense. - * We are trying to tell the user they did something wrong and what that is. - * This is something completely different to a normal exception, - * where we are saying to ourselves that our assumptions in our code about - * the thing we're doing are completely wrong. The user never - * should see exceptions as there is nothing they can do about it. - * - * TO be clear this is only used when the user has done something wrong - * and we need to communicate that. It is not for any other situation. - */ -export class CommandResult { - private constructor( - private readonly okValue: Ok|typeof noValue, - private readonly errValue: Err|typeof noValue, - ) { - - } - public static Ok(value: Ok): CommandResult { - return new CommandResult(value, noValue); - } - - public static Err(value: Err): CommandResult { - return new CommandResult(noValue, value); - } - - public async match(expression: ValidationMatchExpression) { - return this.okValue ? await expression.ok!(this.ok) : await expression.err!(this.err); - } - - public isOk(): boolean { - return this.okValue !== noValue; - } - - public isErr(): boolean { - return this.errValue !== noValue; - } - - public get ok(): Ok { - if (this.isOk()) { - return this.okValue as Ok; - } else { - throw new TypeError("You did not check isOk before accessing ok"); - } - } - - public get err(): Err { - if (this.isErr()) { - return this.errValue as Err; - } else { - throw new TypeError("You did not check isErr before accessing err"); - } - } -} - -export class CommandError { - public constructor( - public readonly message: string, - ) { - // nothing to do. - } - - /** - * Utility to wrap the error into a Result. - * @param message The message for the CommandError. - * @param _options This exists so that the method is extensible by subclasses. Otherwise they wouldn't be able to pass other constructor arguments through this method. - * @returns A CommandResult with a CommandError nested within. - */ - public static Result(message: string, _options = {}): CommandResult { - return CommandResult.Err(new CommandError(message)); - } -} diff --git a/src/config.ts b/src/config.ts index cc139750..272557a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,10 +125,12 @@ export interface IConfig { abuseReporting: { enabled: boolean; } - ruleServer?: { - 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; @@ -209,9 +211,9 @@ const defaultConfig: IConfig = { abuseReporting: { enabled: false, }, - ruleServer: { - enabled: false, - }, + }, + roomStateBackingStore: { + enabled: false, }, experimentalRustCrypto: false, diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts new file mode 100644 index 00000000..26422df2 --- /dev/null +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -0,0 +1,60 @@ +/** + * Copyright (C) 2023-2024 Gnuxie + * All rights reserved. + */ + +import { ActionResult, ClientsInRoomMap, MatrixRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +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 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 protectedRoomsSet = await makeProtectedRoomsSet( + managementRoom, + roomStateManager, + policyRoomManager, + roomMembershipManager, + client, + clientPlatform, + clientUserID + ); + if (isError(protectedRoomsSet)) { + return protectedRoomsSet; + } + return await Draupnir.makeDraupnirBot( + client, + clientUserID, + clientPlatform, + managementRoom, + clientRooms.ok, + protectedRoomsSet.ok, + roomStateManager, + policyRoomManager, + roomMembershipManager, + config + ); + } +} diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts new file mode 100644 index 00000000..c8ab55ed --- /dev/null +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -0,0 +1,159 @@ +/** + * Copyright (C) 2022-2023 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { ActionResult, ClientPlatform, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MatrixRoomID, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsConfig, RoomJoiner, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, 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'; + +async function makePolicyListConfig( + 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; +} + +async function makeProtectedRoomsConfig( + client: MatrixSendClient, + roomJoiner: RoomJoiner, +): Promise> { + return await MjolnirProtectedRoomsConfig.createFromStore( + new BotSDKMatrixAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MjolnirProtectedRoomsEvent, + client + ), + roomJoiner + ); +} + +async function makeSetMembership( + roomMembershipManager: RoomMembershipManager, + protectedRoomsConfig: ProtectedRoomsConfig +): Promise> { + return await StandardSetMembership.create( + roomMembershipManager, + protectedRoomsConfig + ); +} + +async function makeSetRoomState( + roomStateManager: RoomStateManager, + protectedRoomsConfig: ProtectedRoomsConfig +): Promise> { + return await StandardSetRoomState.create( + roomStateManager, + protectedRoomsConfig + ); +} + +async function makeProtectionConfig( + client: MatrixSendClient, + roomStateManager: RoomStateManager, + managementRoom: MatrixRoomID +): Promise> { + const result = await roomStateManager.getRoomStateRevisionIssuer( + managementRoom + ); + if (isError(result)) { + return result; + } + return Ok(new MjolnirProtectionsConfig( + new BotSDKMatrixAccountData( + MjolnirEnabledProtectionsEventType, + MjolnirEnabledProtectionsEvent, + client + ), + new BotSDKMatrixStateData( + MjolnirProtectionSettingsEventType, + result.ok, + client + ), + DefaultEnabledProtectionsMigration, + )); +} + + +export async function makeProtectedRoomsSet( + managementRoom: MatrixRoomID, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager, + client: MatrixSendClient, + clientPlatform: ClientPlatform, + userID: StringUserID +): Promise> { + const protectedRoomsConfig = await makeProtectedRoomsConfig(client, clientPlatform.toRoomJoiner()) + if (isError(protectedRoomsConfig)) { + return protectedRoomsConfig; + } + const setRoomState = await makeSetRoomState( + roomStateManager, + protectedRoomsConfig.ok + ); + if (isError(setRoomState)) { + return setRoomState; + } + const membershipSet = await makeSetMembership( + roomMembershipManager, + protectedRoomsConfig.ok + ); + if (isError(membershipSet)) { + return membershipSet; + } + const policyListConfig = await makePolicyListConfig(client, policyRoomManager, clientPlatform.toRoomJoiner()); + if (isError(policyListConfig)) { + return policyListConfig; + } + const protectionsConfig = await makeProtectionConfig( + client, + roomStateManager, + managementRoom + ); + if (isError(protectionsConfig)) { + return protectionsConfig; + } + const protectedRoomsSet = new StandardProtectedRoomsSet( + policyListConfig.ok, + protectedRoomsConfig.ok, + protectionsConfig.ok, + membershipSet.ok, + setRoomState.ok, + userID, + ); + return Ok(protectedRoomsSet); +} diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts new file mode 100644 index 00000000..d1418a60 --- /dev/null +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -0,0 +1,140 @@ +/** + * Copyright (C) 2022-2024 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { ActionError, ActionResult, MatrixRoomID, StringUserID, 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. + } + + 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)); + } + + 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}`); + } + 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. + } +} + +export enum DraupnirFailType { + Unauthorized = "Unauthorized", + StartError = "StartError", + InitializationError = "InitializationError", +} diff --git a/src/index.ts b/src/index.ts index 607b8221..e769f755 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,8 +40,13 @@ import { } from "matrix-bot-sdk"; import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { read as configRead } from "./config"; -import { Mjolnir } from "./Mjolnir"; import { initializeSentry, patchMatrixClient } from "./utils"; +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"; (async function () { @@ -64,7 +69,8 @@ import { initializeSentry, patchMatrixClient } from "./utils"; healthz.listen(); } - let bot: Mjolnir | null = null; + 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")); @@ -85,17 +91,23 @@ import { initializeSentry, patchMatrixClient } from "./utils"; } patchMatrixClient(); config.RUNTIME.client = client; - - bot = await Mjolnir.setupMjolnirFromConfig(client, client, config); + 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/AccessControlUnit.ts b/src/models/AccessControlUnit.ts deleted file mode 100644 index abb18a2c..00000000 --- a/src/models/AccessControlUnit.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 PolicyList, { ChangeType, ListRuleChange } from "./PolicyList"; -import { EntityType, ListRule, Recommendation, RULE_SERVER, RULE_USER } from "./ListRule"; -import { LogService, UserID } from "matrix-bot-sdk"; -import { ServerAcl } from "./ServerAcl"; - -/** - * The ListRuleCache is a cache for all the rules in a set of lists for a specific entity type and recommendation. - * The cache can then be used to quickly test against all the rules for that specific entity/recommendation. - * E.g. The cache can be used for all the m.ban rules for users in a set of lists to conveniently test members of a room. - * While some effort has been made to optimize the testing of entities, the main purpose of this class is to stop - * ad-hoc destructuring of policy lists to test rules against entities. - * - * Note: This cache should not be used to unban or introspect about the state of `PolicyLists`, for this - * see `PolicyList.unban` and `PolicyList.rulesMatchingEntity`, as these will make sure to account - * for unnormalized entity types. - */ -class ListRuleCache { - /** - * Glob rules always have to be scanned against every entity. - */ - private readonly globRules: Map = new Map(); - /** - * This table allows us to skip matching an entity against every literal. - */ - private readonly literalRules: Map = new Map(); - private readonly listUpdateListener: ((list: PolicyList, changes: ListRuleChange[]) => void); - - constructor( - /** - * The entity type that this cache is for e.g. RULE_USER. - */ - public readonly entityType: EntityType, - /** - * The recommendation that this cache is for e.g. m.ban (RECOMMENDATION_BAN). - */ - public readonly recommendation: Recommendation, - ) { - this.listUpdateListener = (list: PolicyList, changes: ListRuleChange[]) => this.updateCache(changes); - } - - /** - * Test the entitiy for the first matching rule out of all the watched lists. - * @param entity e.g. an mxid for a user, the server name for a server. - * @returns A single `ListRule` matching the entity. - */ - public getAnyRuleForEntity(entity: string): ListRule|null { - const literalRule = this.literalRules.get(entity); - if (literalRule !== undefined) { - return literalRule[0]; - } - for (const rule of this.globRules.values()) { - if (rule[0].isMatch(entity)) { - return rule[0]; - } - } - return null; - } - - /** - * Watch a list and add all its rules (and future rules) to the cache. - * Will automatically update with the list. - * @param list A PolicyList. - */ - public watchList(list: PolicyList): void { - list.on('PolicyList.update', this.listUpdateListener); - const rules = list.rulesOfKind(this.entityType, this.recommendation); - rules.forEach(this.internRule, this); - } - - /** - * Unwatch a list and remove all of its rules from the cache. - * Will stop updating the cache from this list. - * @param list A PolicyList. - */ - public unwatchList(list: PolicyList): void { - list.removeListener('PolicyList.update', this.listUpdateListener); - const rules = list.rulesOfKind(this.entityType, this.recommendation); - rules.forEach(this.uninternRule, this); - } - - /** - * @returns True when there are no rules in the cache. - */ - public isEmpty(): boolean { - return this.globRules.size + this.literalRules.size === 0; - } - - /** - * Returns all the rules in the cache, without duplicates from different lists. - */ - public get allRules(): ListRule[] { - return [...this.literalRules.values(), ...this.globRules.values()].map(rules => rules[0]); - } - - /** - * Remove a rule from the cache as it is now invalid. e.g. it was removed from a policy list. - * @param rule The rule to remove. - */ - private uninternRule(rule: ListRule) { - /** - * Remove a rule from the map, there may be rules from different lists in the cache. - * We don't want to invalidate those. - * @param map A map of entities to rules. - */ - const removeRuleFromMap = (map: Map) => { - const entry = map.get(rule.entity); - if (entry !== undefined) { - const newEntry = entry.filter(internedRule => internedRule.sourceEvent.event_id !== rule.sourceEvent.event_id); - if (newEntry.length === 0) { - map.delete(rule.entity); - } else { - map.set(rule.entity, newEntry); - } - } - }; - if (rule.isGlob()) { - removeRuleFromMap(this.globRules); - } else { - removeRuleFromMap(this.literalRules); - } - } - - /** - * Add a rule to the cache e.g. it was added to a policy list. - * @param rule The rule to add. - */ - private internRule(rule: ListRule) { - /** - * Add a rule to the map, there might be duplicates of this rule in other lists. - * @param map A map of entities to rules. - */ - const addRuleToMap = (map: Map) => { - const entry = map.get(rule.entity); - if (entry !== undefined) { - entry.push(rule); - } else { - map.set(rule.entity, [rule]); - } - } - if (rule.isGlob()) { - addRuleToMap(this.globRules); - } else { - addRuleToMap(this.literalRules); - } - } - - /** - * Update the cache for a single `ListRuleChange`. - * @param change The change made to a rule that was present in the policy list. - */ - private updateCacheForChange(change: ListRuleChange): void { - if (change.rule.kind !== this.entityType || change.rule.recommendation !== this.recommendation) { - return; - } - switch (change.changeType) { - case ChangeType.Added: - case ChangeType.Modified: - this.internRule(change.rule); - break; - case ChangeType.Removed: - this.uninternRule(change.rule); - break; - default: - throw new TypeError(`Uknown ListRule change type: ${change.changeType}`); - } - } - - /** - * Update the cache for a change in a policy list. - * @param changes The changes that were made to list rules since the last update to this policy list. - */ - private updateCache(changes: ListRuleChange[]) { - changes.forEach(this.updateCacheForChange, this); - } -} - -export enum Access { - /// The entity was explicitly banned by a policy list. - Banned, - /// The entity did not match any allow rule. - NotAllowed, - /// The user was allowed and didn't match any ban. - Allowed, -} - -/** - * A description of the access an entity has. - * If the access is `Banned`, then a single rule that bans the entity will be included. - */ -export interface EntityAccess { - readonly outcome: Access, - readonly rule?: ListRule, -} - -/** - * This allows us to work out the access an entity has to some thing based on a set of watched/unwatched lists. - */ -export default class AccessControlUnit { - private readonly userBans = new ListRuleCache(RULE_USER, Recommendation.Ban); - private readonly serverBans = new ListRuleCache(RULE_SERVER, Recommendation.Ban); - private readonly userAllows = new ListRuleCache(RULE_USER, Recommendation.Allow); - private readonly serverAllows = new ListRuleCache(RULE_SERVER, Recommendation.Allow); - private readonly caches = [this.userBans, this.serverBans, this.userAllows, this.serverAllows] - - constructor(policyLists: PolicyList[]) { - policyLists.forEach(this.watchList, this); - } - - public watchList(list: PolicyList) { - for (const cache of this.caches) { - cache.watchList(list); - } - } - - public unwatchList(list: PolicyList) { - for (const cache of this.caches) { - cache.unwatchList(list); - } - } - - /** - * Test whether the server is allowed by the ACL unit. - * @param domain The server name to test. - * @returns A description of the access that the server has. - */ - public getAccessForServer(domain: string): EntityAccess { - return this.getAccessForEntity(domain, this.serverAllows, this.serverBans); - } - - /** - * Get the level of access the user has for the ACL unit. - * @param mxid The user id to test. - * @param policy Whether to check the server part of the user id against server rules. - * @returns A description of the access that the user has. - */ - public getAccessForUser(mxid: string, policy: "CHECK_SERVER" | "IGNORE_SERVER"): EntityAccess { - const userAccess = this.getAccessForEntity(mxid, this.userAllows, this.userBans); - if (userAccess.outcome === Access.Allowed) { - if (policy === "IGNORE_SERVER") { - return userAccess; - } else { - const userId = new UserID(mxid); - return this.getAccessForServer(userId.domain); - } - } else { - return userAccess; - } - } - - private getAccessForEntity(entity: string, allowCache: ListRuleCache, bannedCache: ListRuleCache): EntityAccess { - // Check if the entity is explicitly allowed. - // We have to infer that a rule exists for '*' if the allowCache is empty, otherwise you brick the ACL. - const allowRule = allowCache.getAnyRuleForEntity(entity); - if (allowRule === null && !allowCache.isEmpty()) { - return { outcome: Access.NotAllowed } - } - // Now check if the entity is banned. - const banRule = bannedCache.getAnyRuleForEntity(entity); - if (banRule !== null) { - return { outcome: Access.Banned, rule: banRule }; - } - // If they got to this point, they're allowed!! - return { outcome: Access.Allowed }; - } - - /** - * Create a ServerAcl instance from the rules contained in this unit. - * @param serverName The name of the server that you are operating from, used to ensure you cannot brick yourself. - * @returns A new `ServerAcl` instance with deny and allow entries created from the rules in this unit. - */ - public compileServerAcl(serverName: string): ServerAcl { - const acl = new ServerAcl(serverName).denyIpAddresses(); - const allowedServers = this.serverAllows.allRules; - // Allowed servers (allow). - if (allowedServers.length === 0) { - acl.allowServer('*'); - } else { - for (const rule of allowedServers) { - acl.allowServer(rule.entity); - } - if (this.getAccessForServer(serverName).outcome === Access.NotAllowed) { - acl.allowServer(serverName); - LogService.warn('AccessControlUnit', `The server ${serverName} we are operating from was not on the allowed when constructing the server ACL, so it will be injected it into the server acl. Please check the ACL lists.`) - } - } - // Banned servers (deny). - for (const rule of this.serverBans.allRules) { - if (rule.isMatch(serverName)) { - LogService.warn('AccessControlUnit', `The server ${serverName} we are operating from was found to be banned by ${rule.entity} by a rule from the event: ${rule.sourceEvent.event_id}, ` - + 'while constructing a server acl. Ignoring the rule. Please check the ACL lists.' - ); - } else { - acl.denyServer(rule.entity); - } - } - return acl; - } -} diff --git a/src/models/ListRule.ts b/src/models/ListRule.ts deleted file mode 100644 index bc7a9362..00000000 --- a/src/models/ListRule.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { MatrixGlob } from "matrix-bot-sdk"; - -export enum EntityType { - /// `entity` is to be parsed as a glob of users IDs - RULE_USER = "m.policy.rule.user", - - /// `entity` is to be parsed as a glob of room IDs/aliases - RULE_ROOM = "m.policy.rule.room", - - /// `entity` is to be parsed as a glob of server names - RULE_SERVER = "m.policy.rule.server", -} - -export const RULE_USER = EntityType.RULE_USER; -export const RULE_ROOM = EntityType.RULE_ROOM; -export const RULE_SERVER = EntityType.RULE_SERVER; - -// README! The order here matters for determining whether a type is obsolete, most recent should be first. -// These are the current and historical types for each type of rule which were used while MSC2313 was being developed -// and were left as an artifact for some time afterwards. -// Most rules (as of writing) will have the prefix `m.room.rule.*` as this has been in use for roughly 2 years. -export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"]; -export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"]; -export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"]; -export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES]; - -export enum Recommendation { - /// The rule recommends a "ban". - /// - /// The actual semantics for this "ban" may vary, e.g. room ban, - /// server ban, ignore user, etc. To determine the semantics for - /// this "ban", clients need to take into account the context for - /// the list, e.g. how the rule was imported. - Ban = "m.ban", - - /// The rule specifies an "opinion", as a number in [-100, +100], - /// where -100 represents a user who is considered absolutely toxic - /// by whoever issued this ListRule and +100 represents a user who - /// is considered absolutely absolutely perfect by whoever issued - /// this ListRule. - Opinion = "org.matrix.msc3845.opinion", - - /** - * This is a rule that recommends allowing a user to participate. - * Used for the construction of allow lists. - */ - Allow = "org.matrix.mjolnir.allow", -} - -/** - * All variants of recommendation `m.ban` - */ -const RECOMMENDATION_BAN_VARIANTS = [ - // Stable - Recommendation.Ban, - // Unstable prefix, for compatibility. - "org.matrix.mjolnir.ban" -]; - -/** - * All variants of recommendation `m.opinion` - */ -const RECOMMENDATION_OPINION_VARIANTS: string[] = [ - // Unstable - Recommendation.Opinion -]; - -const RECOMMENDATION_ALLOW_VARIANTS: string[] = [ - // Unstable - Recommendation.Allow -] - -export const OPINION_MIN = -100; -export const OPINION_MAX = +100; - -interface MatrixStateEvent { - type: string, - content: any, - event_id: string, - state_key: string, -} - -/** - * Representation of a rule within a Policy List. - */ -export abstract class ListRule { - /** - * A glob for `entity`. - */ - private glob: MatrixGlob; - constructor( - /** - * The event source for the rule. - */ - public readonly sourceEvent: MatrixStateEvent, - /** - * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. - */ - public readonly entity: string, - /** - * A human-readable reason for this rule, for audit purposes. - */ - public readonly reason: string, - /** - * The type of entity for this rule, e.g. user, server domain, etc. - */ - public readonly kind: EntityType, - /** - * The recommendation for this rule, e.g. "ban" or "opinion", or `null` - * if the recommendation is one that Mjölnir doesn't understand. - */ - public readonly recommendation: Recommendation | null) { - this.glob = new MatrixGlob(entity); - } - - /** - * Determine whether this rule should apply to a given entity. - */ - public isMatch(entity: string): boolean { - return this.glob.test(entity); - } - - /** - * @returns Whether the entity in he rule represents a Matrix glob (and not a literal). - */ - public isGlob(): boolean { - return /[*?]/.test(this.entity); - } - - /** - * Validate and parse an event into a ListRule. - * - * @param event An *untrusted* event. - * @returns null if the ListRule is invalid or not recognized by Mjölnir. - */ - public static parse(event: MatrixStateEvent): ListRule | null { - // Parse common fields. - // If a field is ill-formed, discard the rule. - const content = event['content']; - if (!content || typeof content !== "object") { - return null; - } - const entity = content['entity']; - if (!entity || typeof entity !== "string") { - return null; - } - const recommendation = content['recommendation']; - if (!recommendation || typeof recommendation !== "string") { - return null; - } - - const reason = content['reason'] || ''; - if (typeof reason !== "string") { - return null; - } - - let type = event['type']; - let kind; - if (USER_RULE_TYPES.includes(type)) { - kind = EntityType.RULE_USER; - } else if (ROOM_RULE_TYPES.includes(type)) { - kind = EntityType.RULE_ROOM; - } else if (SERVER_RULE_TYPES.includes(type)) { - kind = EntityType.RULE_SERVER; - } else { - return null; - } - - // From this point, we may need specific fields. - if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) { - return new ListRuleBan(event, entity, reason, kind); - } else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) { - let opinion = content['opinion']; - if (!Number.isInteger(opinion)) { - return null; - } - return new ListRuleOpinion(event, entity, reason, kind, opinion); - } else if (RECOMMENDATION_ALLOW_VARIANTS.includes(recommendation)) { - return new ListRuleAllow(event, entity, reason, kind); - } else { - // As long as the `recommendation` is defined, we assume - // that the rule is correct, just unknown. - return new ListRuleUnknown(event, entity, reason, kind, content); - } - } -} - -/** - * A rule representing a "ban". - */ -export class ListRuleBan extends ListRule { - constructor( - /** - * The event source for the rule. - */ - sourceEvent: MatrixStateEvent, - /** - * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. - */ - entity: string, - /** - * A human-readable reason for this rule, for audit purposes. - */ - reason: string, - /** - * The type of entity for this rule, e.g. user, server domain, etc. - */ - kind: EntityType, - ) { - super(sourceEvent, entity, reason, kind, Recommendation.Ban) - } -} - -/** - * A rule representing an "allow". - */ -export class ListRuleAllow extends ListRule { - constructor( - /** - * The event source for the rule. - */ - sourceEvent: MatrixStateEvent, - /** - * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. - */ - entity: string, - /** - * A human-readable reason for this rule, for audit purposes. - */ - reason: string, - /** - * The type of entity for this rule, e.g. user, server domain, etc. - */ - kind: EntityType, - ) { - super(sourceEvent, entity, reason, kind, Recommendation.Allow) - } -} - -/** - * A rule representing an "opinion" - */ -export class ListRuleOpinion extends ListRule { - constructor( - /** - * The event source for the rule. - */ - sourceEvent: MatrixStateEvent, - /** - * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. - */ - entity: string, - /** - * A human-readable reason for this rule, for audit purposes. - */ - reason: string, - /** - * The type of entity for this rule, e.g. user, server domain, etc. - */ - kind: EntityType, - /** - * A number in [-100, +100] where -100 represents the worst possible opinion - * on the entity (e.g. toxic user or community) and +100 represents the best - * possible opinion on the entity (e.g. pillar of the community). - */ - public readonly opinion: number - ) { - super(sourceEvent, entity, reason, kind, Recommendation.Opinion); - if (!Number.isInteger(opinion)) { - throw new TypeError(`The opinion must be an integer, got ${opinion}`); - } - if (opinion < OPINION_MIN || opinion > OPINION_MAX) { - throw new TypeError(`The opinion must be within [-100, +100], got ${opinion}`); - } - } -} - -/** - * Any list rule that we do not understand. - */ -export class ListRuleUnknown extends ListRule { - constructor( - /** - * The event source for the rule. - */ - sourceEvent: MatrixStateEvent, - /** - * The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain. - */ - entity: string, - /** - * A human-readable reason for this rule, for audit purposes. - */ - reason: string, - /** - * The type of entity for this rule, e.g. user, server domain, etc. - */ - kind: EntityType, - /** - * The event used to create the rule. - */ - public readonly content: any, - ) { - super(sourceEvent, entity, reason, kind, null); - } -} diff --git a/src/models/MatrixDataManager.ts b/src/models/MatrixDataManager.ts deleted file mode 100644 index 6b7058c1..00000000 --- a/src/models/MatrixDataManager.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (C) 2023 Gnuxie - */ - -export const SCHEMA_VERSION_KEY = 'ge.applied-langua.ge.draupnir.schema_version'; - -export type RawSchemedData = object & Record; -export type SchemaMigration = (input: RawSchemedData) => Promise; - -export abstract class MatrixDataManager { - - protected abstract schema: SchemaMigration[]; - protected abstract isAllowedToInferNoVersionAsZero: boolean; - protected abstract requestMatrixData(): Promise - protected abstract storeMatixData(data: Format): Promise; - protected abstract createFirstData(): Promise; - - protected async migrateData(rawData: RawSchemedData): Promise { - const startingVersion = rawData[SCHEMA_VERSION_KEY] as number; - // Rememeber, version 0 has no migrations - if (this.schema.length < startingVersion) { - throw new TypeError(`Encountered a version that we do not have migrations for ${startingVersion}`); - } else if (this.schema.length === startingVersion) { - return rawData; - } else { - const applicableSchema = this.schema.slice(startingVersion); - const migratedData = await applicableSchema.reduce( - async (previousData: Promise, schema: SchemaMigration) => { - return await schema(await previousData) - }, Promise.resolve(rawData) - ); - return migratedData; - } - } - - protected async loadData(): Promise { - const rawData = await this.requestMatrixData(); - if (rawData === undefined) { - return await this.createFirstData(); - } else if (typeof rawData !== 'object' || rawData === null) { - throw new TypeError("The data has been corrupted."); - } - - if (!(SCHEMA_VERSION_KEY in rawData) && this.isAllowedToInferNoVersionAsZero) { - (rawData as RawSchemedData)[SCHEMA_VERSION_KEY] = 0; - } - if (SCHEMA_VERSION_KEY in rawData - && Number.isInteger(rawData[SCHEMA_VERSION_KEY]) - ) { - // what if the schema migration is somehow incorrect and we are casting as Format? - return await this.migrateData(rawData) as Format; - } - throw new TypeError("The schema version or data has been corrupted") - } -} diff --git a/src/models/PolicyList.ts b/src/models/PolicyList.ts deleted file mode 100644 index e888c838..00000000 --- a/src/models/PolicyList.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { LogService, RoomCreateOptions, UserID } from "matrix-bot-sdk"; -import { EventEmitter } from "events"; -import { ALL_RULE_TYPES, EntityType, ListRule, Recommendation, ROOM_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./ListRule"; -import { MatrixSendClient } from "../MatrixEmitter"; -import AwaitLock from "await-lock"; -import { monotonicFactory } from "ulidx"; - -/** - * Account data event type used to store the permalinks to each of the policylists. - * - * Content: - * ```jsonc - * { - * references: string[], // Each entry is a `matrix.to` permalink. - * } - * ``` - */ -export const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists"; - -/** - * A prefix used to record that we have already warned at least once that a PolicyList room is unprotected. - */ -export const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for."; -export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode"; - -export enum ChangeType { - Added = "ADDED", - Removed = "REMOVED", - Modified = "MODIFIED" -} - -export interface ListRuleChange { - readonly changeType: ChangeType, - /** - * State event that caused the change. - * If the rule was redacted, this will be the redacted version of the event. - */ - readonly event: any, - /** - * The sender that caused the change. - * The original event sender unless the change is because `event` was redacted. When the change is `event` being redacted - * this will be the user who caused the redaction. - */ - readonly sender: string, - /** - * The current rule represented by the event. - * If the rule has been removed, then this will show what the rule was. - */ - readonly rule: ListRule, - /** - * The previous state that has been changed. Only (and always) provided when the change type is `ChangeType.Removed` or `Modified`. - * This will be a copy of the same event as `event` when a redaction has occurred and this will show its unredacted state. - */ - readonly previousState?: any, -} - -export declare interface PolicyList { - // PolicyList.update is emitted when the PolicyList has pulled new rules from Matrix and informs listeners of any changes. - on(event: 'PolicyList.update', listener: (list: PolicyList, changes: ListRuleChange[], revision: Revision) => void): this - emit(event: 'PolicyList.update', list: PolicyList, changes: ListRuleChange[], revision: Revision): boolean -} - -/** - * The PolicyList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc. - * This cannot be used to update events in the modeled room, it is a readonly model of the policy room. - * - * The policy list needs to be updated manually, it has no way of knowing about new events in it's modelled matrix room on its own. - * You can inform the PolicyList about new events in the matrix side of policy room with the `updateForEvent`, this will eventually - * cause the PolicyList to update its view of the room (via `updateList`) if it doesn't know about that state event. - * Each time the PolicyList has finished updating, it will emit the `'PolicyList.update'` event on itself as an EventEmitter. - * - * Implementation note: The reason why the PolicyList has to update via a call to `/state` is because - * you cannot rely on the timeline portion of `/sync` to provide a consistent view of the room state as you - * receive events in stream order. - */ -export class PolicyList extends EventEmitter { - private shortcode: string | null = null; - // A map of state events indexed first by state type and then state keys. - private state: Map> = new Map(); - /** - * Allow us to detect whether we have updated the state for this event. - */ - private stateByEventId: Map = new Map(); - // Batches new events from sync together before starting the process to update the list. - private readonly batcher: UpdateBatcher; - // Events that we have already informed the batcher about, that we haven't loaded from the room state yet. - private batchedEvents = new Set(); - - /** MSC3784 support. Please note that policy lists predate room types. So there will be lists in the wild without this type. */ - public static readonly ROOM_TYPE = "support.feline.policy.lists.msc.v1"; - public static readonly ROOM_TYPE_VARIANTS = [PolicyList.ROOM_TYPE] - - /** - * This is used to annotate state events we store with the rule they are associated with. - * If we refactor this, it is important to also refactor any listeners to 'PolicyList.update' - * which may assume `ListRule`s that are removed will be identital (Object.is) to when they were added. - * If you are adding new listeners, you should check the source event_id of the rule. - */ - private static readonly EVENT_RULE_ANNOTATION_KEY = 'org.matrix.mjolnir.annotation.rule'; - - /** - * An ID that represents the current version of the list state. - * Each time we use `updateList` we create a new revision to represent the change of state. - * Listeners can then use the revision to work out whether they have already applied - * the latest revision. - */ - private revisionId = new Revision(); - - /** - * A lock to protect `updateList` from a situation where one call to `getRoomState` can start and end before another. - */ - private readonly updateListLock = new AwaitLock(); - /** - * Construct a PolicyList, does not synchronize with the room. - * @param roomId The id of the policy room, i.e. a room containing MSC2313 policies. - * @param roomRef A sharable/clickable matrix URL that refers to the room. - * @param client A matrix client that is used to read the state of the room when `updateList` is called. - */ - constructor(public readonly roomId: string, public readonly roomRef: string, private client: MatrixSendClient) { - super(); - this.batcher = new UpdateBatcher(this); - } - - /** - * Create a new policy list. - * @param client A MatrixClient that will be used to create the list. - * @param shortcode A shortcode to refer to the list with. - * @param invite A list of users to invite to the list and make moderator. - * @param createRoomOptions Additional room create options such as an alias. - * @returns The room id for the newly created policy list. - */ - public static async createList( - client: MatrixSendClient, - shortcode: string, - invite: string[], - createRoomOptions: RoomCreateOptions = {} - ): Promise { - const powerLevels: { [key: string]: any } = { - "ban": 50, - "events": { - "m.room.name": 100, - "m.room.power_levels": 100, - }, - "events_default": 50, // non-default - "invite": 0, - "kick": 50, - "notifications": { - "room": 20, - }, - "redact": 50, - "state_default": 50, - "users": { - [await client.getUserId()]: 100, - ...invite.reduce((users, mxid) => ({...users, [mxid]: 50 }), {}), - }, - "users_default": 0, - }; - const finalRoomCreateOptions: RoomCreateOptions = { - // Support for MSC3784. - creation_content: { - type: PolicyList.ROOM_TYPE - }, - preset: "public_chat", - invite, - initial_state: [ - { - type: SHORTCODE_EVENT_TYPE, - state_key: "", - content: {shortcode: shortcode} - } - ], - power_level_content_override: powerLevels, - ...createRoomOptions - }; - // Guard room type in case someone overwrites it when declaring custom creation_content in future code. - const roomType = finalRoomCreateOptions.creation_content?.type; - if (typeof roomType !== 'string' || !PolicyList.ROOM_TYPE_VARIANTS.includes(roomType)) { - throw new TypeError(`Creating a policy room with a type other than the policy room type is not supported, you probably don't want to do this.`); - } - const listRoomId = await client.createRoom(finalRoomCreateOptions); - return listRoomId - } - - /** - * The code that can be used to refer to this banlist in Mjolnir commands. - */ - public get listShortcode(): string { - return this.shortcode || ''; - } - - /** - * Lookup the current rules cached for the list. - * @param stateType The event type e.g. m.policy.rule.user. - * @param stateKey The state key e.g. rule:@bad:matrix.org - * @returns A state event if present or null. - */ - private getState(stateType: string, stateKey: string) { - return this.state.get(stateType)?.get(stateKey); - } - - /** - * Store this state event as part of the active room state for this PolicyList (used to cache rules). - * The state type should be normalised if it is obsolete e.g. m.room.rule.user should be stored as m.policy.rule.user. - * @param stateType The event type e.g. m.room.policy.user. - * @param stateKey The state key e.g. rule:@bad:matrix.org - * @param event A state event to store. - */ - private setState(stateType: string, stateKey: string, event: any): void { - let typeTable = this.state.get(stateType); - if (typeTable) { - typeTable.set(stateKey, event); - } else { - this.state.set(stateType, new Map().set(stateKey, event)); - } - this.stateByEventId.set(event.event_id, event); - } - - /** - * Return all the active rules of a given kind. - * @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the PolicyList. - * @param recommendation A specific recommendation to filter for e.g. `m.ban`. Please remember recommendation varients are normalized. - * @returns The active ListRules for the ban list of that kind. - */ - public rulesOfKind(kind: string, recommendation?: Recommendation): ListRule[] { - const rules: ListRule[] = [] - const stateKeyMap = this.state.get(kind); - if (stateKeyMap) { - for (const event of stateKeyMap.values()) { - const rule = event[PolicyList.EVENT_RULE_ANNOTATION_KEY]; - if (rule && rule.kind === kind) { - if (recommendation === undefined) { - rules.push(rule); - } else if (rule.recommendation === recommendation) { - rules.push(rule); - } - } - } - } - return rules; - } - - public get serverRules(): ListRule[] { - return this.rulesOfKind(RULE_SERVER); - } - - public get userRules(): ListRule[] { - return this.rulesOfKind(RULE_USER); - } - - public get roomRules(): ListRule[] { - return this.rulesOfKind(RULE_ROOM); - } - - public get allRules(): ListRule[] { - return [...this.serverRules, ...this.userRules, ...this.roomRules]; - } - - /** - * Return all of the rules in this list that will match the provided entity. - * If the entity is a user, then we match the domain part against server rules too. - * @param ruleKind The type of rule for the entity e.g. `RULE_USER`. - * @param entity The entity to test e.g. the user id, server name or a room id. - * @returns All of the rules that match this entity. - */ - public rulesMatchingEntity(entity: string, ruleKind?: string): ListRule[] { - const ruleTypeOf: (entityPart: string) => string = (entityPart: string) => { - if (ruleKind) { - return ruleKind; - } else if (entityPart.startsWith("#") || entityPart.startsWith("#")) { - return RULE_ROOM; - } else if (entity.startsWith("@")) { - return RULE_USER; - } else { - return RULE_SERVER; - } - }; - - if (ruleTypeOf(entity) === RULE_USER) { - // We special case because want to see whether a server ban is preventing this user from participating too. - const userId = new UserID(entity); - return [ - ...this.userRules.filter(rule => rule.isMatch(entity)), - ...this.serverRules.filter(rule => rule.isMatch(userId.domain)) - ] - } else { - return this.rulesOfKind(ruleTypeOf(entity)).filter(rule => rule.isMatch(entity)); - } - } - - /** - * Create a new policy rule in the list. - * @param entityType The type of entity the rule applies to e.g. RULE_USER for users. - * @param recommendation The recommendation e.g. `Recommendation.Ban` that the policy represents. - * @param entity The entity to be added to the policy list. - * @param additionalProperties Any other properties to embed in the rule such as a reason. - * @returns The event id of the policy. - */ - public async createPolicy(entityType: EntityType, recommendation: Recommendation, entity: string, additionalProperties = {}): Promise { - // '@' at the beginning of state keys is reserved. - const stateKey = entityType === RULE_USER ? '_' + entity.substring(1) : entity; - const eventId = await this.client.sendStateEvent(this.roomId, entityType, stateKey, { - recommendation, - entity, - ...additionalProperties - }); - this.updateForEvent(eventId); - return eventId; - } - - /** - * Ban an entity with Recommendation.Ban from the list. - * @param ruleType The type of rule e.g. RULE_USER. - * @param entity The entity to ban. - * @param reason A reason we are banning them. - */ - public async banEntity(ruleType: EntityType, entity: string, reason?: string): Promise { - await this.createPolicy(ruleType, Recommendation.Ban, entity, { - reason: reason || '', - }); - } - - /** - * Remove all rules in the banList for this entity that have the same state key (as when we ban them) - * by searching for rules that have legacy state types. - * @param ruleType The normalized (most recent) type for this rule e.g. `RULE_USER`. - * @param entity The entity to unban from this list. - * @returns true if any rules were removed and the entity was unbanned, otherwise false because there were no rules. - */ - public async unbanEntity(ruleType: string, entity: string): Promise { - let typesToCheck = [ruleType]; - switch (ruleType) { - case RULE_USER: - typesToCheck = USER_RULE_TYPES; - break; - case RULE_SERVER: - typesToCheck = SERVER_RULE_TYPES; - break; - case RULE_ROOM: - typesToCheck = ROOM_RULE_TYPES; - break; - } - const sendNullState = async (stateType: string, stateKey: string) => { - const event_id = await this.client.sendStateEvent(this.roomId, stateType, stateKey, {}); - this.updateForEvent(event_id); - } - const removeRule = async (rule: ListRule): Promise => { - const stateKey = rule.sourceEvent.state_key; - // We can't cheat and check our state cache because we normalize the event types to the most recent version. - const typesToRemove = (await Promise.all( - typesToCheck.map(stateType => this.client.getRoomStateEvent(this.roomId, stateType, stateKey) - .then(_ => stateType) // We need the state type as getRoomState only returns the content, not the top level. - .catch(e => e.statusCode === 404 ? null : Promise.reject(e)))) - ).filter(e => e); // remove nulls. I don't know why TS still thinks there can be nulls after this?? - if (typesToRemove.length === 0) { - return; - } - await Promise.all(typesToRemove.map(stateType => sendNullState(stateType!, stateKey))); - } - const rules = this.rulesMatchingEntity(entity, ruleType); - await Promise.all(rules.map(removeRule)); - return rules.length > 0; - } - - /** - * Synchronise the model with the room representing the ban list by reading the current state of the room - * and updating the model to reflect the room. - * @returns A description of any rules that were added, modified or removed from the list as a result of this update. - */ - public async updateList(): Promise> { - await this.updateListLock.acquireAsync(); - try { - const state = await this.client.getRoomState(this.roomId); - return this.updateListWithState(state); - } finally { - this.updateListLock.release(); - } - } - - /** - * Same as `updateList` but without async to make sure that no one uses await within the body. - * The reason no one should use await is to avoid a horrible race should `updateList` be called more than once. - * @param state Room state to update the list with, provided by `updateList` - * @returns Any changes that have been made to the PolicyList. - */ - private updateListWithState(state: any): { revision: Revision, changes: ListRuleChange[] } { - const changes: ListRuleChange[] = []; - for (const event of state) { - if (event['state_key'] === '' && event['type'] === SHORTCODE_EVENT_TYPE) { - this.shortcode = (event['content'] || {})['shortcode'] || null; - continue; - } - - if (event['state_key'] === '' || !ALL_RULE_TYPES.includes(event['type'])) { - continue; - } - - let kind: EntityType | null = null; - if (USER_RULE_TYPES.includes(event['type'])) { - kind = RULE_USER; - } else if (ROOM_RULE_TYPES.includes(event['type'])) { - kind = RULE_ROOM; - } else if (SERVER_RULE_TYPES.includes(event['type'])) { - kind = RULE_SERVER; - } else { - continue; // invalid/unknown - } - - const previousState = this.getState(kind, event['state_key']); - - // Now we need to figure out if the current event is of an obsolete type - // (e.g. org.matrix.mjolnir.rule.user) when compared to the previousState (which might be m.policy.rule.user). - // We do not want to overwrite a rule of a newer type with an older type even if the event itself is supposedly more recent - // as it may be someone deleting the older versions of the rules. - if (previousState) { - const logObsoleteRule = () => { - LogService.info('PolicyList', `In PolicyList ${this.roomRef}, conflict between rules ${event['event_id']} (with obsolete type ${event['type']}) ` + - `and ${previousState['event_id']} (with standard type ${previousState['type']}). Ignoring rule with obsolete type.`); - } - if (kind === RULE_USER && USER_RULE_TYPES.indexOf(event['type']) > USER_RULE_TYPES.indexOf(previousState['type'])) { - logObsoleteRule(); - continue; - } else if (kind === RULE_ROOM && ROOM_RULE_TYPES.indexOf(event['type']) > ROOM_RULE_TYPES.indexOf(previousState['type'])) { - logObsoleteRule(); - continue; - } else if (kind === RULE_SERVER && SERVER_RULE_TYPES.indexOf(event['type']) > SERVER_RULE_TYPES.indexOf(previousState['type'])) { - logObsoleteRule(); - continue; - } - } - - // The reason we set the state at this point is because it is valid to want to set the state to an invalid rule - // in order to mark a rule as deleted. - // We always set state with the normalised state type via `kind` to de-duplicate rules. - this.setState(kind, event['state_key'], event); - const changeType: null | ChangeType = (() => { - if (!previousState) { - return ChangeType.Added; - } else if (previousState['event_id'] === event['event_id']) { - if (event['unsigned']?.['redacted_because']) { - return ChangeType.Removed; - } else { - // Nothing has changed. - return null; - } - } else { - // Then the policy has been modified in some other way, possibly 'soft' redacted by a new event with empty content... - if (Object.keys(event['content']).length === 0) { - return ChangeType.Removed; - } else { - return ChangeType.Modified; - } - } - })(); - - // Clear out any events that we were informed about via updateForEvent. - if (changeType !== null) { - this.batchedEvents.delete(event.event_id) - } - - // If we haven't got any information about what the rule used to be, then it wasn't a valid rule to begin with - // and so will not have been used. Removing a rule like this therefore results in no change. - if (changeType === ChangeType.Removed && previousState?.[PolicyList.EVENT_RULE_ANNOTATION_KEY]) { - const sender = event.unsigned['redacted_because'] ? event.unsigned['redacted_because']['sender'] : event.sender; - changes.push({ - changeType, event, sender, rule: previousState[PolicyList.EVENT_RULE_ANNOTATION_KEY], - ...previousState ? { previousState } : {} - }); - // Event has no content and cannot be parsed as a ListRule. - continue; - } - // It's a rule - parse it - const rule = ListRule.parse(event); - if (!rule) { - // Invalid/unknown rule, just skip it. - continue; - } - event[PolicyList.EVENT_RULE_ANNOTATION_KEY] = rule; - if (changeType) { - changes.push({ rule, changeType, event, sender: event.sender, ...previousState ? { previousState } : {} }); - } - } - if (changes.length > 0) { - this.revisionId = new Revision(); - this.emit('PolicyList.update', this, changes, this.revisionId); - } - if (this.batchedEvents.keys.length !== 0) { - // The only reason why this isn't a TypeError is because we need to know about this when it happens, because it means - // we're probably doing something wrong, on the other hand, if someone messes with a server implementation and - // strange things happen where events appear in /sync sooner than they do in /state (which would be outrageous) - // we don't want Mjolnir to stop working properly. Though, I am not confident a burried warning is going to alert us. - LogService.warn("PolicyList", "The policy list is being informed about events that it cannot find in the room state, this is really bad and you should seek help."); - } - return { revision: this.revisionId, changes }; - } - - /** - * Inform the `PolicyList` about a new event from the room it is modelling. - * @param event An event from the room the `PolicyList` models to inform an instance about. - */ - public updateForEvent(eventId: string): void { - if (this.stateByEventId.has(eventId) || this.batchedEvents.has(eventId)) { - return; // we already know about this event. - } - this.batcher.addToBatch(eventId); - this.batchedEvents.add(eventId); - } -} - -export default PolicyList; - -/** - * Helper class that emits a batch event on a `PolicyList` when it has made a batch - * out of the Matrix events given to `addToBatch` via `updateForEvent`. - * The `UpdateBatcher` will then call `list.update()` on the associated `PolicyList` once it has finished batching events. - */ -class UpdateBatcher { - // Whether we are waiting for more events to form a batch. - private isWaiting = false; - // The latest (or most recent) event we have received. - private latestEventId: string | null = null; - private readonly waitPeriodMS = 200; // 200ms seems good enough. - private readonly maxWaitMS = 3000; // 3s is long enough to wait while batching. - - constructor(private readonly banList: PolicyList) { - - } - - /** - * Reset the state for the next batch. - */ - private reset() { - this.latestEventId = null; - this.isWaiting = false; - } - - /** - * Checks if any more events have been added to the current batch since - * the previous iteration, then keep waiting up to `this.maxWait`, otherwise stop - * and emit a batch. - * @param eventId The id of the first event for this batch. - */ - private async checkBatch(eventId: string): Promise { - let start = Date.now(); - do { - await new Promise(resolve => setTimeout(resolve, this.waitPeriodMS)); - } while ((Date.now() - start) < this.maxWaitMS && this.latestEventId !== eventId) - this.reset(); - // batching finished, update the associated list. - await this.banList.updateList(); - } - - /** - * Adds an event to the batch. - * @param eventId The event to inform the batcher about. - */ - public addToBatch(eventId: string): void { - if (this.isWaiting) { - this.latestEventId = eventId; - return; - } - this.latestEventId = eventId; - this.isWaiting = true; - // We 'spawn' off here after performing the checks above - // rather than before (ie if `addToBatch` was async) because - // `banListTest` showed that there were 100~ ACL events per protected room - // as compared to just 5~ by doing this. Not entirely sure why but it probably - // has to do with queuing up `n event` tasks on the event loop that exaust scheduling - // (so the latency between them is percieved as much higher by - // the time they get checked in `this.checkBatch`, thus batching fails). - this.checkBatch(eventId); - } -} - -/** - * Represents a specific version of the state contained in `PolicyList`. - * These are unique and can be compared with `supersedes`. - * We use a ULID to work out whether a revision supersedes another. - */ -export class Revision { - - /** - * Ensures that ULIDs are monotonic. - */ - private static makeULID = monotonicFactory(); - - /** - * Is only public for the comparison method, - * I feel like I'm missing something here and it is possible without - */ - public readonly ulid = Revision.makeULID(); - - constructor() { - // nothing to do. - } - - /** - * Check whether this revision supersedes another revision. - * @param revision The revision we want to check this supersedes. - * @returns True if this Revision supersedes the other revision. - */ - public supersedes(revision: Revision): boolean { - return this.ulid > revision.ulid; - } -} diff --git a/src/models/PolicyListManager.ts b/src/models/PolicyListManager.ts deleted file mode 100644 index c0a4ad8e..00000000 --- a/src/models/PolicyListManager.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Copyright (C) 2022-2023 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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, LogService } from "matrix-bot-sdk"; -import { Mjolnir } from "../Mjolnir"; -import { MatrixDataManager, RawSchemedData, SCHEMA_VERSION_KEY } from "./MatrixDataManager"; -import { MatrixRoomReference } from "../commands/interface-manager/MatrixRoomReference"; -import { PolicyList, WATCHED_LISTS_EVENT_TYPE, WARN_UNPROTECTED_ROOM_EVENT_PREFIX } from "./PolicyList"; - -type WatchedListsEvent = RawSchemedData & { references?: string[]; }; -/** - * Manages the policy lists that a Mjolnir watches - */ - -export class PolicyListManager extends MatrixDataManager { - private policyLists: PolicyList[]; - - protected schema = []; - protected isAllowedToInferNoVersionAsZero = true; - - constructor(private readonly mjolnir: Mjolnir) { - super(); - } - - public get lists(): PolicyList[] { - return [...this.policyLists]; - } - - public resolveListShortcode(listShortcode: string): PolicyList | undefined { - return this.lists.find(list => list.listShortcode.toLocaleLowerCase() === listShortcode); - } - - /** - * Helper for constructing `PolicyList`s and making sure they have the right listeners set up. - * @param roomId The room id for the `PolicyList`. - * @param roomRef A reference (matrix.to URL) for the `PolicyList`. - */ - private async addPolicyList(roomId: string, roomRef: string): Promise { - const list = new PolicyList(roomId, roomRef, this.mjolnir.client); - this.mjolnir.ruleServer?.watch(list); - await list.updateList(); - this.policyLists.push(list); - this.mjolnir.protectedRoomsTracker.watchList(list); - - return list; - } - - /** - * Watching a list applies all of its policies to a protected room. - * @param roomRef A matrix room reference to a policy list that should be watched. - * - * @returns The list that has been watched or null if the manager was already - * watching the list. - */ - public async watchList(roomRef: MatrixRoomReference): Promise { - const roomId = await roomRef.joinClient(this.mjolnir.client); - if (this.policyLists.find(b => b.roomId === roomId.toRoomIdOrAlias())) { - // This room was already in our list of policy rooms, nothing else to do. - // Note that we bailout *after* the call to `joinRoom`, in case a user - // calls `watchList` in an attempt to repair something that was broken, - // e.g. a Mjölnir who could not join the room because of alias resolution - // or server being down, etc. - return null; - } - - const list = await this.addPolicyList(roomId.toRoomIdOrAlias(), roomId.toPermalink()); - - await this.storeMatixData(); - await this.warnAboutUnprotectedPolicyListRoom(roomId.toRoomIdOrAlias()); - return list; - } - - /** - * Stop watching the list and applying its associated policies. - * @param roomRef A matrix room reference to a list that should be unwatched. - * @returns The list being unwatched or null if we were not watching the list. - */ - public async unwatchList(roomRef: MatrixRoomReference): Promise { - const roomId = await roomRef.resolve(this.mjolnir.client); - const list = this.policyLists.find(b => b.roomId === roomId.toRoomIdOrAlias()) || null; - if (list) { - this.policyLists.splice(this.policyLists.indexOf(list), 1); - this.mjolnir.ruleServer?.unwatch(list); - this.mjolnir.protectedRoomsTracker.unwatchList(list); - } - - await this.storeMatixData(); - return list; - } - - protected async createFirstData(): Promise { - return { [SCHEMA_VERSION_KEY]: 0 }; - } - - protected async requestMatrixData(): Promise { - try { - return await this.mjolnir.client.getAccountData(WATCHED_LISTS_EVENT_TYPE); - } catch (e) { - if (e.statusCode === 404) { - LogService.warn('PolicyListManager', "Couldn't find account data for Mjolnir's watched lists, assuming first start.", e); - return this.createFirstData(); - } else { - throw e; - } - } - } - - /** - * Load the watched policy lists from account data, only used when Mjolnir is initialized. - */ - public async start() { - this.policyLists = []; - const watchedListsEvent = await super.loadData(); - - await Promise.all( - (watchedListsEvent?.references || []).map(async (roomRef: string) => { - const roomReference = await MatrixRoomReference.fromPermalink(roomRef).joinClient(this.mjolnir.client) - .catch(ex => { - LogService.error("PolicyListManager", "Failed to load watched lists for this mjolnir", ex); - return Promise.reject(ex); - } - ); - await this.warnAboutUnprotectedPolicyListRoom(roomReference.toRoomIdOrAlias()); - // TODO, FIXME: fix this so that it stores room references and not this utter junk. - await this.addPolicyList(roomReference.toRoomIdOrAlias(), roomReference.toPermalink()); - }) - ); - } - - /** - * Store to account the list of policy rooms. - */ - protected async storeMatixData() { - let list = this.policyLists.map(b => b.roomRef); - await this.mjolnir.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { - references: list, - }); - } - - /** - * Check whether a policy list room is protected. If not, display - * a user-readable warning. - * - * We store as account data the list of room ids for which we have - * already displayed the warning, to avoid bothering users at every - * single startup. - * - * @param roomId The id of the room to check/warn. - */ - private async warnAboutUnprotectedPolicyListRoom(roomId: string) { - if (!this.mjolnir.config.protectAllJoinedRooms) { - return; // doesn't matter - } - if (this.mjolnir.explicitlyProtectedRooms.includes(roomId)) { - return; // explicitly protected - } - - try { - const accountData: { warned: boolean; } | null = await this.mjolnir.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId); - if (accountData && accountData.warned) { - return; // already warned - } - } catch (e) { - // Expect that we haven't warned yet. - } - - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId); - await this.mjolnir.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true }); - } -} diff --git a/src/models/RoomUpdateError.tsx b/src/models/RoomUpdateError.tsx index e760d281..e525ee85 100644 --- a/src/models/RoomUpdateError.tsx +++ b/src/models/RoomUpdateError.tsx @@ -25,47 +25,15 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { UserID } from "matrix-bot-sdk"; -import { CommandException, CommandExceptionKind } from "../commands/interface-manager/CommandException"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; import { JSXFactory } from "../commands/interface-manager/JSXFactory"; -import { Permalinks } from "../commands/interface-manager/Permalinks"; -import { CommandError, CommandResult } from "../commands/interface-manager/Validation"; -import { MatrixSendClient } from "../MatrixEmitter"; +import { ActionException, ActionExceptionKind, ActionResult, Ok, RoomUpdateError, StringRoomID } from "matrix-protection-suite"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -export interface IRoomUpdateError extends CommandError { - readonly roomId: string, -} - -export class PermissionError extends CommandError implements IRoomUpdateError { - constructor( - public readonly roomId: string, - message: string - ) { - super(message); - } -} - -export class RoomUpdateException extends CommandException implements IRoomUpdateError { - constructor(public readonly roomId: string, ...args: ConstructorParameters) { - super(...args); - } - - public static Result( - message: string, - options: { - exception: Error, - exceptionKind: CommandExceptionKind, - roomId: string - }): CommandResult { - return CommandResult.Err(new RoomUpdateException(options.roomId, options.exceptionKind, options.exception, message)); - } -} - -function renderErrorItem(error: IRoomUpdateError, viaServers: string[]): DocumentNode { +function renderErrorItem(error: RoomUpdateError): DocumentNode { return
    1. - {error.roomId} - {error.message} + {error.room.toRoomIDOrAlias()} - {error.message}
    2. } @@ -79,17 +47,12 @@ function renderErrorItem(error: IRoomUpdateError, viaServers: string[]): Documen * @returns A `DocumentNode` fragment that can be sent to Matrix or incorperated into another message. */ export async function renderActionResult( - client: MatrixSendClient, - errors: IRoomUpdateError[], + 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} } - // This is a little unfortunate because for some reason we don't have a way to keep - // room references around that have vias and are meaningful - // there isn't really any easy way to do this :( - const viaServers = [(new UserID(await client.getUserId())).domain]; return {title}
      @@ -101,7 +64,7 @@ export async function renderActionResult(
        - {errors.map(error => renderErrorItem(error, viaServers))} + {errors.map(error => renderErrorItem(error))}
      @@ -110,7 +73,7 @@ export async function renderActionResult( /** * Render a message to represent the outcome of an action in an update. * @param client A matrix client to send a notice with. - * @param roomId The room to send the notice to. + * @param roomID The room to send the notice to. * @param errors Any errors that are a result of the action. * @param options.title To give context about what the action was, shown when there are errors. * @param options.noErrorsText To show when there are no errors. @@ -118,14 +81,22 @@ export async function renderActionResult( */ export async function printActionResult( client: MatrixSendClient, - roomId: string, - errors: IRoomUpdateError[], + roomID: StringRoomID, + errors: RoomUpdateError[], renderOptions: { title?: string, noErrorsText?: string } = {} -): Promise { - await renderMatrixAndSend( - {await renderActionResult(client, errors, renderOptions)}, - roomId, +): 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 + } + ) ) } diff --git a/src/models/RuleServer.ts b/src/models/RuleServer.ts deleted file mode 100644 index 35926eaa..00000000 --- a/src/models/RuleServer.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 BanList, { ChangeType, ListRuleChange } from "./PolicyList" -import * as crypto from "crypto"; -import { LogService } from "matrix-bot-sdk"; -import { EntityType, ListRule } from "./ListRule"; -import PolicyList from "./PolicyList"; - -export const USER_MAY_INVITE = 'user_may_invite'; -export const CHECK_EVENT_FOR_SPAM = 'check_event_for_spam'; - -/** - * Rules in the RuleServer format that have been produced from a single event. - */ -class EventRules { - constructor( - readonly eventId: string, - readonly roomId: string, - readonly ruleServerRules: RuleServerRule[], - // The token associated with when the event rules were created. - readonly token: number - ) { - } -} - -/** - * A description of a property that should be checked as part of a RuleServerRule. - */ -interface Checks { - property: string; -} - -/** - * A Rule served by the rule server. - */ -interface RuleServerRule { - // A unique identifier for this rule. - readonly id: string - // A description of a property that should be checked. - readonly checks: Checks -} - -/** - * The RuleServer is an experimental server that is used to propogate the rules of the watched policy rooms (BanLists) to - * homeservers (or e.g. synapse modules). - * This is done using an experimental format that is heavily based on the "Spam Checker Callbacks" made available to - * synapse modules https://matrix-org.github.io/synapse/latest/modules/spam_checker_callbacks.html. - * - */ -export default class RuleServer { - // Each token is an index for a row of this two dimensional array. - // Each row represents the rules that were added during the lifetime of that token. - private ruleStartsByToken: EventRules[][] = [[]]; - - // Each row, indexed by a token, represents the rules that were stopped during the lifetime of that token. - private ruleStopsByToken: string[][] = [[]]; - - // We use this to quickly lookup if we have stored a policy without scanning rulesByToken. - // First key is the room id and the second is the event id. - private rulesByEvent: Map> = new Map(); - - // A unique identifier for this server instance that is given to each response so we can tell if the token - // was issued by this server or not. This is important for when Mjolnir has been restarted - // but the client consuming the rules hasn't been - // and we need to tell the client we have rebuilt all of the rules (via `reset` in the response). - private readonly serverId: string = crypto.randomUUID(); - - // Represents the current instant in which rules can started and/or stopped. - // Should always be incremented before adding rules. See `nextToken`. - private currentToken = 0; - - private readonly banListUpdateListener = this.update.bind(this); - - /** - * The token is used to separate EventRules from each other based on when they were added. - * The lower the token, the longer a rule has been tracked for (relative to other rules in this RuleServer). - * The token is incremented before adding new rules to be served. - */ - private nextToken(): void { - this.currentToken += 1; - this.ruleStartsByToken.push([]); - this.ruleStopsByToken.push([]); - } - - /** - * Get a combination of the serverId and currentToken to give to the client. - */ - private get since(): string { - return `${this.serverId}::${this.currentToken}`; - } - - /** - * Get the `EventRules` object for a Matrix event. - * @param roomId The room the event came from. - * @param eventId The id of the event. - * @returns The `EventRules` object describing which rules have been created based on the policy the event represents - * or `undefined` if there are no `EventRules` associated with the event. - */ - private getEventRules(roomId: string, eventId: string): EventRules | undefined { - return this.rulesByEvent.get(roomId)?.get(eventId); - } - - /** - * Add the EventRule to be served by the rule server at the current token. - * @param eventRules Add rules for an associated policy room event. (e.g. m.policy.rule.user). - * @throws If there are already rules associated with the event specified in `eventRules.eventId`. - */ - private addEventRules(eventRules: EventRules): void { - const { roomId, eventId, token } = eventRules; - if (this.rulesByEvent.get(roomId)?.has(eventId)) { - throw new TypeError(`There is already an entry in the RuleServer for rules created from the event ${eventId}.`); - } - const roomTable = this.rulesByEvent.get(roomId); - if (roomTable) { - roomTable.set(eventId, eventRules); - } else { - this.rulesByEvent.set(roomId, new Map().set(eventId, eventRules)); - } - this.ruleStartsByToken[token].push(eventRules); - } - - /** - * Stop serving the rules from this policy rule. - * @param eventRules The EventRules to stop serving from the rule server. - */ - private stopEventRules(eventRules: EventRules): void { - const { eventId, roomId, token } = eventRules; - this.rulesByEvent.get(roomId)?.delete(eventId); - // We expect that each row of `rulesByEvent` list of eventRules (represented by 1 row in `rulesByEvent`) to be relatively small (1-5) - // as it can only contain eventRules added during the instant of time represented by one token. - const index = this.ruleStartsByToken[token].indexOf(eventRules); - if (index > -1) { - this.ruleStartsByToken[token].splice(index, 1); - } - eventRules.ruleServerRules.map(rule => this.ruleStopsByToken[this.currentToken].push(rule.id)); - } - - /** - * Update the rule server to reflect a ListRule change. - * @param change A ListRuleChange sourced from a BanList. - */ - private applyRuleChange(change: ListRuleChange): void { - if (change.changeType === ChangeType.Added) { - const eventRules = new EventRules(change.event.event_id, change.event.room_id, toRuleServerFormat(change.rule), this.currentToken); - this.addEventRules(eventRules); - } else if (change.changeType === ChangeType.Modified) { - const entry: EventRules | undefined = this.getEventRules(change.event.roomId, change.previousState.event_id); - if (entry === undefined) { - LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`); - return; - } - this.stopEventRules(entry); - const eventRules = new EventRules(change.event.event_id, change.event.room_id, toRuleServerFormat(change.rule), this.currentToken); - this.addEventRules(eventRules); - } else if (change.changeType === ChangeType.Removed) { - // 1) When the change is a redaction, the original version of the event will be available to us in `change.previousState`. - // 2) When an event has been "soft redacted" (ie we have a new event with the same state type and state_key with no content), - // the events in the `previousState` and `event` slots of `change` will be distinct events. - // In either case (of redaction or "soft redaction") we can use `previousState` to get the right event id to stop. - const entry: EventRules | undefined = this.getEventRules(change.event.room_id, change.previousState.event_id); - if (entry === undefined) { - LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`); - return; - } - this.stopEventRules(entry); - } - } - - /** - * Watch the ban list for changes and serve its policies as rules. - * You will almost always want to call this before calling `updateList` on the BanList for the first time, - * as we won't be able to serve rules that have already been interned in the BanList. - * @param banList a BanList to watch for rule changes with. - */ - public watch(banList: PolicyList): void { - banList.on('PolicyList.update', this.banListUpdateListener); - } - - /** - * Remove all of the rules that have been created from the policies in this banList. - * @param banList The BanList to unwatch. - */ - public unwatch(banList: PolicyList): void { - banList.removeListener('PolicyList.update', this.banListUpdateListener); - const listRules = this.rulesByEvent.get(banList.roomId); - this.nextToken(); - if (listRules) { - for (const rule of listRules.values()) { - this.stopEventRules(rule); - } - } - } - - /** - * Process the changes that have been made to a BanList. - * This will ususally be called as a callback from `BanList.onChange`. - * @param banList The BanList that the changes happened in. - * @param changes An array of ListRuleChanges. - */ - private update(banList: BanList, changes: ListRuleChange[]) { - if (changes.length > 0) { - this.nextToken(); - changes.forEach(this.applyRuleChange, this); - } - } - - /** - * Get all of the new rules since the token. - * @param sinceToken A token that has previously been issued by this server. - * @returns An object with the rules that have been started and stopped since the token and a new token to poll for more rules with. - */ - public getUpdates(sinceToken: string | null): { start: RuleServerRule[], stop: string[], reset?: boolean, since: string } { - const updatesSince = (token: number | null, policyStore: T[][]): T[] => { - if (token === null) { - // The client is requesting for the first time, we will give them everything. - return policyStore.flat(); - } else if (token === this.currentToken) { - // There will be no new rules to give this client, they're up to date. - return []; - } else { - return policyStore.slice(token).flat(); - } - } - const [serverId, since] = sinceToken ? sinceToken.split('::') : [null, null]; - const parsedSince: number | null = since ? parseInt(since, 10) : null; - if (serverId && serverId !== this.serverId) { - // The server has restarted, but the client has not and still has rules we can no longer account for. - // So we have to resend them everything. - return { - start: updatesSince(null, this.ruleStartsByToken).map((e: EventRules) => e.ruleServerRules).flat(), - stop: updatesSince(null, this.ruleStopsByToken), - since: this.since, - reset: true - } - } else { - // We will bring the client up to date on the rules. - return { - start: updatesSince(parsedSince, this.ruleStartsByToken).map((e: EventRules) => e.ruleServerRules).flat(), - stop: updatesSince(parsedSince, this.ruleStopsByToken), - since: this.since, - } - } - } -} - -/** -* Convert a ListRule into the format that can be served by the rule server. -* @param policyRule A ListRule to convert. -* @returns An array of rules that can be served from the rule server. -*/ -function toRuleServerFormat(policyRule: ListRule): RuleServerRule[] { - function makeLiteral(literal: string) { - return { literal } - } - - function makeGlob(glob: string) { - return { glob } - } - - function makeServerGlob(server: string) { - return { glob: `:${server}` } - } - - function makeRule(checks: Checks) { - return { - id: crypto.randomUUID(), - checks: checks - } - } - - if (policyRule.kind === EntityType.RULE_USER) { - // Block any messages or invites from being sent by a matching local user - // Block any messages or invitations from being received that were sent by a matching remote user. - return [{ - property: USER_MAY_INVITE, - user_id: [makeGlob(policyRule.entity)] - }, - { - property: CHECK_EVENT_FOR_SPAM, - sender: [makeGlob(policyRule.entity)] - }].map(makeRule) - } else if (policyRule.kind === EntityType.RULE_ROOM) { - // Block any messages being sent or received in the room, stop invitations being sent to the room and - // stop anyone receiving invitations from the room. - return [{ - property: USER_MAY_INVITE, - 'room_id': [makeLiteral(policyRule.entity)] - }, - { - property: CHECK_EVENT_FOR_SPAM, - 'room_id': [makeLiteral(policyRule.entity)] - }].map(makeRule) - } else if (policyRule.kind === EntityType.RULE_SERVER) { - // Block any invitations from the server or any new messages from the server. - return [{ - property: USER_MAY_INVITE, - user_id: [makeServerGlob(policyRule.entity)] - }, - { - property: CHECK_EVENT_FOR_SPAM, - sender: [makeServerGlob(policyRule.entity)] - }].map(makeRule) - } else { - LogService.info('RuleServer', `Ignoring unsupported policy rule type ${policyRule.kind}`); - return [] - } -} diff --git a/src/models/ServerAcl.ts b/src/models/ServerAcl.ts deleted file mode 100644 index 83ad2144..00000000 --- a/src/models/ServerAcl.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { MatrixGlob } from "matrix-bot-sdk"; -import { setToArray } from "../utils"; - -export interface ServerAclContent { - allow: string[]; - deny: string[]; - allow_ip_literals: boolean; -} - -export class ServerAcl { - private allowedServers: Set = new Set(); - private deniedServers: Set = new Set(); - private allowIps = false; - - public constructor(public readonly homeserver: string) { - - } - - /** - * Checks the ACL for any entries that might ban ourself. - * @returns A list of deny entries that will not ban our own homeserver. - */ - public safeDeniedServers(): string[] { - // The reason we do this check here rather than in the `denyServer` method - // is because `literalAclContent` exists and also we want to be defensive about someone - // mutating `this.deniedServers` via another method in the future. - const entries: string[] = [] - for (const server of this.deniedServers) { - const glob = new MatrixGlob(server); - if (!glob.test(this.homeserver)) { - entries.push(server); - } - } - return entries; - } - - public allowIpAddresses(): ServerAcl { - this.allowIps = true; - return this; - } - - public denyIpAddresses(): ServerAcl { - this.allowIps = false; - return this; - } - - public allowServer(glob: string): ServerAcl { - this.allowedServers.add(glob); - return this; - } - - public setAllowedServers(globs: string[]): ServerAcl { - this.allowedServers = new Set(globs); - return this; - } - - public denyServer(glob: string): ServerAcl { - this.deniedServers.add(glob); - return this; - } - - public setDeniedServers(globs: string[]): ServerAcl { - this.deniedServers = new Set(globs); - return this; - } - - public literalAclContent(): ServerAclContent { - return { - allow: setToArray(this.allowedServers), - deny: setToArray(this.deniedServers), - allow_ip_literals: this.allowIps, - }; - } - - public safeAclContent(): ServerAclContent { - const allowed = setToArray(this.allowedServers); - if (!allowed || allowed.length === 0) { - allowed.push("*"); // allow everything - } - return { - allow: allowed, - deny: this.safeDeniedServers(), - allow_ip_literals: this.allowIps, - }; - } - - public matches(acl: any): boolean { - if (!acl) return false; - - const allow = acl['allow']; - const deny = acl['deny']; - const ips = acl['allow_ip_literals']; - - let allowMatches = true; // until proven false - let denyMatches = true; // until proven false - let ipsMatch = ips === this.allowIps; - - const currentAllowed = setToArray(this.allowedServers); - if (allow.length === currentAllowed.length) { - for (const s of allow) { - if (!currentAllowed.includes(s)) { - allowMatches = false; - break; - } - } - } else allowMatches = false; - - const currentDenied = setToArray(this.deniedServers); - if (deny.length === currentDenied.length) { - for (const s of deny) { - if (!currentDenied.includes(s)) { - denyMatches = false; - break; - } - } - } else denyMatches = false; - - return denyMatches && allowMatches && ipsMatch; - } -} diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 432387a9..dadf398d 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -28,26 +28,28 @@ limitations under the License. * a Mjolnir PR that was originally created by Gergő Fándly https://github.com/matrix-org/mjolnir/pull/223 */ -import { Protection } from "./Protection"; -import { Mjolnir } from "../Mjolnir"; -import { LogService } from "matrix-bot-sdk"; import { JSXFactory } from "../commands/interface-manager/JSXFactory"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; -import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; -import { RULE_USER, ListRule } from "../models/ListRule"; -import { UserID } from "matrix-bot-sdk"; -import { MatrixRoomReference } from "../commands/interface-manager/MatrixRoomReference"; -import { findPolicyListFromRoomReference } from "../commands/Ban"; -import PolicyList from "../models/PolicyList"; -import { renderListRules } from "../commands/Rules"; -import { printActionResult, IRoomUpdateError, RoomUpdateException } from "../models/RoomUpdateError"; -import { CommandExceptionKind } from "../commands/interface-manager/CommandException"; +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, UserID, 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 BAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.ban_propagation'; const UNBAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.unban_propagation'; -function makePolicyListShortcodeReferenceMap(lists: PolicyList[]): Map { - return lists.reduce((map, list, index) => (map.set(`${index + 1}.`, list.roomRef), map), new Map()) +// FIXME: https://github.com/the-draupnir-project/Draupnir/issues/160 +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. @@ -64,191 +66,248 @@ interface BanPropagationMessageContext { * @returns An event id which can be used by the `PromptResponseListener`. */ async function promptBanPropagation( - mjolnir: Mjolnir, - event: any, - roomId: string -): Promise { - const reactionMap = makePolicyListShortcodeReferenceMap(mjolnir.policyListManager.lists); + draupnir: Draupnir, + change: MembershipChange, +): Promise { + const editablePolicyRoomIDs = draupnir.policyRoomManager.getEditablePolicyRoomIDs( + draupnir.clientUserID, + PolicyRuleType.User + ); + const reactionMap = makePolicyRoomReactionReferenceMap(editablePolicyRoomIDs); const promptEventId = (await renderMatrixAndSend( - The user {renderMentionPill(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was banned - in {roomId} by {new UserID(event["sender"])} for {event["content"]?.["reason"] ?? ''}.
      + The user {renderMentionPill(change.userID, change.content.displayname ?? change.userID)} was banned + in {change.roomID} by {new UserID(change.sender)} for {change.content.reason ?? ''}.
      Would you like to add the ban to a policy list? -
        - {mjolnir.policyListManager.lists.map(list =>
      1. {list}
      2. )} -
      +
        + {editablePolicyRoomIDs.map((room) =>
      1. {room.toRoomIDOrAlias()}
      2. )} +
      , - mjolnir.managementRoomId, + draupnir.managementRoomID, undefined, - mjolnir.client, - mjolnir.reactionHandler.createAnnotation( + draupnir.client, + draupnir.reactionHandler.createAnnotation( BAN_PROPAGATION_PROMPT_LISTENER, reactionMap, { - target: event["state_key"], - reason: event["content"]?.["reason"], + target: change.userID, + reason: change.content.reason, } ) )).at(0) as string; - await mjolnir.reactionHandler.addReactionsToEvent(mjolnir.client, mjolnir.managementRoomId, promptEventId, reactionMap); - return promptEventId; + await draupnir.reactionHandler.addReactionsToEvent(draupnir.client, draupnir.managementRoomID, promptEventId, reactionMap); } async function promptUnbanPropagation( - mjolnir: Mjolnir, - event: any, - roomId: string, - rulesMatchingUser: Map -): Promise { + 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(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was unbanned - from the room {roomId} by {new UserID(event["sender"])} for {event["content"]?.["reason"] ?? ''}.
      + 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.entries()] - .map(([list, rules]) =>
      • {renderListRules({ - shortcode: list.listShortcode, - roomRef: list.roomRef, - roomId: list.roomId, - matches: rules - })}
      • ) - } + { + rulesMatchingUser.map(match =>
      • {renderListRules(match)}
      • ) + }
      Would you like to remove these rules and unban the user from all protected rooms?
      , - mjolnir.managementRoomId, + draupnir.managementRoomID, undefined, - mjolnir.client, - mjolnir.reactionHandler.createAnnotation( + draupnir.client, + draupnir.reactionHandler.createAnnotation( UNBAN_PROPAGATION_PROMPT_LISTENER, reactionMap, { - target: event["state_key"], - reason: event["content"]?.["reason"], + target: membershipChange.userID, + reason: membershipChange.content.reason, } ) )).at(0) as string; - await mjolnir.reactionHandler.addReactionsToEvent(mjolnir.client, mjolnir.managementRoomId, promptEventId, reactionMap); - return promptEventId; + await draupnir.reactionHandler.addReactionsToEvent(draupnir.client, draupnir.managementRoomID, promptEventId, reactionMap); } -interface ListenerContext { - mjolnir: Mjolnir, -} +export type BanPropagationProtectionCapabilities = { + userConsequences: UserConsequences +}; -async function banReactionListener(this: ListenerContext, key: string, item: unknown, context: BanPropagationMessageContext) { - try { - if (typeof item === 'string') { - const listRef = MatrixRoomReference.fromPermalink(item); - const listResult = await findPolicyListFromRoomReference(this.mjolnir, listRef); - if (listResult.isOk()) { - return await listResult.ok.banEntity(RULE_USER, context.target, context.reason); - } else { - LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err); - return; - } - } else { - throw new TypeError("The ban prompt event's reaction map is malformed.") - } - } catch (e) { - LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e); +export type BanPropagationProtectionCapabilitiesDescription = ProtectionDescription< + Draupnir, + UnknownSettings, + BanPropagationProtectionCapabilities +>; + +export class BanPropagationProtection + extends AbstractProtection + implements DraupnirProtection { + + private readonly userConsequences: UserConsequences; + constructor( + description: BanPropagationProtectionCapabilitiesDescription, + capabilities: BanPropagationProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + ) { + super(description, capabilities, protectedRoomsSet, [], []); + this.userConsequences = capabilities.userConsequences; + // FIXME: These listeners are gonna leak all over if we don't have a + // hook for stopping protections. + this.draupnir.reactionHandler.on(BAN_PROPAGATION_PROMPT_LISTENER, this.banReactionListener.bind(this)); + this.draupnir.reactionHandler.on(UNBAN_PROPAGATION_PROMPT_LISTENER, this.unbanUserReactionListener.bind(this)); } -} -async function unbanFromAllLists(mjolnir: Mjolnir, user: string): Promise { - const errors: IRoomUpdateError[] = []; - for (const list of mjolnir.policyListManager.lists) { - try { - await list.unbanEntity(RULE_USER, user); - } catch (e) { - LogService.info('BanPropagation', `Could not unban ${user} from ${list.roomRef}`, e); - const message = e.message || (e.body ? e.body.error : ''); - errors.push(new RoomUpdateException( - list.roomId, - message.includes("You don't have permission") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown, - e, - message - )); + 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) { + Task(this.handleUnban(unban, this.draupnir)); } + return Ok(undefined); } - return errors; -} -async function unbanUserReactionListener(this: ListenerContext, _key: string, item: unknown, context: BanPropagationMessageContext): Promise { - try { - if (item === 'unban from all') { - const listErrors = await unbanFromAllLists(this.mjolnir, context.target); - if (listErrors.length > 0) { - await printActionResult( - this.mjolnir.client, - this.mjolnir.managementRoomId, - listErrors, - { title: `There were errors unbanning ${context.target} from all lists.`} - ); - } else { - const unbanErrors = await this.mjolnir.protectedRoomsTracker.unbanUser(context.target); - await printActionResult( - this.mjolnir.client, - this.mjolnir.managementRoomId, - unbanErrors, - { - title: `There were errors unbanning ${context.target} from protected rooms.`, - noErrorsText: `Done unbanning ${context.target} from protected rooms - no errors.` - } - ); - } + 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. } - } catch (e) { - LogService.error(`BanPropagationProtection`, "Unexpected error unbanning a user", e); + Task(promptBanPropagation(this.draupnir, change)); } -} - -export class BanPropagation extends Protection { - - settings = {}; - public get name(): string { - return 'BanPropagationProtection'; - } - public get description(): string { - return "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."; + 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.`) + } + return { + room: info.revision.room, + roomID: policyRoomID, + matches: rules, + profile: info.watchedListProfile + } + }) + ); } - public async registerProtection(mjolnir: Mjolnir): Promise { - mjolnir.reactionHandler.on(BAN_PROPAGATION_PROMPT_LISTENER, banReactionListener.bind({ mjolnir })); - mjolnir.reactionHandler.on(UNBAN_PROPAGATION_PROMPT_LISTENER, unbanUserReactionListener.bind({ mjolnir })); + 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); + } } - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] !== 'm.room.member' - || !(event['content']?.['membership'] === 'ban' || event['content']?.['membership'] === 'leave')) { - return; - } - const rulesMatchingUser = mjolnir.policyListManager.lists.reduce( - (listMap, list) => { - const rules = list.rulesMatchingEntity(event['state_key'], RULE_USER); - if (rules.length > 0) { - listMap.set(list, rules) - }; - return listMap - }, new Map() - ); - const userMembership = event['content']?.['membership']; - if (userMembership === 'ban') { - if (rulesMatchingUser.size > 0) { - return; // The user is already banned. + 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; + } } - // do not await, we don't want to block the protection manager - promptBanPropagation(mjolnir, event, roomId) - } else if (userMembership === 'leave' && rulesMatchingUser.size > 0) { - // Then this is a banned user being unbanned. - // do not await, we don't want to block the protection manager - promptUnbanPropagation(mjolnir, event, roomId, rulesMatchingUser); + if (errors.length > 0) { + Task(printActionResult( + this.draupnir.client, + this.draupnir.managementRoomID, + errors, + { title: `There were errors unbanning ${context.target} from all lists.`} + )); + } else { + this.userConsequences.unbanUserFromRoomSet( + context.target as StringUserID, + '' + ) + } + } else { + log.error(`unban reaction map is malformed got item ${item} for key ${key}`); } } } + +describeProtection({ + 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 + ) + ), +}); diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 89cc7a4c..686e71e3 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -25,45 +25,104 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Protection } from "./Protection"; -import { NumberProtectionSetting } from "./ProtectionSettings"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, LogService } from "matrix-bot-sdk"; +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"; + +const log = new Logger('BasicFloodingProtection'); + +type BasicFloodingProtectionSettings = { + 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 class BasicFlooding extends Protection { - - private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {}; +export type BasicFloodingProtectionCapabilities = { + userConsequences: UserConsequences; + eventConsequences: EventConsequences; +}; + +export type BasicFloodingProtectionDescription = ProtectionDescription; + +describeProtection({ + 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 + }) +}); + + +export class BasicFloodingProtection extends AbstractProtection implements DraupnirProtection { + + private lastEvents: { [roomID: StringRoomID]: { [userID: StringUserID]: { originServerTs: number, eventID: StringEventID }[] } } = {}; private recentlyBanned: string[] = []; - settings = { - maxPerMinute: new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE) - }; - - public get name(): string { - return 'BasicFloodingProtection'; - } - public get description(): string { - return "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."; + 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; } - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (!this.lastEvents[roomId]) this.lastEvents[roomId] = {}; + public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { + if (!this.lastEvents[room.toRoomIDOrAlias()]) this.lastEvents[room.toRoomIDOrAlias()] = {}; - const forRoom = this.lastEvents[roomId]; + const forRoom = this.lastEvents[room.toRoomIDOrAlias()]; if (!forRoom[event['sender']]) forRoom[event['sender']] = []; let forUser = forRoom[event['sender']]; if ((new Date()).getTime() - event['origin_server_ts'] > TIMESTAMP_THRESHOLD) { - LogService.warn("BasicFlooding", `${event['event_id']} is more than ${TIMESTAMP_THRESHOLD}ms out of phase - rewriting event time to be 'now'`); + 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(); } - forUser.push({originServerTs: event['origin_server_ts'], eventId: event['event_id']}); + forUser.push({originServerTs: event['origin_server_ts'], eventID: event['event_id']}); // Do some math to see if the user is spamming let messageCount = 0; @@ -72,25 +131,27 @@ export class BasicFlooding extends Protection { messageCount++; } - if (messageCount >= this.settings.maxPerMinute.value) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); - if (!mjolnir.config.noop) { - await mjolnir.client.banUser(event['sender'], roomId, "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 mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + 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; // already handled (will be redacted) - mjolnir.unlistedUserRedactionHandler.addUser(event['sender']); + 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 (!mjolnir.config.noop) { - for (const eventId of forUser.map(e => e.eventId)) { - await mjolnir.client.redactEvent(roomId, eventId, "spam"); + if (!this.draupnir.config.noop) { + for (const eventID of forUser.map(e => e.eventID)) { + await this.eventConsequences.consequenceForEvent(room.toRoomIDOrAlias(), eventID, 'spam'); } } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + 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 @@ -98,8 +159,9 @@ export class BasicFlooding extends Protection { } // Trim the oldest messages off the user's history if it's getting large - if (forUser.length > this.settings.maxPerMinute.value * 2) { - forUser.splice(0, forUser.length - (this.settings.maxPerMinute.value * 2) - 1); + 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/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts new file mode 100644 index 00000000..e82be589 --- /dev/null +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -0,0 +1,77 @@ +/** + * Copyright (C) 2023-2024 Gnuxie + * All rights reserved. + */ + +import { ActionError,ActionException,ActionExceptionKind,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value, findProtection } from "matrix-protection-suite"; +import { RedactionSynchronisationProtection } from "./RedactionSynchronisation"; + +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, + }); + }, + 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, + }); + }, + 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, + }); + } +]); diff --git a/src/protections/DetectFederationLag.ts b/src/protections/DetectFederationLag.ts deleted file mode 100644 index 944eac91..00000000 --- a/src/protections/DetectFederationLag.ts +++ /dev/null @@ -1,762 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { Protection } from "./Protection"; -import { DurationMSProtectionSetting, NumberProtectionSetting, StringSetProtectionSetting } from "./ProtectionSettings"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, UserID } from "matrix-bot-sdk"; -import { ReadItem } from "../commands/interface-manager/CommandReader"; -import { MatrixRoomReference } from "../commands/interface-manager/MatrixRoomReference"; - -const DEFAULT_BUCKET_DURATION_MS = 10_000; -const DEFAULT_BUCKET_NUMBER = 6; -const DEFAULT_CLEANUP_PERIOD_MS = 3_600 * 1_000; -const DEFAULT_INITIAL_DELAY_GRACE_MS = 180_000; -const DEFAULT_LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS = 120_000; -const DEFAULT_LOCAL_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS = 100_000; -const DEFAULT_FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS = 180_000; -const DEFAULT_FEDERATED_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS = 150_000; -const DEFAULT_NUMBER_OF_LAGGING_FEDERATED_SERVERS_ENTER_WARNING_ZONE = 20; -const DEFAULT_NUMBER_OF_LAGGING_FEDERATED_SERVERS_EXIT_WARNING_ZONE = 10; -const DEFAULT_REWARN_AFTER_MS = 60_000; - -/** - * A state event emitted in the moderation room when there is lag, - * redacted when lag has disappeared. - * - * The state key is the id of the room in which lag was detected. - */ -export const LAG_STATE_EVENT = "org.mjolnir.monitoring.lag"; - -/** - * Settings for a timed histogram. - */ -type HistogramSettings = { - // The width of a bucket, in ms. - bucketDurationMS: number, - // The number of buckets. - bucketNumber: number; -} - -/** - * A histogram with time as x and some arbitrary value T as y. - */ -class TimedHistogram { - /** - * An array of at most `this.settings.bucketNumber` buckets of events. - * - * Each bucket gathers all events that were pushed during an interval of - * `this.settings.bucketDurationMS` starting at `bucket.timeStamp`. - * - * `0` is the oldest bucket. - * .. - * `length - 1` is the most recent bucket. - * - * Notes: - * - this is a sparse array, buckets are not necessarily adjacent; - * - if `this.updateSettings()` is called, we do not redistribute events - * between buckets, so it may take some time before statistics fully - * respect the new settings. - */ - protected buckets: { - start: Date; - events: T[] - }[]; - - /** - * Construct an empty TimedHistogram - */ - constructor(private settings: HistogramSettings) { - this.buckets = [] - } - - /** - * Push a new event into the histogram. - * - * New events are always considered most recent, without checking `new`. - * If pushing a new event causes the histogram to overflow, oldest buckets - * are removed. - * - * @param event The event to push. - * @param now The current date, used to create a new bucket to the event if - * necessary and to determine whether some buckets are too old. - */ - push(event: T, now: Date) { - let timeStamp = now.getTime(); - let latestBucket = this.buckets[this.buckets.length - 1]; - if (latestBucket && latestBucket.start.getTime() + this.settings.bucketDurationMS >= timeStamp) { - // We're still within `durationPerColumnMS` of latest entry, we can reuse that entry. - latestBucket.events.push(event); - return; - } - // Otherwise, initialize an entry, then prune columns that are too old. - this.buckets.push({ - start: now, - events: [event] - }); - this.trimBuckets(this.settings, now); - } - - /** - * If any buckets are too old, remove them. If there are (still) too - * many buckets, remove the oldest ones. - */ - private trimBuckets(settings: HistogramSettings, now: Date) { - if (this.buckets.length > settings.bucketNumber) { - this.buckets.splice(0, this.buckets.length - settings.bucketNumber); - } - const oldestAcceptableTimestamp = now.getTime() - settings.bucketDurationMS * settings.bucketNumber; - for (let i = this.buckets.length - 2; i >= 0; --i) { - // Find the most recent bucket that is too old. - if (this.buckets[i].start.getTime() < oldestAcceptableTimestamp) { - // ...and remove that bucket and every bucket before it. - this.buckets.splice(0, i + 1); - break; - } - } - } - - /** - * Change the settings of a histogram. - */ - public updateSettings(settings: HistogramSettings, now: Date) { - this.trimBuckets(settings, now); - this.settings = settings; - } -} - -/** - * General-purpose statistics on a sample. - */ -class Stats { - // Minimum. - public readonly min: number; - // Maximum. - public readonly max: number; - // Mean. - public readonly mean: number; - // Median. - public readonly median: number; - // Standard deviation. - public readonly stddev: number; - // Length of the sample. - public readonly length: number; - - constructor(values: number[]) { - this.length = values.length; - if (this.length === 0) { - throw new TypeError("Attempting to compute stats on an empty sample"); - } - if (this.length === 1) { - // `values[Math.ceil(this.length / 2)]` below fails when `this.length == 1`. - this.min = - this.max = - this.mean = - this.median = values[0]; - this.stddev = 0; - return; - } - values.sort((a, b) => a - b); // Don't forget to force sorting by value, not by stringified value! - this.min = values[0]; - this.max = values[this.length - 1]; - let total = 0; - for (let num of values) { - total += num; - } - this.mean = total / this.length; - - let totalVariance = 0; - for (let num of values) { - const deviation = num - this.mean; - totalVariance += deviation * deviation; - } - this.stddev = Math.sqrt(totalVariance / this.length); - - if (this.length % 2 === 0) { - this.median = values[this.length / 2]; - } else { - this.median = (values[Math.floor(this.length / 2)] + values[Math.ceil(this.length / 2)]) / 2; - } - } - - public round(): { min: number, max: number, mean: number, median: number, stddev: number, length: number } { - return { - min: Math.round(this.min), - max: Math.round(this.max), - mean: Math.round(this.mean), - median: Math.round(this.median), - stddev: Math.round(this.stddev), - length: this.length - } - } -} - -/** - * A subclass of TimedHistogram that supports only numbers - * and can compute statistics. - */ -class NumbersTimedHistogram extends TimedHistogram { - constructor(settings: HistogramSettings) { - super(settings); - } - - /** - * Compute stats. - * - * @returns `null` if the histogram is empty, otherwise `Stats`. - */ - public stats(): Stats | null { - if (this.buckets.length === 0) { - return null; - } - let numbers = []; - for (let bucket of this.buckets) { - numbers.push(...bucket.events); - } - if (numbers.length === 0) { - return null; - } - return new Stats(numbers); - } -} - -/** - * Lag information on a server for a specific room. - * - * The same server may be represented by distinct instances of `ServerInfo` in - * distinct rooms. - */ -class ServerInfo { - /** - * The histogram collecting lag, in ms. - */ - private histogram: NumbersTimedHistogram; - - /** - * Date of the latest message received from this server. - * - * May be used to clean up data structures. - */ - public latestMessage: Date = new Date(0); - public latestStatsUpdate: Date; - - constructor(settings: HistogramSettings, now: Date) { - this.histogram = new NumbersTimedHistogram(settings); - this.latestStatsUpdate = now; - } - - /** - * Record lag information on this server. - * - * @param lag The duration of lag, in ms. - */ - pushLag(lag: number, now: Date) { - this.latestMessage = now; - this.histogram.push(lag, now); - } - - updateSettings(settings: HistogramSettings, now: Date) { - this.histogram.updateSettings(settings, now); - } - - /** - * Compute stats. - * - * @returns `null` if the histogram is empty, otherwise `Stats`. - */ - stats(now?: Date) { - if (now) { - this.latestStatsUpdate = now; - } - return this.histogram.stats(); - } -} - -/** - * Thresholds to start/stop warning of an issue. - * - * Once we have hit a value higher that `enterWarningZone`, the alert - * will remain active until the value decreases below `exitWarningZone`. - */ -type WarningThresholds = { - enterWarningZone: number, - exitWarningZone: number -} - -enum AlertDiff { - Start, - Stop, - NoChange -} - -/** - * Statistics to help determine whether we should raise the alarm on lag in a room. - * - * Each individual server may have lag. - */ -class RoomInfo { - /** - * A map of domain => lag information. - */ - private serverLags: Map = new Map(); - /** - * The set of servers currently on alert. - */ - private serverAlerts: Set = new Set(); - - /** - * Global lag information for this room. - */ - public totalLag: ServerInfo; - - /** - * If non-`null`, the date at which this room started being on alert. - * Otherwise, the room is not an alert. - */ - public latestAlertStart: Date | null; - - /** - * The date at which we last issued a warning on this room. - * - * Used to avoid spamming the monitoring room with too many warnings per room. - */ - public latestWarning: Date = new Date(0); - - /** - * If non-`null`, we have issued a structured warning as a state event. - * This needs to be redacted once the alert has passed. - */ - public warnStateEventId: string | null = null; - - /** - * The date at which we last received a message in this room. - */ - public latestMessage: Date = new Date(0); - - constructor(now: Date) { - this.serverLags = new Map(); - this.totalLag = new ServerInfo({ - bucketDurationMS: DEFAULT_BUCKET_DURATION_MS, - bucketNumber: DEFAULT_BUCKET_NUMBER - }, now); - } - - /** - * Add a lag annotation. - * - * @param serverId The server from which the message was sent. Could be the local server. - * @param lag How many ms of lag was measured. Hopefully ~0. - * @param settings Settings used in case we need to create or update the histogram. - * @param thresholds The thresholds to use to determine whether an origin server is currently lagging. - * @param now Instant at which all of this was measured. - */ - pushLag(serverId: string, lag: number, settings: HistogramSettings, thresholds: WarningThresholds, now: Date = new Date()): AlertDiff { - this.latestMessage = now; - - // Update per-server lag. - let serverInfo = this.serverLags.get(serverId); - if (!serverInfo) { - serverInfo = new ServerInfo(settings, now); - this.serverLags.set(serverId, serverInfo); - } else { - serverInfo.updateSettings(settings, now); - } - serverInfo.pushLag(lag, now); - - // Update global lag. - this.totalLag.updateSettings(settings, now); - this.totalLag.pushLag(lag, now); - - // Check for alerts, if necessary. - if (serverInfo.latestStatsUpdate.getTime() + settings.bucketDurationMS > now.getTime()) { - // Too early to recompute stats. - return AlertDiff.NoChange; - } - - let stats = serverInfo.stats(now)!; - if (stats.median > thresholds.enterWarningZone) { - // Oops, we're now on alert for this server. - let previous = this.serverAlerts.has(serverId); - if (!previous) { - this.serverAlerts.add(serverId); - return AlertDiff.Start; - } - } else if (stats.median < thresholds.exitWarningZone) { - // Ah, we left the alert zone. - let previous = this.serverAlerts.has(serverId); - if (previous) { - this.serverAlerts.delete(serverId); - return AlertDiff.Stop; - } - } - return AlertDiff.NoChange; - } - - /** - * The number of servers currently on alert. - */ - public get alerts(): number { - return this.serverAlerts.size; - } - - /** - * The current global stats. - * - * These stats are not separated by remote server. - * - * @returns null if we have no recent data at all, - * some stats otherwise. - */ - public globalStats(): Stats | null { - return this.totalLag.stats(); - } - - /** - * Check if a server is currently marked as lagging. - * - * A server is marked as lagging if its mean lag has exceeded - * `threshold.enterWarningZone` and has not decreased below - * `threshold.exitWarningZone`. - * - * @returns `true` is that server is currently on alert. - */ - public isServerOnAlert(serverId: string): boolean { - return this.serverAlerts.has(serverId); - } - - /** - * The list of servers currently on alert. - */ - public serversOnAlert(): IterableIterator { - return this.serverAlerts.keys(); - } - - public cleanup(settings: HistogramSettings, now: Date, oldest: Date) { - // Cleanup global histogram. - // - // If `oldest == now - settings.duration * settings.number`, this - // should correspond exactly to the cleanup that takes place within - // `this.serverLags`. There is a risk of inconsistency between data - // if this is not the case. - // - // We assume that this is an acceptable risk: as we regularly - // erase oldest data from both `this.totalLag` and individual - // entries of `this.serverLags`, both sets of data will eventually - // catch up with each other. - this.totalLag.updateSettings(settings, now); - let serverLagsDeleteIds = []; - for (let [serverId, serverStats] of this.serverLags) { - if (serverStats.latestMessage < oldest) { - // Remove entire histogram. - serverLagsDeleteIds.push(serverId); - continue; - } - // Cleanup histogram. - serverStats.updateSettings(settings, now); - } - for (let key of serverLagsDeleteIds) { - this.serverLags.delete(key); - this.serverAlerts.delete(key); - // Note that we remove the alert to save memory (it's not really useful - // to keep monitoring a server for too long after receiving a message) - // but this does NOT guaranteed that server lag is over. It may be that - // the server is down or that the server is lagging by more than ~1h - // (by default). - } - } -} - -export class DetectFederationLag extends Protection { - /** - * For each room we're monitoring, lag information. - */ - lagPerRoom: Map = new Map(); - public settings = { - // Rooms to ignore. - ignoreRooms: new StringSetProtectionSetting(), - // Servers to ignore, typically because they're known to be slow. - ignoreServers: new StringSetProtectionSetting(), - // How often we should recompute lag. - bucketDuration: new DurationMSProtectionSetting(DEFAULT_BUCKET_DURATION_MS, 100), - // How long we should remember lag in a room (`bucketDuration * bucketNumber` ms). - bucketNumber: new NumberProtectionSetting(DEFAULT_BUCKET_NUMBER, 1), - // How much lag before the local homeserver is considered lagging. - localHomeserverLagEnterWarningZone: new DurationMSProtectionSetting(DEFAULT_LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, 1), - // How much lag before the local homeserver is considered not lagging anymore. - localHomeserverLagExitWarningZone: new DurationMSProtectionSetting(DEFAULT_LOCAL_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS, 1), - // How much lag before a federated homeserver is considered lagging. - federatedHomeserverLagEnterWarningZone: new DurationMSProtectionSetting(DEFAULT_FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, 1), - // How much lag before a federated homeserver is considered not lagging anymore. - federatedHomeserverLagExitWarningZone: new DurationMSProtectionSetting(DEFAULT_FEDERATED_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS, 1), - // How much time we should wait before printing a new warning. - warnAgainAfter: new DurationMSProtectionSetting(DEFAULT_REWARN_AFTER_MS, 1), - // How many federated homeservers it takes to trigger an alert. - // You probably want to update this if you're monitoring a room that - // has many underpowered homeservers. - numberOfLaggingFederatedHomeserversEnterWarningZone: new NumberProtectionSetting(DEFAULT_NUMBER_OF_LAGGING_FEDERATED_SERVERS_ENTER_WARNING_ZONE, 1), - // How many federated homeservers it takes before we're considered not on alert anymore. - // You probably want to update this if you're monitoring a room that - // has many underpowered homeservers. - numberOfLaggingFederatedHomeserversExitWarningZone: new NumberProtectionSetting(DEFAULT_NUMBER_OF_LAGGING_FEDERATED_SERVERS_EXIT_WARNING_ZONE, 1), - // How long to wait before actually collecting statistics. - // Used to avoid being misled by Mjölnir catching up with old messages on first sync. - initialDelayGrace: new DurationMSProtectionSetting(DEFAULT_INITIAL_DELAY_GRACE_MS, 0), - cleanupPeriod: new DurationMSProtectionSetting(DEFAULT_CLEANUP_PERIOD_MS, 1), - }; - // The instant at which the first message was received. - private firstMessage: Date | null = null; - // The latest instant at which we have started cleaning up old data. - private latestCleanup: Date = new Date(0); - private latestHistogramSettings: HistogramSettings; - constructor() { - super(); - // Initialize and watch `this.latestHistogramSettings`. - this.updateLatestHistogramSettings(); - this.settings.bucketDuration.on("set", () => this.updateLatestHistogramSettings()); - this.settings.bucketNumber.on("set", () => this.updateLatestHistogramSettings()); - } - dispose() { - this.settings.bucketDuration.removeAllListeners(); - this.settings.bucketNumber.removeAllListeners(); - } - public get name(): string { - return 'DetectFederationLag'; - } - public get description(): string { - return `Warn moderators if either the local homeserver starts lagging by ${this.settings.localHomeserverLagEnterWarningZone.value}ms or at least ${this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value} start lagging by at least ${this.settings.federatedHomeserverLagEnterWarningZone.value}ms.`; - } - - /** - * @param now An argument used only by tests, to simulate events taking place at a specific date. - */ - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any, now: Date = new Date()) { - // First, handle all cases in which we should ignore the event. - if (!this.firstMessage) { - this.firstMessage = now; - } - if (this.firstMessage.getTime() + this.settings.initialDelayGrace.value > now.getTime()) { - // We're still in the initial grace period, ignore. - return; - } - if (this.latestCleanup.getTime() + this.settings.cleanupPeriod.value > now.getTime()) { - // We should run some cleanup. - this.latestCleanup = now; - this.cleanup(now); - } - if (this.settings.ignoreRooms.value.has(roomId)) { - // Room is ignored. - return; - } - const sender = event['sender'] as string; - if (typeof sender !== "string") { - // Ill-formed event. - return; - } - if (sender === await mjolnir.client.getUserId()) { - // Let's not create loops. - return; - } - const domain = new UserID(sender).domain; - if (!domain) { - // Ill-formed event. - return; - } - - const origin = event['origin_server_ts'] as number; - if (typeof origin !== "number" || isNaN(origin)) { - // Ill-formed event. - return; - } - const delay = now.getTime() - origin; - if (delay < 0) { - // Could be an ill-formed event. - // Could be non-motonic clocks or other time shennanigans. - return; - } - - let roomInfo = this.lagPerRoom.get(roomId); - if (!roomInfo) { - roomInfo = new RoomInfo(now); - this.lagPerRoom.set(roomId, roomInfo); - } - - const localDomain = new UserID(await mjolnir.client.getUserId()).domain - const isLocalDomain = domain === localDomain; - const thresholds = - isLocalDomain - ? { - enterWarningZone: this.settings.localHomeserverLagEnterWarningZone.value, - exitWarningZone: this.settings.localHomeserverLagExitWarningZone.value, - } - : { - enterWarningZone: this.settings.federatedHomeserverLagEnterWarningZone.value, - exitWarningZone: this.settings.federatedHomeserverLagExitWarningZone.value, - }; - - const diff = roomInfo.pushLag(domain, delay, this.latestHistogramSettings, thresholds, now); - if (diff === AlertDiff.NoChange) { - return; - } - - if (roomInfo.latestWarning.getTime() + this.settings.warnAgainAfter.value > now.getTime()) { - if (!isLocalDomain || diff !== AlertDiff.Start) { - // No need to check for alarms, we have raised an alarm recently. - return; - } - } - - // Check whether an alarm needs to be raised! - const isLocalDomainOnAlert = roomInfo.isServerOnAlert(localDomain); - if (roomInfo.alerts > this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value - || isLocalDomainOnAlert) { - // Raise the alarm! - if (!roomInfo.latestAlertStart) { - roomInfo.latestAlertStart = now; - } - roomInfo.latestAlertStart = now; - // Background-send message. - const stats = roomInfo.globalStats(); - /* do not await */ mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FederationLag", - `Room ${roomId} is experiencing ${isLocalDomainOnAlert ? "LOCAL" : "federated"} lag since ${roomInfo.latestAlertStart}.\n${roomInfo.alerts} homeservers are lagging: ${[...roomInfo.serversOnAlert()].sort()} .\nRoom lag statistics: ${JSON.stringify(stats, null, 2)}.`); - // Drop a state event, for the use of potential other bots. - const warnStateEventId = await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, LAG_STATE_EVENT, roomId, { - domains: [...roomInfo.serversOnAlert()], - roomId, - // We need to round the stats, as Matrix doesn't support floating-point - // numbers in messages. - stats: stats?.round(), - since: roomInfo.latestAlertStart, - }); - roomInfo.warnStateEventId = warnStateEventId; - } else if (roomInfo.alerts < this.settings.numberOfLaggingFederatedHomeserversExitWarningZone.value - || !isLocalDomainOnAlert) { - // Stop the alarm! - /* do not await */ mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "FederationLag", - `Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging` - ); - if (roomInfo.warnStateEventId) { - const warnStateEventId = roomInfo.warnStateEventId; - roomInfo.warnStateEventId = null; - await mjolnir.client.redactEvent(mjolnir.managementRoomId, warnStateEventId, "Alert over"); - } - } - } - - /** - * Run cleanup on data structures, to save memory. - * - * @param now Now. - * @param oldest Prune any data older than `oldest`. - */ - public async cleanup(now: Date = new Date()) { - const oldest: Date = this.getOldestAcceptableData(now); - const lagPerRoomDeleteIds = []; - for (const [roomId, roomInfo] of this.lagPerRoom) { - if (roomInfo.latestMessage < oldest) { - // We need to remove the entire room. - lagPerRoomDeleteIds.push(roomId); - continue; - } - // Clean room stats. - roomInfo.cleanup(this.latestHistogramSettings, now, oldest); - } - for (const roomId of lagPerRoomDeleteIds) { - this.lagPerRoom.delete(roomId); - } - } - - private getOldestAcceptableData(now: Date): Date { - return new Date(now.getTime() - this.latestHistogramSettings.bucketDurationMS * this.latestHistogramSettings.bucketNumber) - } - private updateLatestHistogramSettings() { - this.latestHistogramSettings = Object.freeze({ - bucketDurationMS: this.settings.bucketDuration.value, - bucketNumber: this.settings.bucketNumber.value, - }); - }; - - /** - * Return (mostly) human-readable lag status. - */ - public async statusCommand(mjolnir: Mjolnir, subcommand: ReadItem[]): Promise<{html: string, text: string} | null> { - const roomRef = subcommand[0] || "*"; - const localDomain = new UserID(await mjolnir.client.getUserId()).domain; - const annotatedStats = (roomInfo: RoomInfo) => { - const stats = roomInfo.globalStats()?.round(); - if (!stats) { - return null; - } - const isLocalDomainOnAlert = roomInfo.isServerOnAlert(localDomain); - const numberOfServersOnAlert = roomInfo.alerts; - if (isLocalDomainOnAlert) { - (stats as any)["warning"] = "Local homeserver is lagging"; - } else if (numberOfServersOnAlert > this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value) { - (stats as any)["warning"] = `${numberOfServersOnAlert} homeservers are lagging`; - } - return stats; - }; - let text; - let html; - if (roomRef === "*") { - // Collate data from all protected rooms. - const result: any = {}; - - for (const [perRoomId, perRoomInfo] of this.lagPerRoom.entries()) { - const key = await mjolnir.client.getPublishedAlias(perRoomId) || perRoomId; - result[key] = annotatedStats(perRoomInfo); - } - text = JSON.stringify(result, null, 2); - html = `${JSON.stringify(result, null, "  ")}`; - } else if (roomRef instanceof MatrixRoomReference) { - const roomId = (await roomRef.resolve(mjolnir.client)).toRoomIdOrAlias(); - // Fetch data from a specific room. - const roomInfo = this.lagPerRoom.get(roomId); - if (!roomInfo) { - html = text = `Either ${roomId} is unmonitored or it has received no messages in a while`; - } else { - // Fetch data from all remote homeservers. - const stats = annotatedStats(roomInfo); - if (!stats) { - html = text = `No recent messages in room ${roomId}`; - } else { - text = JSON.stringify(stats, null, 2); - html = `${JSON.stringify(stats, null, "  ")}`; - } - } - } else { - // FIXME, TODO: - // These subcommands should really be implemented as another command table - // That can be refered to in a command in another table. - // https://github.com/Gnuxie/Draupnir/issues/21 - throw new TypeError(`Unexpected argument ${roomRef}`); - } - return { - text, - html - } - } -} diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts new file mode 100644 index 00000000..f8cfd9ee --- /dev/null +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -0,0 +1,19 @@ +/** + * This file exists as a way to register all protections. + * In future, we should maybe try to dogfood the dynamic plugin load sytem + * instead. For now that system doesn't even exist. + */ + +// keep alphabetical please. +import './BanPropagation'; +import './BasicFlooding'; +import './FirstMessageIsImage'; +import './JoinWaveShortCircuit'; +import './RedactionSynchronisation'; +import './MessageIsMedia'; +import './MessageIsVoice'; +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 915f6b88..376e5b65 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -25,72 +25,116 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Protection } from "./Protection"; -import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; -import { isTrueJoinEvent } from "../utils"; - -export class FirstMessageIsImage extends Protection { - - private justJoined: { [roomId: string]: string[] } = {}; - private recentlyBanned: string[] = []; - - settings = {}; - - constructor() { - super(); - } - - public get name(): string { - return 'FirstMessageIsImageProtection'; +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 = {}; + +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, \ + 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 + ) + ) } - public get description(): string { - return "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."; +}) + +export class FirstMessageIsImageProtection extends AbstractProtection implements Protection { + + private justJoined: { [roomID: StringRoomID]: StringUserID[] } = {}; + 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 handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (!this.justJoined[roomId]) this.justJoined[roomId] = []; - - if (event['type'] === 'm.room.member') { - if (isTrueJoinEvent(event)) { - this.justJoined[roomId].push(event['state_key']); - LogService.info("FirstMessageIsImage", `Tracking ${event['state_key']} in ${roomId} as just joined`); + 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); } - - return; // stop processing (membership event spam is another problem) } + return Ok(undefined); + } - if (event['type'] === 'm.room.message') { - const content = event['content'] || {}; - const msgtype = content['msgtype'] || 'm.text'; - const formattedBody = content['formatted_body'] || ''; + 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'] || 'm.text'; + const formattedBody = event.content !== undefined && '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.justJoined[roomID].splice(idx, 1); } + return Ok(undefined); } } diff --git a/src/protections/JoinWaveShortCircuit.ts b/src/protections/JoinWaveShortCircuit.ts deleted file mode 100644 index 733441d5..00000000 --- a/src/protections/JoinWaveShortCircuit.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 {Protection} from "./Protection"; -import {Mjolnir} from "../Mjolnir"; -import {NumberProtectionSetting} from "./ProtectionSettings"; -import {LogLevel} from "matrix-bot-sdk"; - -const DEFAULT_MAX_PER_TIMESCALE = 50; -const DEFAULT_TIMESCALE_MINUTES = 60; -const ONE_MINUTE = 60_000; // 1min in ms - -export class JoinWaveShortCircuit extends Protection { - requiredStatePermissions = ["m.room.join_rules"] - - private joinBuckets: { - [roomId: string]: { - lastBucketStart: Date, - numberOfJoins: number, - } - } = {}; - - settings = { - maxPer: new NumberProtectionSetting(DEFAULT_MAX_PER_TIMESCALE), - timescaleMinutes: new NumberProtectionSetting(DEFAULT_TIMESCALE_MINUTES) - }; - - constructor() { - super(); - } - - public get name(): string { - return "JoinWaveShortCircuit"; - } - - public get description(): string { - return "If X amount of users join in Y time, set the room to invite-only." - } - - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) { - if (event['type'] !== 'm.room.member') { - // Not a join/leave event. - return; - } - - if (!mjolnir.protectedRoomsTracker.isProtectedRoom(roomId)) { - // Not a room we are watching. - return; - } - - const userId = event['state_key']; - if (!userId) { - // Ill-formed event. - return; - } - - const newMembership = event['content']['membership']; - const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || null; - - // We look at the previous membership to filter out profile changes - if (newMembership === 'join' && prevMembership !== "join") { - // A new join, fallthrough - } else { - 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.value) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId); - - if (!mjolnir.config.noop) { - await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"}) - } else { - await mjolnir.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.value * ONE_MINUTE) - } - - public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string, text: string }> { - const withExpired = subcommand.includes("withExpired"); - const withStart = subcommand.includes("withStart"); - - let html = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes}):
        `; - let text = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes):\n`; - - for (const roomId of Object.keys(this.joinBuckets)) { - const bucket = this.joinBuckets[roomId]; - const isExpired = this.hasExpired(bucket.lastBucketStart); - - if (isExpired && !withExpired) { - continue; - } - - const startText = withStart ? ` (since ${bucket.lastBucketStart})` : ""; - const expiredText = isExpired ? ` (bucket expired since ${new Date(bucket.lastBucketStart.getTime() + this.timescaleMilliseconds())})` : ""; - - html += `
      • ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.
      • `; - text += `* ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.\n`; - } - - html += "
      "; - - return { - html, - text, - } - } -} diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx new file mode 100644 index 00000000..d936ec74 --- /dev/null +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -0,0 +1,183 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { 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 DEFAULT_MAX_PER_TIMESCALE = 50; +const DEFAULT_TIMESCALE_MINUTES = 60; +const ONE_MINUTE = 60_000; // 1min in ms + +type JoinWaveShortCircuitProtectionSettings = { + maxPer: number, + timescaleMinutes: number, +} + +// TODO: Add join rule capability. +type JoinWaveShortCircuitProtectionCapabilities = {} + +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' + ) + }, + { + maxPer: DEFAULT_MAX_PER_TIMESCALE, + timescaleMinutes: DEFAULT_TIMESCALE_MINUTES, + }) +}) + +export class JoinWaveShortCircuitProtection extends AbstractProtection implements DraupnirProtection { + requiredStatePermissions = ["m.room.join_rules"] + + private joinBuckets: { + [roomID: StringRoomID]: { + lastBucketStart: Date, + numberOfJoins: number, + } + } = {}; + + constructor( + description: JoinWaveShortCircuitProtectionDescription, + capabilities: CapabilitySet, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + public readonly settings: JoinWaveShortCircuitProtectionSettings + ) { + super( + description, + capabilities, + protectedRoomsSet, + ["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 => log.error(`Unexpected error handling memebership change`, e)); + } + return Ok(undefined); + } + + 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})`, 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. + * which will probably entail doing the symbol case hacks from Utena for camel case etc. + public async status(keywords, subcommands): Promise { + const withExpired = subcommand.includes("withExpired"); + const withStart = subcommand.includes("withStart"); + + let html = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes}):
        `; + let text = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes):\n`; + + for (const roomId of Object.keys(this.joinBuckets)) { + const bucket = this.joinBuckets[roomId]; + const isExpired = this.hasExpired(bucket.lastBucketStart); + + if (isExpired && !withExpired) { + continue; + } + + const startText = withStart ? ` (since ${bucket.lastBucketStart})` : ""; + const expiredText = isExpired ? ` (bucket expired since ${new Date(bucket.lastBucketStart.getTime() + this.timescaleMilliseconds())})` : ""; + + html += `
      • ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.
      • `; + text += `* ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.\n`; + } + + html += "
      "; + + return { + html, + text, + } + } + */ +} diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 7b31e8ab..65beb331 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -25,41 +25,76 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Protection } from "./Protection"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, UserID } from "matrix-bot-sdk"; -import { Permalinks } from "../commands/interface-manager/Permalinks"; +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 { Draupnir } from "../Draupnir"; -export class MessageIsMedia extends Protection { +type MessageIsMediaProtectionSettings = {}; - settings = {}; +type MessageIsMediaCapabilities = { + eventConsequences: EventConsequences; +} - constructor() { - super(); - } +type MessageIsMediaProtectionDescription = ProtectionDescription; - public get name(): string { - return 'MessageIsMediaProtection'; +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 + ) + ) } - public get description(): string { - return "If a user posts an image or video, that message will be redacted. No bans are issued."; +}) + +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 = this.eventConsequences; } - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] === 'm.room.message') { - const content = event['content'] || {}; - const msgtype = content['msgtype'] || 'm.text'; - const formattedBody = content['formatted_body'] || ''; + 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'] || 'm.text'; + const formattedBody = event.content !== undefined && 'formatted_body' in event.content ? event.content?.['formatted_body'] || '' : ''; const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('; - constructor() { - super(); +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 + ) + ); } +}) - public get name(): string { - return 'MessageIsVoiceProtection'; - } - public get description(): string { - return "If a user posts a voice message, that message will be redacted. No bans are issued."; +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 handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] === 'm.room.message' && event['content']) { - if (event['content']['msgtype'] !== 'm.audio') return; - if (event['content']['org.matrix.msc3245.voice'] === undefined) return; - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); + 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 (!mjolnir.config.noop) { - await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here"); + if (!this.draupnir.config.noop) { + return await this.eventConsequences.consequenceForEvent(roomID, event['event_id'], "Voice messages are not permitted here"); } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + 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/ProtectedRoomsSetRenderers.tsx b/src/protections/ProtectedRoomsSetRenderers.tsx new file mode 100644 index 00000000..bb6e78f2 --- /dev/null +++ b/src/protections/ProtectedRoomsSetRenderers.tsx @@ -0,0 +1,32 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +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 { JSXFactory } from "../commands/interface-manager/JSXFactory"; + +export async function renderProtectionFailedToStart( + 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 + ) +} diff --git a/src/protections/Protection.ts b/src/protections/Protection.ts index e33281b3..c2e91ee1 100644 --- a/src/protections/Protection.ts +++ b/src/protections/Protection.ts @@ -25,52 +25,13 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir } from "../Mjolnir"; -import { AbstractProtectionSetting } from "./ProtectionSettings"; -import { Consequence } from "./consequence"; +import { Protection } from "matrix-protection-suite"; +import { DocumentNode } from "../commands/interface-manager/DeadDocument"; +import { ParsedKeywords } from "../commands/interface-manager/ParameterParsing"; import { ReadItem } from "../commands/interface-manager/CommandReader"; -/** - * Represents a protection mechanism of sorts. Protections are intended to be - * event-based (ie: X messages in a period of time, or posting X events). - * - * Protections are guaranteed to be run before redaction handlers. - */ -export abstract class Protection { - abstract readonly name: string - abstract readonly description: string; - enabled = false; - readonly requiredStatePermissions: string[] = []; - abstract settings: { [setting: string]: AbstractProtectionSetting }; - - /* - * Handle a single event from a protected room, to decide if we need to - * respond to it - */ - async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - } - - /* - * Handle a single reported event from a protecte room, to decide if we - * need to respond to it - */ - async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { - } - - /** - * Return status information for `!mjolnir status ${protectionName}`. - * FIXME: protections need their own tables https://github.com/Gnuxie/Draupnir/issues/21 - */ - async statusCommand(mjolnir: Mjolnir, subcommand: ReadItem[]): Promise<{html: string, text: string} | null> { - // By default, protections don't have any status to show. - return null; - } - - /** - * Allows protections to setup listeners when Mjolnir starts up. - * @param mjolnir The mjolnir instance associated with a given protection manager. - */ - public async registerProtection(mjolnir: Mjolnir): Promise { - return; - } +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/ProtectionManager.ts b/src/protections/ProtectionManager.ts deleted file mode 100644 index 66ef815d..00000000 --- a/src/protections/ProtectionManager.ts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { FirstMessageIsImage } from "./FirstMessageIsImage"; -import { Protection } from "./Protection"; -import { BasicFlooding } from "./BasicFlooding"; -import { DetectFederationLag } from "./DetectFederationLag"; -import { WordList } from "./WordList"; -import { MessageIsVoice } from "./MessageIsVoice"; -import { MessageIsMedia } from "./MessageIsMedia"; -import { TrustedReporters } from "./TrustedReporters"; -import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, LogService } from "matrix-bot-sdk"; -import { ProtectionSettingValidationError } from "./ProtectionSettings"; -import { Consequence } from "./consequence"; -import { htmlEscape } from "../utils"; -import { IRoomUpdateError, PermissionError, RoomUpdateException } from "../models/RoomUpdateError"; -import { BanPropagation } from "./BanPropagation"; -import { MatrixDataManager, RawSchemedData, SchemaMigration, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager"; -import { Permalinks } from "../commands/interface-manager/Permalinks"; -import { CommandExceptionKind } from "../commands/interface-manager/CommandException"; - -const PROTECTIONS: Protection[] = [ - new FirstMessageIsImage(), - new BanPropagation(), - new BasicFlooding(), - new WordList(), - new MessageIsVoice(), - new MessageIsMedia(), - new TrustedReporters(), - new DetectFederationLag(), - new JoinWaveShortCircuit(), -]; - -const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; -type EnabledProtectionsEvent = RawSchemedData & { - enabled: string[], -} - -class EnabledProtectionsManager extends MatrixDataManager { - protected readonly schema: SchemaMigration[] = [ - async function enableBanPropagationByDefault(input: EnabledProtectionsEvent) { - const enabled = new Set(input.enabled); - const banPropagationProtection = PROTECTIONS.find(p => p.name === 'BanPropagationProtection'); - if (banPropagationProtection === undefined) { - throw new TypeError("Couldn't find the ban propagation protection"); - } - enabled.add(banPropagationProtection.name) - return { - enabled: [...enabled], - [SCHEMA_VERSION_KEY]: 1, - } - } - ]; - protected readonly isAllowedToInferNoVersionAsZero = true; - private readonly enabledProtections = new Set(); - - constructor( - private readonly mjolnir: Mjolnir - ) { - super() - } - - protected async requestMatrixData(): Promise { - try { - return await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); - } catch (e) { - if (e.statusCode === 404) { - LogService.warn('PolicyListManager', "Couldn't find account data for Draupnir's protections, assuming first start.", e); - return this.createFirstData(); - } else { - throw e; - } - } - } - - protected async storeMatixData(): Promise { - const data: EnabledProtectionsEvent = { - enabled: [...this.enabledProtections], - [SCHEMA_VERSION_KEY]: 1, - } - await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, data); - } - - protected async createFirstData(): Promise { - return { enabled: [], [SCHEMA_VERSION_KEY]: 0 }; - } - - public isEnabled(protection: Protection): boolean { - return this.enabledProtections.has(protection.name); - } - - public async enable(protection: Protection): Promise { - this.enabledProtections.add(protection.name); - protection.enabled = true; - await this.storeMatixData(); - } - - public async disable(protection: Protection): Promise { - this.enabledProtections.delete(protection.name); - protection.enabled = false; - await this.storeMatixData(); - } - - public async start(): Promise { - const data = await this.loadData(); - for (const protection of data.enabled) { - this.enabledProtections.add(protection); - } - } -} - -const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence"; - -/** - * This is responsible for informing protections about relevant events and handle standard consequences. - */ -export class ProtectionManager { - private enabledProtectionsManager: EnabledProtectionsManager; - private _protections = new Map(); - get protections(): Readonly> { - return this._protections; - } - - constructor(private readonly mjolnir: Mjolnir) { - this.enabledProtectionsManager = new EnabledProtectionsManager(this.mjolnir); - } - - /* - * Take all the builtin protections, register them to set their enabled (or not) state and - * update their settings with any saved non-default values - */ - public async start() { - await this.enabledProtectionsManager.start(); - this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this)); - this.mjolnir.matrixEmitter.on("room.event", this.handleEvent.bind(this)); - for (const protection of PROTECTIONS) { - try { - await this.registerProtection(protection); - } catch (e) { - LogService.error("ProtectionManager", `Unable to start protection ${protection.name}`, e); - this.mjolnir.managementRoomOutput.logMessage( - LogLevel.WARN, "ProtectionManager", `Unable to start protection ${protection.name}` - ); - - } - } - } - - /** - * Given a protection object; add it to our list of protections, set it up if it has been enabled previously (in account data) - * and update its settings with any saved non-default values. See `ENABLED_PROTECTIONS_EVENT_TYPE`. - * - * @param protection The protection object we want to register - */ - public async registerProtection(protection: Protection) { - this._protections.set(protection.name, protection) - protection.enabled = this.enabledProtectionsManager.isEnabled(protection) ?? false; - - const savedSettings = await this.getProtectionSettings(protection.name); - for (let [key, value] of Object.entries(savedSettings)) { - // this.getProtectionSettings() validates this data for us, so we don't need to - protection.settings[key].setValue(value); - } - await protection.registerProtection(this.mjolnir); - } - - /* - * Given a protection object; remove it from our list of protections. - * - * @param protection The protection object we want to unregister - */ - public unregisterProtection(protectionName: string) { - if (!(this._protections.has(protectionName))) { - throw new Error("Failed to find protection by name: " + protectionName); - } - this._protections.delete(protectionName); - } - - /* - * Takes an object of settings we want to change and what their values should be, - * check that their values are valid, combine them with current saved settings, - * then save the amalgamation to a state event - * - * @param protectionName Which protection these settings belong to - * @param changedSettings The settings to change and their values - */ - public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise { - const protection = this._protections.get(protectionName); - if (protection === undefined) { - return; - } - - const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); - - for (let [key, value] of Object.entries(changedSettings)) { - if (!(key in protection.settings)) { - throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); - } - if (typeof (protection.settings[key].value) !== typeof (value)) { - throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`); - } - if (!protection.settings[key].validate(value)) { - throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); - } - validatedSettings[key] = value; - } - - await this.mjolnir.client.sendStateEvent( - this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings - ); - } - - /* - * Enable a protection by name and persist its enable state in to a state event - * - * @param name The name of the protection whose settings we're enabling - */ - public async enableProtection(name: string) { - const protection = this._protections.get(name); - if (protection !== undefined) { - await this.enabledProtectionsManager.enable(protection); - } - } - - public get enabledProtections(): Protection[] { - return [...this._protections.values()].filter(p => p.enabled); - } - - /** - * Get a protection by name. - * - * @return If there is a protection with this name *and* it is enabled, - * return the protection. - */ - public getProtection(protectionName: string): Protection | null { - return this._protections.get(protectionName) ?? null; - } - - - /* - * Disable a protection by name and remove it from the persistent list of enabled protections - * - * @param name The name of the protection whose settings we're disabling - */ - public async disableProtection(name: string) { - const protection = this._protections.get(name); - if (protection !== undefined) { - await this.enabledProtectionsManager.disable(protection); - } - } - - /* - * Read org.matrix.mjolnir.setting state event, find any saved settings for - * the requested protectionName, then iterate and validate against their parser - * counterparts in Protection.settings and return those which validate - * - * @param protectionName The name of the protection whose settings we're reading - * @returns Every saved setting for this protectionName that has a valid value - */ - public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> { - let savedSettings: { [setting: string]: any } = {} - try { - savedSettings = await this.mjolnir.client.getRoomStateEvent( - this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName - ); - } catch { - // setting does not exist, return empty object - return {}; - } - - const settingDefinitions = this._protections.get(protectionName)?.settings ?? {}; - const validatedSettings: { [setting: string]: any } = {} - for (let [key, value] of Object.entries(savedSettings)) { - if ( - // is this a setting name with a known parser? - key in settingDefinitions - // is the datatype of this setting's value what we expect? - && typeof (settingDefinitions[key].value) === typeof (value) - // is this setting's value valid for the setting? - && settingDefinitions[key].validate(value) - ) { - validatedSettings[key] = value; - } else { - await this.mjolnir.managementRoomOutput.logMessage( - LogLevel.WARN, - "getProtectionSetting", - `Tried to read ${protectionName}.${key} and got invalid value ${value}` - ); - } - } - return validatedSettings; - } - - private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) { - for (const consequence of consequences) { - try { - if (consequence.name === "alert") { - /* take no additional action, just print the below message to management room */ - } else if (consequence.name === "ban") { - await this.mjolnir.client.banUser(sender, roomId, "abuse detected"); - } else if (consequence.name === "redact") { - await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected"); - } else { - throw new Error(`unknown consequence ${consequence.name}`); - } - - let message = `protection ${protection.name} enacting` - + ` ${consequence.name}` - + ` against ${htmlEscape(sender)}` - + ` in ${htmlEscape(roomId)}` - + ` (reason: ${htmlEscape(consequence.reason)})`; - await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { - msgtype: "m.notice", - body: message, - [CONSEQUENCE_EVENT_DATA]: { - who: sender, - room: roomId, - types: [consequence.name], - } - }); - } catch (e) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`); - } - } - } - - private async handleEvent(roomId: string, event: any) { - if (this.mjolnir.protectedRoomsTracker.getProtectedRooms().includes(roomId)) { - if (event['sender'] === await this.mjolnir.client.getUserId()) return; // Ignore ourselves - - // Iterate all the enabled protections - for (const protection of this.enabledProtections) { - let consequences: Consequence[] | undefined = undefined; - try { - consequences = await protection.handleEvent(this.mjolnir, roomId, event); - } catch (e) { - const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); - LogService.error("ProtectionManager", "Error handling protection: " + protection.name); - LogService.error("ProtectionManager", "Failed event: " + eventPermalink); - LogService.error("ProtectionManager", e); - await this.mjolnir.client.sendNotice(this.mjolnir.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink); - continue; - } - - if (consequences !== undefined) { - await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences); - } - } - - // Run the event handlers - we always run this after protections so that the protections - // can flag the event for redaction. - await this.mjolnir.unlistedUserRedactionHandler.handleEvent(roomId, event, this.mjolnir); // FIXME: That's rather spaghetti - } - } - - - private requiredProtectionPermissions(): Set { - return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) - } - - public async verifyPermissionsIn(roomId: string): Promise { - const errors: IRoomUpdateError[] = []; - const additionalPermissions = this.requiredProtectionPermissions(); - - try { - const ownUserId = await this.mjolnir.client.getUserId(); - - const powerLevels = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.power_levels", ""); - if (!powerLevels) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("Missing power levels state event"); - } - - function plDefault(val: number | undefined | null, def: number): number { - if (!val && val !== 0) return def; - return val; - } - - const users = powerLevels['users'] || {}; - const events = powerLevels['events'] || {}; - const usersDefault = plDefault(powerLevels['users_default'], 0); - const stateDefault = plDefault(powerLevels['state_default'], 50); - const ban = plDefault(powerLevels['ban'], 50); - const kick = plDefault(powerLevels['kick'], 50); - const redact = plDefault(powerLevels['redact'], 50); - - const userLevel = plDefault(users[ownUserId], usersDefault); - const aclLevel = plDefault(events["m.room.server_acl"], stateDefault); - - const addErrorToReport = (message: string) => { - errors.push(new PermissionError(roomId, message)) - } - - if (userLevel < ban) { - addErrorToReport(`Missing power level for bans: ${userLevel} < ${ban}`); - } - if (userLevel < kick) { - addErrorToReport(`Missing power level for kicks: ${userLevel} < ${kick}`); - } - if (userLevel < redact) { - addErrorToReport(`Missing power level for redactions: ${userLevel} < ${redact}`); - } - if (!this.mjolnir.config.disableServerACL && userLevel < aclLevel) { - addErrorToReport(`Missing power level for server ACLs: ${userLevel} < ${aclLevel}`); - } - - // Wants: Additional permissions - - for (const additionalPermission of additionalPermissions) { - const permLevel = plDefault(events[additionalPermission], stateDefault); - - if (userLevel < permLevel) { - addErrorToReport(`Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`); - } - } - - // Otherwise OK - } catch (e) { - const message = `Unexpected error when attempting to verify the permissions in ${roomId}`; - errors.push(new RoomUpdateException(roomId, CommandExceptionKind.Unknown, e, message)); - } - return errors; - } - - private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { - for (const protection of this.enabledProtections) { - await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason); - } - } -} diff --git a/src/protections/ProtectionSettings.ts b/src/protections/ProtectionSettings.ts deleted file mode 100644 index f2ff96f6..00000000 --- a/src/protections/ProtectionSettings.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -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 { EventEmitter } from "events"; -import { default as parseDuration } from "parse-duration"; - -// Define a few aliases to simplify parsing durations. - -parseDuration["milliseconds"] = parseDuration["millis"] = parseDuration["ms"]; -parseDuration["days"] = parseDuration["day"]; -parseDuration["weeks"] = parseDuration["week"] = parseDuration["wk"]; -parseDuration["months"] = parseDuration["month"]; -parseDuration["years"] = parseDuration["year"]; - -export class ProtectionSettingValidationError extends Error {}; - -/* - * @param TChange Type for individual pieces of data (e.g. `string`) - * @param TValue Type for overall value of this setting (e.g. `string[]`) - */ -export class AbstractProtectionSetting extends EventEmitter { - // the current value of this setting - value: TValue - - /* - * Deserialise a value for this setting type from a string - * - * @param data Serialised value - * @returns Deserialised value or undefined if deserialisation failed - */ - fromString(data: string): TChange | undefined { - throw new Error("not Implemented"); - } - - /* - * Check whether a given value is valid for this setting - * - * @param data Setting value - * @returns Validity of provided value - */ - validate(data: TChange): boolean { - throw new Error("not Implemented"); - } - - /* - * Store a value in this setting, only to be used after `validate()` - * @param data Validated setting value - */ - setValue(data: TValue) { - this.value = data; - this.emit("set", data); - } -} -export class AbstractProtectionListSetting extends AbstractProtectionSetting { - /* - * Add `data` to the current setting value, and return that new object - * - * @param data Value to add to the current setting value - * @returns The potential new value of this setting object - */ - addValue(data: TChange): TValue { - throw new Error("not Implemented"); - } - - /* - * Remove `data` from the current setting value, and return that new object - * - * @param data Value to remove from the current setting value - * @returns The potential new value of this setting object - */ - removeValue(data: TChange): TValue { - throw new Error("not Implemented"); - } -} -export function isListSetting(object: any): object is AbstractProtectionListSetting { - return object instanceof AbstractProtectionListSetting; -} - - -export class StringProtectionSetting extends AbstractProtectionSetting { - value = ""; - fromString = (data: string): string => data; - validate = (data: string): boolean => true; -} -export class StringListProtectionSetting extends AbstractProtectionListSetting { - value: string[] = []; - fromString = (data: string): string => data; - validate = (data: string): boolean => true; - addValue(data: string): string[] { - this.emit("add", data); - this.value.push(data); - return this.value; - } - removeValue(data: string): string[] { - this.emit("remove", data); - this.value = this.value.filter(i => i !== data); - return this.value; - } -} - -export class StringSetProtectionSetting extends AbstractProtectionListSetting> { - value: Set = new Set(); - fromString = (data: string): string => data; - validate = (data: string): boolean => true; - addValue(data: string): Set { - this.emit("add", data); - this.value.add(data); - return this.value; - } - removeValue(data: string): Set { - this.emit("remove", data); - this.value.delete(data); - return this.value; - } -} - -// A list of strings that match the glob pattern @*:* -export class MXIDListProtectionSetting extends StringListProtectionSetting { - // validate an individual piece of data for this setting - namely a single mxid - validate = (data: string) => /^@\S+:\S+$/.test(data); -} - -export class NumberProtectionSetting extends AbstractProtectionSetting { - min: number|undefined; - max: number|undefined; - - constructor( - defaultValue: number, - min: number|undefined = undefined, - max: number|undefined = undefined - ) { - super(); - this.setValue(defaultValue); - this.min = min; - this.max = max; - } - - fromString(data: string) { - let number = Number(data); - return isNaN(number) ? undefined : number; - } - validate(data: number) { - return (!isNaN(data) - && (this.min === undefined || this.min <= data) - && (this.max === undefined || data <= this.max)) - } -} - -/** - * A setting holding durations, in ms. - * - * When parsing, the setting expects a unit, e.g. "1ms". - */ -export class DurationMSProtectionSetting extends AbstractProtectionSetting { - constructor( - defaultValue: number, - public readonly minMS: number|undefined = undefined, - public readonly maxMS: number|undefined = undefined - ) { - super(); - this.setValue(defaultValue); - } - - fromString(data: string) { - let number = parseDuration(data); - return isNaN(number) ? undefined : number; - } - validate(data: number) { - return (!isNaN(data) - && (this.minMS === undefined || this.minMS <= data) - && (this.maxMS === undefined || data <= this.maxMS)) - } -} diff --git a/src/protections/RedactionSynchronisation.ts b/src/protections/RedactionSynchronisation.ts new file mode 100644 index 00000000..21511cfd --- /dev/null +++ b/src/protections/RedactionSynchronisation.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +// README: This protection really exists as a stop gap to bring over redaction +// functionality over from Draupnir, while we figure out how to add redaction +// 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, 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; + +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, + [], + ['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.protectedRoomsConfig.allRooms.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)); + } + 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 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); + } +} + +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 + )) + } +}); diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 01b4bd15..366442a9 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -25,49 +25,106 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Protection } from "./Protection"; -import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings"; -import { Mjolnir } from "../Mjolnir"; +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, +} + +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, + } + ) +}) + /* * Hold a list of users trusted to make reports, and enact consequences on * events that surpass configured report count thresholds */ -export class TrustedReporters extends Protection { - private recentReported = new Map>(); - - settings = { - mxids: new MXIDListProtectionSetting(), - alertThreshold: new NumberProtectionSetting(3), - // -1 means 'disabled' - redactThreshold: new NumberProtectionSetting(-1), - banThreshold: new NumberProtectionSetting(-1) - }; - - constructor() { - super(); +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 get name(): string { - return 'TrustedReporters'; - } - public get description(): string { - return "Count reports from trusted reporters and take a configured action"; - } - - public async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { - if (!this.settings.mxids.value.includes(reporterId)) { + public async handleEventReport(report: EventReport): Promise> { + if (!this.settings.mxids.has(report.sender)) { // not a trusted user, we're not interested - return + return Ok(undefined); } - let reporters = this.recentReported.get(event.id); + 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(event.id, reporters); + 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]; @@ -75,29 +132,29 @@ export class TrustedReporters extends Protection { } } - reporters.add(reporterId); + reporters.add(report.sender); let met: string[] = []; - if (reporters.size === this.settings.alertThreshold.value) { + 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.value) { + if (reporters.size === this.settings.redactThreshold) { met.push("redact"); - await mjolnir.client.redactEvent(roomId, event.id, "abuse detected"); + await this.eventConsequences.consequenceForEvent(report.room_id, report.event_id, "abuse detected"); } - if (reporters.size === this.settings.banThreshold.value) { + if (reporters.size === this.settings.banThreshold) { met.push("ban"); - await mjolnir.client.banUser(event.userId, roomId, "abuse detected"); + await this.userConsequences.consequenceForUserInRoom(report.room_id, report.event.sender, "abuse detected"); } - if (met.length > 0) { - await mjolnir.client.sendMessage(mjolnir.config.managementRoom, { + await this.draupnir.client.sendMessage(this.draupnir.config.managementRoom, { msgtype: "m.notice", - body: `message ${event.id} reported by ${[...reporters].join(', ')}. ` + body: `message ${report.event_id} reported by ${[...reporters].join(', ')}. ` + `actions: ${met.join(', ')}` }); } + return Ok(undefined) } } diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index 80e26cb4..b144b28e 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -25,77 +25,116 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Protection } from "./Protection"; -import { ConsequenceBan, ConsequenceRedact } from "./consequence"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, LogService } from "matrix-bot-sdk"; -import { isTrueJoinEvent } from "../utils"; - -export class WordList extends Protection { - - settings = {}; +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'); + +type WordListCapabilities = { + userConsequences: UserConsequences; + eventConsequences: EventConsequences; +}; + +type WordListSettings = {}; + +type WordListDescription = ProtectionDescription; + +describeProtection({ + 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 + ) + ); + } +}); - private justJoined: { [roomId: string]: { [username: string]: Date} } = {}; +export class WordListProtection extends AbstractProtection implements Protection { + private justJoined: { [roomID: StringRoomID]: { [username: StringUserID]: Date} } = {}; private badWords?: RegExp; - constructor() { - super(); + 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 get name(): string { - return 'WordList'; - } - public get description(): string { - return "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."; - } - - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - - const content = event['content'] || {}; - const minsBeforeTrusting = mjolnir.config.protections.wordlist.minutesBeforeTrusting; - + public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { + const roomID = revision.room.toRoomIDOrAlias(); + const minsBeforeTrusting = this.draupnir.config.protections.wordlist.minutesBeforeTrusting; if (minsBeforeTrusting > 0) { - if (!this.justJoined[roomId]) this.justJoined[roomId] = {}; + for (const change of changes) { + if (!this.justJoined[roomID]) this.justJoined[roomID] = {}; - // 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 (event['type'] === 'm.room.member') { - if (isTrueJoinEvent(event)) { + // 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(); - this.justJoined[roomId][event['state_key']] = now; - LogService.info("WordList", `${event['state_key']} joined ${roomId} at ${now.toDateString()}`); - } else if (content['membership'] === 'leave' || content['membership'] === 'ban') { - delete this.justJoined[roomId][event['sender']] + this.justJoined[roomID][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) { + delete this.justJoined[roomID][change.userID] } - - return; } } + return Ok(undefined); + } - if (event['type'] === 'm.room.message') { - const message = content['formatted_body'] || content['body'] || null; - if (!message) { - return; + 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 = (event.content !== undefined && 'formatted_body' in event.content && event.content?.['formatted_body']) || event.content?.['body']; + if (!message === undefined) { + return Ok(undefined); } + const roomID = room.toRoomIDOrAlias(); // Check conditions first if (minsBeforeTrusting > 0) { - const joinTime = this.justJoined[roomId][event['sender']] + const joinTime = this.justJoined[roomID][event['sender']] if (joinTime) { // 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) { - delete this.justJoined[roomId][event['sender']] // Remove the user - LogService.info("WordList", `${event['sender']} is no longer considered suspect`); - return + delete this.justJoined[roomID][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 + return Ok(undefined); } } if (!this.badWords) { @@ -105,20 +144,17 @@ export class WordList extends Protection { }; // Create a mega-regex from all the tiny words. - const words = mjolnir.config.protections.wordlist.words.filter(word => word.length !== 0).map(escapeRegExp); - if (words.length === 0) { - mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "WordList", `Someone turned on the word list protection without configuring any words. Disabling.`); - this.enabled = false; - return; - } + 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); + const match = this.badWords!.exec(message ?? ''); if (match) { const reason = `bad word: ${match[0]}`; - return [new ConsequenceBan(reason), new ConsequenceRedact(reason)]; + 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/consequence.ts b/src/protections/consequence.ts deleted file mode 100644 index 26c11c18..00000000 --- a/src/protections/consequence.ts +++ /dev/null @@ -1,44 +0,0 @@ -export class Consequence { - /* - * A requested action to take against a user after detected abuse - * - * @param name The name of the consequence being requested - * @param reason Brief explanation of why we're taking an action, printed to management room. - * this will be HTML escaped before printing, just in case it has user-provided data - */ - constructor(public name: string, public reason: string) { } -} - -export class ConsequenceAlert extends Consequence { - /* - * Request an alert to be created after detected abuse - * - * @param reason Brief explanation of why we're taking an action, printed to management room. - * this will be HTML escaped before printing, just in case it has user-provided data - */ - constructor(reason: string) { - super("alert", reason); - } -} -export class ConsequenceRedact extends Consequence { - /* - * Request a message redaction after detected abuse - * - * @param reason Brief explanation of why we're taking an action, printed to management room. - * this will be HTML escaped before printing, just in case it has user-provided data - */ - constructor(reason: string) { - super("redact", reason); - } -} -export class ConsequenceBan extends Consequence { - /* - * Request a ban after detected abuse - * - * @param reason Brief explanation of why we're taking an action, printed to management room. - * this will be HTML escaped before printing, just in case it has user-provided data - */ - constructor(reason: string) { - super("ban", reason); - } -} diff --git a/src/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index 8806921f..8dbad28e 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -24,16 +24,15 @@ 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, MatrixClient } from "matrix-bot-sdk" -import { IRoomUpdateError, RoomUpdateException } from "../models/RoomUpdateError"; +import { LogLevel } from "matrix-bot-sdk" import { redactUserMessagesIn } from "../utils"; import ManagementRoomOutput from "../ManagementRoomOutput"; -import { MatrixSendClient } from "../MatrixEmitter"; -import { CommandExceptionKind } from "../commands/interface-manager/CommandException"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +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: string; + readonly roomID: StringRoomID; /** * Carry out the redaction. * Called by the EventRedactionQueue. @@ -51,22 +50,20 @@ export interface QueuedRedaction { * Redacts all of the messages a user has sent to one room. */ export class RedactUserInRoom implements QueuedRedaction { - userId: string; - roomId: string; - - constructor(userId: string, roomId: string) { - this.userId = userId; - this.roomId = roomId; + constructor( + public readonly userID: StringUserID, + public readonly roomID: StringRoomID, + ) { } - public async redact(client: MatrixClient, 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; + return redaction.userID === this.userID && redaction.roomID === this.roomID; } else { return false; } @@ -87,7 +84,7 @@ export class EventRedactionQueue { * @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)); + return !!this.toRedact.get(redaction.roomID)?.find(r => r.redactionEqual(redaction)); } /** @@ -99,11 +96,11 @@ export class EventRedactionQueue { if (this.has(redaction)) { return false; } else { - let entry = this.toRedact.get(redaction.roomId); + let entry = this.toRedact.get(redaction.roomID); if (entry) { entry.push(redaction); } else { - this.toRedact.set(redaction.roomId, [redaction]); + this.toRedact.set(redaction.roomID, [redaction]); } return true; } @@ -119,8 +116,8 @@ export class EventRedactionQueue { * @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?: string): Promise { - const errors: IRoomUpdateError[] = []; + public async process(client: MatrixSendClient, managementRoom: ManagementRoomOutput, limitToRoomID?: StringRoomID): Promise { + const errors: RoomUpdateError[] = []; const redact = async (currentBatch: QueuedRedaction[]) => { for (const redaction of currentBatch) { try { @@ -128,8 +125,8 @@ export class EventRedactionQueue { } catch (e) { const message = e.message || (e.body ? e.body.error : ''); const error = new RoomUpdateException( - redaction.roomId, - CommandExceptionKind.Unknown, + MatrixRoomReference.fromRoomID(redaction.roomID), + ActionExceptionKind.Unknown, e, message ); @@ -137,11 +134,11 @@ export class EventRedactionQueue { } } } - if (limitToRoomId) { + if (limitToRoomID) { // There might not actually be any queued redactions for this room. - let queuedRedactions = this.toRedact.get(limitToRoomId); + let queuedRedactions = this.toRedact.get(limitToRoomID); if (queuedRedactions) { - this.toRedact.delete(limitToRoomId); + this.toRedact.delete(limitToRoomID); await redact(queuedRedactions); } } else { diff --git a/src/queues/ProtectedRoomActivityTracker.ts b/src/queues/ProtectedRoomActivityTracker.ts index afc931bf..8729fe8e 100644 --- a/src/queues/ProtectedRoomActivityTracker.ts +++ b/src/queues/ProtectedRoomActivityTracker.ts @@ -80,8 +80,8 @@ export class ProtectedRoomActivityTracker { public protectedRoomsByActivity(): string[] { if (!this.activeRoomsCache) { this.activeRoomsCache = [...this.protectedRoomActivities] - .sort((a, b) => b[1] - a[1]) - .map(pair => pair[0]); + .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 ce526469..08698291 100644 --- a/src/queues/ThrottlingQueue.ts +++ b/src/queues/ThrottlingQueue.ts @@ -26,7 +26,7 @@ limitations under the License. */ import { LogLevel } from "matrix-bot-sdk"; -import { Mjolnir } from "../Mjolnir"; +import ManagementRoomOutput from "../ManagementRoomOutput"; export type Task = (queue: ThrottlingQueue) => Promise; @@ -58,7 +58,7 @@ export class ThrottlingQueue { * * @param delayMS The default delay between executing two tasks, in ms. */ - constructor(private mjolnir: Mjolnir, delayMS: number) { + constructor(private managementRoomOutput: ManagementRoomOutput, delayMS: number) { this.timeout = null; this.delayMS = delayMS; this._tasks = []; @@ -189,7 +189,7 @@ export class ThrottlingQueue { try { await task(); } catch (ex) { - await this.mjolnir.managementRoomOutput.logMessage( + await this.managementRoomOutput.logMessage( LogLevel.WARN, 'Error while executing task', ex diff --git a/src/queues/UnlistedUserRedactionQueue.ts b/src/queues/UnlistedUserRedactionQueue.ts index d1679b7e..a3fc033e 100644 --- a/src/queues/UnlistedUserRedactionQueue.ts +++ b/src/queues/UnlistedUserRedactionQueue.ts @@ -25,8 +25,8 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ import { LogLevel, LogService } from "matrix-bot-sdk"; -import { Permalinks } from "../commands/interface-manager/Permalinks"; -import { Mjolnir } from "../Mjolnir"; +import { Permalinks, RoomEvent, StringRoomID, StringUserID } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; /** * A queue of users who have been flagged for redaction typically by the flooding or image protection. @@ -36,35 +36,35 @@ import { Mjolnir } from "../Mjolnir"; * to view a room until a moderator can investigate. */ export class UnlistedUserRedactionQueue { - private usersToRedact: Set = new Set(); + private usersToRedact = new Set(); constructor() { } - public addUser(userId: string) { - this.usersToRedact.add(userId); + public addUser(userID: StringUserID) { + this.usersToRedact.add(userID); } - public removeUser(userId: string) { - this.usersToRedact.delete(userId); + public removeUser(userID: StringUserID) { + this.usersToRedact.delete(userID); } - public isUserQueued(userId: string): boolean { - return this.usersToRedact.has(userId); + public isUserQueued(userID: StringUserID): boolean { + return this.usersToRedact.has(userID); } - public async handleEvent(roomId: string, event: any, mjolnir: Mjolnir) { + public async handleEvent(roomID: StringRoomID, event: RoomEvent, draupnir: Draupnir) { if (this.isUserQueued(event['sender'])) { - const permalink = Permalinks.forEvent(roomId, event['event_id']); + const permalink = Permalinks.forEvent(roomID, event['event_id']); try { LogService.info("AutomaticRedactionQueue", `Redacting event because the user is listed as bad: ${permalink}`) - if (!mjolnir.config.noop) { - await mjolnir.client.redactEvent(roomId, event['event_id']); + if (!draupnir.config.noop) { + await draupnir.client.redactEvent(roomID, event['event_id']); } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); + await draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); } } catch (e) { - mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); + 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 35ac536d..d4912ec5 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -30,8 +30,8 @@ import { LogService, UserID } from "matrix-bot-sdk"; import { htmlToText } from "html-to-text"; import { htmlEscape } from "../utils"; import { JSDOM } from 'jsdom'; -import { EventEmitter } from 'events'; -import { Mjolnir } from "../Mjolnir"; +import { Draupnir } from "../Draupnir"; +import { ReactionContent, RoomEvent, StringEventID, StringRoomID, StringUserID, Task, Value, isError } 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]`. @@ -85,26 +85,18 @@ enum Kind { /** * A class designed to respond to abuse reports. */ -export class ReportManager extends EventEmitter { +export class ReportManager { private displayManager: DisplayManager; - constructor(public mjolnir: Mjolnir) { - super(); - // Configure bot interactions. - mjolnir.matrixEmitter.on("room.event", async (roomId, event) => { - try { - switch (event["type"]) { - case "m.reaction": { - await this.handleReaction({ roomId, event }); - break; - } - } - } catch (ex) { - LogService.error("ReportManager", "Uncaught error while handling an event", ex); - } - }); + 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') { + Task(this.handleReaction({ roomID, event })); + } + } + /** * Display an incoming abuse report received, e.g. from the /report Matrix API. * @@ -112,51 +104,62 @@ export class ReportManager extends EventEmitter { * * 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 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 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: string, reporterId: string, event: any, reason?: string }) { - this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason }); - if (this.mjolnir.config.displayReports) { - return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId }); + 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: reason + }) + if (this.draupnir.config.displayReports) { + return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationroomID: this.draupnir.managementRoomID }); } } /** * Handle a reaction to an abuse report. * - * @param roomId The room in which the reaction took place. + * @param roomID The room in which the reaction took place. * @param event The reaction. */ - public async handleReaction({ roomId, event }: { roomId: string, event: any }) { - if (event.sender === await this.mjolnir.client.getUserId()) { + 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.mjolnir.managementRoomId) { + if (roomID !== this.draupnir.managementRoomID) { // Let's not accept commands in rooms other than the management room. return; } - let relation; - try { - relation = event["content"]["m.relates_to"]!; - } catch (ex) { + 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 { - let originalEvent = await this.mjolnir.client.getEvent(roomId, relation.event_id); - if (originalEvent.sender !== await this.mjolnir.client.getUserId()) { + let 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; @@ -188,7 +191,7 @@ export class ReportManager extends EventEmitter { if (confirmationReport) { // Extract the action and the decision. - let matches = relation.key.match(REACTION_CONFIRMATION); + let matches = relation.key?.match(REACTION_CONFIRMATION); if (!matches) { // Invalid key. return; @@ -212,19 +215,19 @@ export class ReportManager extends EventEmitter { await this.executeAction({ label: matches[1], report: confirmationReport, - successEventId: confirmationReport.notification_event_id, + successEventId: confirmationReport.notification_event_id as StringEventID, failureEventId: relation.event_id, onSuccessRemoveEventId: relation.event_id, - moderationRoomId: roomId + moderationRoomId: roomID, }) } else { LogService.info("ReportManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]); - this.mjolnir.client.redactEvent(this.mjolnir.managementRoomId, relation.event_id, "Action cancelled"); + this.draupnir.client.redactEvent(this.draupnir.managementRoomID, relation.event_id, "Action cancelled"); } return; } else if (initialNoticeReport) { - let matches = relation.key.match(REACTION_ACTION); + let matches = relation.key?.match(REACTION_ACTION); if (!matches) { // Invalid key. return; @@ -253,21 +256,22 @@ export class ReportManager extends EventEmitter { [ABUSE_ACTION_CONFIRMATION_KEY]: confirmationReport }; - let requestConfirmationEventId = await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, confirmation); - await this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { + let 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.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { + 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]); @@ -275,8 +279,8 @@ export class ReportManager extends EventEmitter { label, report: confirmationReport, successEventId: relation.event_id, - failureEventId: relation.eventId, - moderationRoomId: roomId + failureEventId: relation.event_id, + moderationRoomId: roomID }) } } @@ -296,7 +300,21 @@ export class ReportManager extends EventEmitter { * @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: string, failureEventId: string, onSuccessRemoveEventId?: string, moderationRoomId: string }) { + private async executeAction({ + label, + report, + successEventId, + failureEventId, + onSuccessRemoveEventId, + moderationRoomId + }: { + label: string, + report: IReportWithAction, + successEventId: StringEventID, + failureEventId: StringEventID, + onSuccessRemoveEventId?: StringEventID, + moderationRoomId: StringRoomID + }) { let action: IUIAction | undefined = ACTIONS.get(label); if (!action) { return; @@ -305,7 +323,7 @@ export class ReportManager extends EventEmitter { let response; try { // Check security. - if (moderationRoomId === this.mjolnir.managementRoomId) { + if (moderationRoomId === this.draupnir.managementRoomID) { // Always accept actions executed from the management room. } else { throw new Error("Security error: Cannot execute this action."); @@ -315,14 +333,14 @@ export class ReportManager extends EventEmitter { error = ex; } if (error) { - this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { + this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.reaction", { "m.relates_to": { "rel_type": "m.annotation", "event_id": failureEventId, "key": `${action.emoji} ❌` } }); - this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.notice", { + this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.notice", { "body": error.message || "", "m.relationship": { "rel_type": "m.reference", @@ -330,7 +348,7 @@ export class ReportManager extends EventEmitter { } }) } else { - this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { + this.draupnir.client.sendEvent(this.draupnir.managementRoomID, "m.reaction", { "m.relates_to": { "rel_type": "m.annotation", "event_id": successEventId, @@ -338,10 +356,10 @@ export class ReportManager extends EventEmitter { } }); if (onSuccessRemoveEventId) { - this.mjolnir.client.redactEvent(this.mjolnir.managementRoomId, onSuccessRemoveEventId, "Action complete"); + this.draupnir.client.redactEvent(this.draupnir.managementRoomID, onSuccessRemoveEventId, "Action complete"); } if (response) { - this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { + this.draupnir.client.sendMessage(this.draupnir.managementRoomID, { msgtype: "m.notice", "formatted_body": response, format: "org.matrix.custom.html", @@ -439,7 +457,7 @@ interface IUIAction { * * @param report Details on the abuse report. */ - canExecute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise; + canExecute(manager: ReportManager, report: IReport, moderationroomID: string): Promise; /** * A human-readable title to display for the end-user. @@ -458,7 +476,7 @@ interface IUIAction { /** * Attempt to execute the action. */ - execute(manager: ReportManager, report: IReport, moderationRoomId: string, displayManager: DisplayManager): Promise; + execute(manager: ReportManager, report: IReport, moderationroomID: string, displayManager: DisplayManager): Promise; } /** @@ -478,7 +496,7 @@ class IgnoreBadReport implements IUIAction { return "Ignore bad report"; } public async execute(manager: ReportManager, report: IReportWithAction): Promise { - await manager.mjolnir.client.sendEvent(manager.mjolnir.managementRoomId, "m.room.message", + await manager.draupnir.client.sendEvent(manager.draupnir.managementRoomID, "m.room.message", { msgtype: "m.notice", body: "Report classified as invalid", @@ -505,7 +523,7 @@ class RedactMessage implements IUIAction { public needsConfirmation = true; public async canExecute(manager: ReportManager, report: IReport): Promise { try { - return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.RedactEvents); + return await manager.draupnir.client.userHasPowerLevelForAction(await manager.draupnir.client.getUserId(), report.room_id, PowerLevelAction.RedactEvents); } catch (ex) { return false; } @@ -516,8 +534,8 @@ class RedactMessage implements IUIAction { 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.mjolnir.client.redactEvent(report.room_id, 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; } } @@ -531,7 +549,7 @@ class KickAccused implements IUIAction { public needsConfirmation = true; public async canExecute(manager: ReportManager, report: IReport): Promise { try { - return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.Kick); + return await manager.draupnir.client.userHasPowerLevelForAction(await manager.draupnir.client.getUserId(), report.room_id, PowerLevelAction.Kick); } catch (ex) { return false; } @@ -543,7 +561,7 @@ class KickAccused implements IUIAction { return `Kick ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute(manager: ReportManager, report: IReport): Promise { - await manager.mjolnir.client.kickUser(report.accused_id, report.room_id); + await manager.draupnir.client.kickUser(report.accused_id, report.room_id); return; } } @@ -557,7 +575,7 @@ class MuteAccused implements IUIAction { public needsConfirmation = true; public async canExecute(manager: ReportManager, report: IReport): Promise { try { - return await manager.mjolnir.client.userHasPowerLevelFor(await manager.mjolnir.client.getUserId(), report.room_id, "m.room.power_levels", true); + return await manager.draupnir.client.userHasPowerLevelFor(await manager.draupnir.client.getUserId(), report.room_id, "m.room.power_levels", true); } catch (ex) { return false; } @@ -569,7 +587,7 @@ class MuteAccused implements IUIAction { return `Mute ${htmlEscape(report.accused_id)} in room ${htmlEscape(report.room_alias_or_id)}`; } public async execute(manager: ReportManager, report: IReport): Promise { - await manager.mjolnir.client.setUserPowerLevel(report.accused_id, report.room_id, -1); + await manager.draupnir.client.setUserPowerLevel(report.accused_id, report.room_id, -1); return; } } @@ -583,7 +601,7 @@ class BanAccused implements IUIAction { public needsConfirmation = true; public async canExecute(manager: ReportManager, report: IReport): Promise { try { - return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.Ban); + return await manager.draupnir.client.userHasPowerLevelForAction(await manager.draupnir.client.getUserId(), report.room_id, PowerLevelAction.Ban); } catch (ex) { return false; } @@ -595,7 +613,7 @@ class BanAccused implements IUIAction { return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute(manager: ReportManager, report: IReport): Promise { - await manager.mjolnir.client.banUser(report.accused_id, report.room_id); + await manager.draupnir.client.banUser(report.accused_id, report.room_id); return; } } @@ -616,15 +634,15 @@ class Help implements IUIAction { public async help(_manager: ReportManager, _report: IReport): Promise { return "This help"; } - public async execute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise { + public async execute(manager: ReportManager, report: IReport, moderationroomID: string): Promise { // Produce a html list of actions, in the order specified by ACTION_LIST. let list: string[] = []; for (let action of ACTION_LIST) { - if (await action.canExecute(manager, report, moderationRoomId)) { + if (await action.canExecute(manager, report, moderationroomID)) { list.push(`
    3. ${action.emoji} ${await action.help(manager, report)}
    4. `); } } - if (!await ACTIONS.get("ban-accused")!.canExecute(manager, report, moderationRoomId)) { + if (!await ACTIONS.get("ban-accused")!.canExecute(manager, report, moderationroomID)) { list.push(`
    5. Some actions were disabled because Mjölnir is not moderator in room ${htmlEscape(report.room_alias_or_id)}
    6. `) } let body = `
        ${list.join("\n")}
      `; @@ -639,13 +657,13 @@ 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.mjolnir.managementRoomId) { + 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.mjolnir.client.getEvent(report.room_id, report.event_id); + await manager.draupnir.client.getEvent(report.room_id, report.event_id); } catch (ex) { // We can't fetch the event. return false; @@ -656,20 +674,20 @@ class EscalateToServerModerationRoom implements IUIAction { return "Escalate"; } public async help(manager: ReportManager, _report: IReport): Promise { - return `Escalate report to ${getHomeserver(await manager.mjolnir.client.getUserId())} server moderators`; + return `Escalate report to ${getHomeserver(await manager.draupnir.client.getUserId())} server moderators`; } - public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string, displayManager: DisplayManager): Promise { - let event = await manager.mjolnir.client.getEvent(report.room_id, report.event_id); + public async execute(manager: ReportManager, report: IReport, _moderationroomID: string, displayManager: DisplayManager): Promise { + let 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; + // - `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.mjolnir.managementRoomId, event }); + await displayManager.displayReportAndUI({ kind: Kind.ESCALATED_REPORT, reporterId: report.reporter_id, moderationroomID: manager.draupnir.managementRoomID, event }); return; } } @@ -692,17 +710,17 @@ class DisplayManager { * @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. + * @param moderationroomID The room in which the report and ui will be displayed. MUST be checked. */ - public async displayReportAndUI(args: { kind: Kind, event: any, reporterId: string, reason?: string, nature?: string, moderationRoomId: string, error?: string }) { - let { kind, event, reporterId, reason, nature, moderationRoomId, error } = args; + public async displayReportAndUI(args: { kind: Kind, event: any, reporterId: string, reason?: string, nature?: string, moderationroomID: string, error?: string }) { + let { kind, event, reporterId, reason, nature, moderationroomID, error } = args; - let roomId = event["room_id"]!; + let roomID = event["room_id"]!; let eventId = event["event_id"]!; - let roomAliasOrId = roomId; + let roomAliasOrId = roomID; try { - roomAliasOrId = await this.owner.mjolnir.client.getPublishedAlias(roomId) || roomId; + roomAliasOrId = await this.owner.draupnir.client.getPublishedAlias(roomID) || roomID; } catch (ex) { // Ignore. } @@ -732,17 +750,17 @@ class DisplayManager { let reporterDisplayName: string, accusedDisplayName: string; try { - reporterDisplayName = (await this.owner.mjolnir.client.getUserProfile(reporterId))["displayname"] || reporterId; + reporterDisplayName = (await this.owner.draupnir.client.getUserProfile(reporterId))["displayname"] || reporterId; } catch (ex) { reporterDisplayName = ""; } try { - accusedDisplayName = (await this.owner.mjolnir.client.getUserProfile(accusedId))["displayname"] || accusedId; + accusedDisplayName = (await this.owner.draupnir.client.getUserProfile(accusedId))["displayname"] || accusedId; } catch (ex) { accusedDisplayName = ""; } - let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`; + let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomID)}/${encodeURIComponent(eventId)}`; let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrId)}`; let eventTimestamp; @@ -876,7 +894,7 @@ class DisplayManager { accused_id: accusedId, reporter_id: reporterId, event_id: eventId, - room_id: roomId, + room_id: roomID, room_alias_or_id: roomAliasOrId, }; let notice = { @@ -887,15 +905,15 @@ class DisplayManager { [ABUSE_REPORT_KEY]: report }; - let noticeEventId = await this.owner.mjolnir.client.sendMessage(this.owner.mjolnir.managementRoomId, notice); + let noticeEventId = await this.owner.draupnir.client.sendMessage(this.owner.draupnir.managementRoomID, notice); if (kind !== Kind.ERROR) { // Now let's display buttons. for (let [label, action] of ACTIONS) { // Display buttons for actions that can be executed. - if (!await action.canExecute(this.owner, report, moderationRoomId)) { + if (!await action.canExecute(this.owner, report, moderationroomID)) { continue; } - await this.owner.mjolnir.client.sendEvent(this.owner.mjolnir.managementRoomId, "m.reaction", { + await this.owner.draupnir.client.sendEvent(this.owner.draupnir.managementRoomID, "m.reaction", { "m.relates_to": { "rel_type": "m.annotation", "event_id": noticeEventId, diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index e298cabe..4ba35b97 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -25,16 +25,27 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { ReportManager } from './ReportManager'; -import { LogLevel } from "matrix-bot-sdk"; +import { LogLevel, LogService } from "matrix-bot-sdk"; +import ManagementRoomOutput from "../ManagementRoomOutput"; +import { Draupnir } from "../Draupnir"; +import { ActionException, ActionExceptionKind, Ok, SynapseReport, Value, isError } from "matrix-protection-suite"; + +/** + * Synapse will tell us where we last got to on polling reports, so we need + * to store that for pagination on further polls + */ +export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll"; class InvalidStateError extends Error { } +export type ReportPollSetting = { from: number }; + /** * A class to poll synapse's report endpoint, so we can act on new reports * - * @param mjolnir The running Mjolnir instance + * @param draupnir The running Draupnir instance * @param manager The report manager in to which we feed new reports */ export class ReportPoller { @@ -49,7 +60,7 @@ export class ReportPoller { private timeout: ReturnType | null = null; constructor( - private mjolnir: Mjolnir, + private draupnir: Draupnir, private manager: ReportManager, ) { } @@ -72,11 +83,11 @@ export class ReportPoller { private async getAbuseReports() { let response_: { - event_reports: { room_id: string, event_id: string, sender: string, reason: string }[], + event_reports: unknown[], next_token: number | undefined } | undefined; try { - response_ = await this.mjolnir.client.doRequest( + response_ = await this.draupnir.client.doRequest( "GET", "/_synapse/admin/v1/event_reports", { @@ -86,32 +97,39 @@ export class ReportPoller { } ); } catch (ex) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); + await this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); return; } const response = response_!; - for (let report of response.event_reports) { - if (!this.mjolnir.protectedRoomsTracker.isProtectedRoom(report.room_id)) { + 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; } - - let event: any; // `any` because `handleServerAbuseReport` uses `any` - try { - event = (await this.mjolnir.client.doRequest( - "GET", - `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` - )).event; - } catch (ex) { - this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); + 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)) { + 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, + roomID: report.room_id, reporterId: report.sender, event: event, - reason: report.reason, + reason: report.reason ?? undefined, }); } @@ -123,9 +141,9 @@ export class ReportPoller { if (response.next_token !== undefined) { this.from = response.next_token; try { - await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); + await this.draupnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); } catch (ex) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); + await this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); } } } @@ -136,12 +154,25 @@ export class ReportPoller { try { await this.getAbuseReports() } catch (ex) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); + await this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); } this.schedulePoll(); } - public start(startFrom: number) { + 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 { + managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet"); + } + } + return reportPollSetting; + } + public start({from: startFrom }: ReportPollSetting) { if (this.timeout === null) { this.from = startFrom; this.schedulePoll(); diff --git a/src/utils.ts b/src/utils.ts index 5ec7c71a..34aa7c1c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,8 +40,8 @@ import * as _ from '@sentry/tracing'; // Performing the import activates tracing import ManagementRoomOutput from "./ManagementRoomOutput"; import { IConfig } from "./config"; -import { MatrixSendClient } from "./MatrixEmitter"; import { Gauge } from "prom-client"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; // Define a few aliases to simplify parsing durations. @@ -453,7 +453,7 @@ function patchMatrixClientForRetry() { let isMatrixClientPatchedForPrototypePollution = false; -function jsonReviver(key: string, value: any): any { +export function jsonReviver(key: string, value: any): any { if (key === '__proto__' || key === 'constructor') { return undefined; } else { diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 76492fae..a1507236 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -27,10 +27,10 @@ limitations under the License. import { Server } from "http"; import express from "express"; -import { LogService, MatrixClient } from "matrix-bot-sdk"; -import RuleServer from "../models/RuleServer"; +import { MatrixClient } from "matrix-bot-sdk"; import { ReportManager } from "../report/ReportManager"; import { IConfig } from "../config"; +import { StringEventID, StringRoomID } from "matrix-protection-suite"; /** @@ -38,13 +38,13 @@ import { IConfig } from "../config"; */ const API_PREFIX = "/api/1"; -const AUTHORIZATION: RegExp = new RegExp("Bearer (.*)"); +const AUTHORIZATION = new RegExp("Bearer (.*)"); export class WebAPIs { private webController: express.Express = express(); private httpServer?: Server; - constructor(private reportManager: ReportManager, private readonly config: IConfig, private readonly ruleServer: RuleServer|null) { + constructor(private reportManager: ReportManager, private readonly config: IConfig) { // Setup JSON parsing. this.webController.use(express.json()); } @@ -75,26 +75,10 @@ export class WebAPIs { 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"); - await this.handleReport({ request, response, roomId: request.params.room_id, eventId: request.params.event_id }) + await 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`); } - - // configure ruleServer API. - // FIXME: Doesn't this need some kind of access control? - // See https://github.com/matrix-org/mjolnir/issues/139#issuecomment-1012221479. - if (this.config.web.ruleServer?.enabled) { - const updatesUrl = `${API_PREFIX}/ruleserver/updates`; - LogService.info("WebAPIs", `configuring ${updatesUrl}...`); - if (!this.ruleServer) { - throw new Error("The rule server to use has not been configured for the WebAPIs."); - } - const ruleServer: RuleServer = this.ruleServer; - this.webController.get(updatesUrl, async (request, response) => { - await this.handleRuleServerUpdate(ruleServer, { request, response, since: request.query.since as string}); - }); - LogService.info("WebAPIs", `configuring ${updatesUrl}... DONE`); - } } public stop() { @@ -115,7 +99,7 @@ export class WebAPIs { * @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: string, eventId: string, request: express.Request, response: express.Response }) { + 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; @@ -192,29 +176,17 @@ export class WebAPIs { // // 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); + event = await reporterClient.getEvent(roomID, eventID); } let reason = request.body["reason"]; - await this.reportManager.handleServerAbuseReport({ roomId, reporterId, event, 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); + console.warn("Error responding to an abuse report", roomID, eventID, ex); response.status(503); } } - - async handleRuleServerUpdate(ruleServer: RuleServer, { since, request, response }: { since: string, request: express.Request, response: express.Response }) { - // FIXME Have to do this because express sends keep alive by default and during tests. - // The server will never be able to close because express never closes the sockets, only stops accepting new connections. - // See https://github.com/matrix-org/mjolnir/issues/139#issuecomment-1012221479. - response.set("Connection", "close"); - try { - response.json(ruleServer.getUpdates(since)).status(200); - } catch (ex) { - LogService.error("WebAPIs", `Error responding to a rule server updates request`, since, ex); - } - } } diff --git a/test/appservice/integration/listUnstartedMjolnir.ts b/test/appservice/integration/listUnstartedDraupnirTest.ts similarity index 81% rename from test/appservice/integration/listUnstartedMjolnir.ts rename to test/appservice/integration/listUnstartedDraupnirTest.ts index f8908b0c..9b2fd153 100644 --- a/test/appservice/integration/listUnstartedMjolnir.ts +++ b/test/appservice/integration/listUnstartedDraupnirTest.ts @@ -2,6 +2,7 @@ import expect from "expect"; import { MjolnirAppService } from "../../../src/appservice/AppService"; import { AppservideBotCommandClient } from "../utils/AppserviceBotCommandClient"; import { setupHarness } from "../utils/harness"; +import { isError } from "matrix-protection-suite"; interface Context extends Mocha.Context { appservice?: MjolnirAppService @@ -19,10 +20,12 @@ describe("Just test some commands innit", function() { return Promise.resolve(); // TS7030: Not all code paths return a value. } }); - it("Can list any unstarted mjolnir", async function(this: Context) { + it("Can list any unstarted draupnir", async function(this: Context) { const commandClient = new AppservideBotCommandClient(this.appservice!); const result = await commandClient.sendCommand("list", "unstarted"); - expect(result.isOk()).toBe(true); + 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 8e2765ae..12da9854 100644 --- a/test/appservice/integration/provisionTest.ts +++ b/test/appservice/integration/provisionTest.ts @@ -43,7 +43,7 @@ describe("Test that the app service can provision a mjolnir on invite of the app const managementRoomId = roomsInvitedTo.filter(async roomId => !(await isPolicyRoom(moderator, roomId)))[0]; // Check that the newly provisioned mjolnir is actually responsive. await getFirstReply(moderator, managementRoomId, () => { - return moderator.sendMessage(managementRoomId, { body: `!mjolnir status`, msgtype: 'm.text' }); + 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 2875e69d..d23a0ce7 100644 --- a/test/appservice/integration/webAPITest.ts +++ b/test/appservice/integration/webAPITest.ts @@ -50,7 +50,7 @@ describe("Test that the app service can provision a mjolnir when requested from expect(managementRoomId).toBe(mjolnirDetails.managementRoomId); // Check that the newly provisioned mjolnir is actually responsive. const event = await getFirstReply(moderator, managementRoomId, () => { - return moderator.sendMessage(managementRoomId, { body: `!mjolnir status`, msgtype: 'm.text' }); + 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 acebdec4..ea569aad 100644 --- a/test/appservice/utils/AppserviceBotCommandClient.ts +++ b/test/appservice/utils/AppserviceBotCommandClient.ts @@ -1,15 +1,15 @@ +import { ActionResult } from "matrix-protection-suite"; import { MjolnirAppService } from "../../../src/appservice/AppService"; import { ReadItem } from "../../../src/commands/interface-manager/CommandReader"; import { findCommandTable } from "../../../src/commands/interface-manager/InterfaceCommand"; import { ArgumentStream } from "../../../src/commands/interface-manager/ParameterParsing"; -import { CommandResult } from "../../../src/commands/interface-manager/Validation"; export class AppservideBotCommandClient { constructor(private readonly appservice: MjolnirAppService) { } - public async sendCommand>(...items: ReadItem[]): Promise { + public async sendCommand>(...items: ReadItem[]): Promise { const stream = new ArgumentStream(items); const matchingCommand = findCommandTable("appservice bot").findAMatchingCommand(stream); if (!matchingCommand) { diff --git a/test/appservice/utils/harness.ts b/test/appservice/utils/harness.ts index f76225f8..dc735e9d 100644 --- a/test/appservice/utils/harness.ts +++ b/test/appservice/utils/harness.ts @@ -3,8 +3,8 @@ import { MjolnirAppService } from "../../../src/appservice/AppService"; import { ensureAliasedRoomExists } from "../../integration/mjolnirSetupUtils"; import { read as configRead, IConfig } from "../../../src/appservice/config/config"; import { newTestUser } from "../../integration/clientHelper"; -import PolicyList from "../../../src/models/PolicyList"; 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")); @@ -19,5 +19,5 @@ export async function setupHarness(): Promise { export async function isPolicyRoom(user: MatrixClient, roomId: string): Promise { const createEvent = new CreateEvent(await user.getRoomStateEvent(roomId, "m.room.create", "")); - return PolicyList.ROOM_TYPE_VARIANTS.includes(createEvent.type); + return POLICY_ROOM_TYPE_VARIANTS.includes(createEvent.type); } diff --git a/test/commands/CommandClient.ts b/test/commands/CommandClient.ts deleted file mode 100644 index ab2ac547..00000000 --- a/test/commands/CommandClient.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - */ - -import { MatrixSendClient } from "../../src/MatrixEmitter"; - -/** - * Do the ReadItems need to be readable? - * Probably yes!!! - */ -export class MatrixInterfaceClient { - constructor( - private readonly client: MatrixSendClient, - private readonly commandRoomId: string, - ) { - - } - - public sendCommand -} - -// so we have a few options -// ignore matrix and use the executor result -// try and verify the writen result -// i know which is easier.... diff --git a/test/commands/CommandReaderTest.ts b/test/commands/CommandReaderTest.ts index 15133e29..bb34b2ec 100644 --- a/test/commands/CommandReaderTest.ts +++ b/test/commands/CommandReaderTest.ts @@ -1,6 +1,6 @@ import expect from "expect"; -import { Keyword, readCommand, ReadItem } from "../../src/commands/interface-manager/CommandReader"; -import { MatrixRoomReference } from "../../src/commands/interface-manager/MatrixRoomReference"; +import { Keyword, readCommand } from "../../src/commands/interface-manager/CommandReader"; +import { MatrixRoomAlias, MatrixRoomID } from "matrix-protection-suite"; describe("Can read", function() { it("Can read a simple command with only strings", function() { @@ -11,16 +11,16 @@ describe("Can read", function() { it("Can turn room aliases to room references", function() { const command = "#meow:example.org"; const readItems = readCommand(command); - expect(readItems.at(0)).toBeInstanceOf(MatrixRoomReference); - const roomReference = readItems.at(0) as MatrixRoomReference; - expect(roomReference.toRoomIdOrAlias()).toBe(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(MatrixRoomReference); - const roomReference = readItems.at(0) as MatrixRoomReference; - expect(roomReference.toRoomIdOrAlias()).toBe(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) => { diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 9484a831..3121af21 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -1,8 +1,7 @@ import { strict as assert } from "assert"; - -import { matrixClient } from "./mjolnirSetupUtils"; import { newTestUser } from "./clientHelper"; -import { ReportManager, ABUSE_ACTION_CONFIRMATION_KEY, ABUSE_REPORT_KEY } from "../../src/report/ReportManager"; +import { ABUSE_REPORT_KEY } from "../../src/report/ReportManager"; +import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; /** * Test the ability to turn abuse reports into room messages. @@ -26,13 +25,20 @@ describe("Test: Reporting abuse", async () => { // Note that this version change only affects the actual URL at which reports // are sent. for (let endpoint of ['v3', 'r0']) { - it(`Mjölnir intercepts abuse reports with endpoint ${endpoint}`, async function() { + 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. let notices: any[] = []; - this.mjolnir.client.on("room.event", (roomId, event) => { - if (roomId = this.mjolnir.managementRoomId) { + draupnirSyncClient.on("room.event", (roomId, event) => { + if (roomId = draupnir.managementRoomID) { notices.push(event); } }); @@ -49,7 +55,6 @@ describe("Test: Reporting abuse", async () => { console.log("Test: Reporting abuse - send messages"); // Exchange a few messages. - let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported. let badText = `BAD: ${Math.random()}`; // Will be reported as abuse. let badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse. let badText3 = `BAD: ${Math.random()}`; // Will be reported as abuse. @@ -146,14 +151,14 @@ describe("Test: Reporting abuse", async () => { for (let toFind of reportsToFind) { for (let event of notices) { if ("content" in event && "body" in event.content) { - if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != toFind.eventId) { + if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id !== toFind.eventId) { // Not a report or not our report. continue; } let report = event.content[ABUSE_REPORT_KEY]; let body = event.content.body as string; let matches: Map | null = new Map(); - for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) { + for (let key of Object.keys(REPORT_NOTICE_REGEXPS) as (keyof typeof REPORT_NOTICE_REGEXPS)[]) { let match = body.match(REPORT_NOTICE_REGEXPS[key]); if (match) { console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups); @@ -219,29 +224,32 @@ describe("Test: Reporting abuse", async () => { } } } - }); + } as unknown as Mocha.AsyncFunc); } - it('The redact action works', async function() { + 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"); + } // Listen for any notices that show up. let notices: any[] = []; - this.mjolnir.client.on("room.event", (roomId, event) => { - if (roomId = this.mjolnir.managementRoomId) { + draupnirSyncClient.on("room.event", (roomId, event) => { + if (roomId = draupnir.managementRoomID) { notices.push(event); } }); // Create a moderator. let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }}); - this.mjolnir.client.inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId); - await moderatorUser.joinRoom(this.mjolnir.managementRoomId); + draupnir.client.inviteUser(await moderatorUser.getUserId(), draupnir.managementRoomID); + await moderatorUser.joinRoom(draupnir.managementRoomID); // Create a few users and a room. let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-good-user" }}); let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-bad-user" }}); - let goodUserId = await goodUser.getUserId(); - let badUserId = await badUser.getUserId(); let roomId = await moderatorUser.createRoom({ invite: [await badUser.getUserId()] }); await moderatorUser.inviteUser(await goodUser.getUserId(), roomId); @@ -250,27 +258,16 @@ describe("Test: Reporting abuse", async () => { await goodUser.joinRoom(roomId); // Setup Mjölnir as moderator for our room. - await moderatorUser.inviteUser(await this.mjolnir.client.getUserId(), roomId); - await moderatorUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); + 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. - let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported. let badText = `BAD: ${Math.random()}`; // Will be reported as abuse. - let goodEventId = await goodUser.sendText(roomId, goodText); let badEventId = await badUser.sendText(roomId, badText); - let goodEventId2 = await goodUser.sendText(roomId, goodText); console.log("Test: Reporting abuse - send reports"); - // Time to report. - let reportToFind = { - reporterId: goodUserId, - accusedId: badUserId, - eventId: badEventId, - text: badText, - comment: null, - }; try { await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); } catch (e) { @@ -281,14 +278,14 @@ describe("Test: Reporting abuse", async () => { console.log("Test: Reporting abuse - wait"); await new Promise(resolve => setTimeout(resolve, 1000)); - let mjolnirRooms = new Set(await this.mjolnir.client.getJoinedRooms()); + let 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 (let 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].event_id != badEventId) { + if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id !== badEventId) { // Not a report or not our report. continue; } @@ -301,13 +298,13 @@ describe("Test: Reporting abuse", async () => { // Find the buttons. let buttons: any[] = []; for (let event of notices) { - if (event["type"] != "m.reaction") { + if (event["type"] !== "m.reaction") { continue; } - if (event["content"]["m.relates_to"]["rel_type"] != "m.annotation") { + if (event["content"]["m.relates_to"]["rel_type"] !== "m.annotation") { continue; } - if (event["content"]["m.relates_to"]["event_id"] != noticeId) { + if (event["content"]["m.relates_to"]["event_id"] !== noticeId) { continue; } buttons.push(event); @@ -318,7 +315,7 @@ describe("Test: Reporting abuse", async () => { for (let button of buttons) { if (button["content"]["m.relates_to"]["key"].includes("[redact-message]")) { redactButtonId = button["event_id"]; - await moderatorUser.sendEvent(this.mjolnir.managementRoomId, "m.reaction", button["content"]); + await moderatorUser.sendEvent(draupnir.managementRoomID, "m.reaction", button["content"]); break; } } @@ -338,14 +335,14 @@ describe("Test: Reporting abuse", async () => { console.debug("Not confirm"); continue; } - if (!event["content"]["m.relates_to"]["event_id"] == redactButtonId) { + if (!event["content"]["m.relates_to"]["event_id"] === redactButtonId) { console.debug("Not reaction to redact button"); continue; } // It's the confirm button, click it! confirmEventId = event["event_id"]; - await moderatorUser.sendEvent(this.mjolnir.managementRoomId, "m.reaction", event["content"]); + await moderatorUser.sendEvent(draupnir.managementRoomID, "m.reaction", event["content"]); break; } assert.ok(confirmEventId, "We should have found the confirm button"); @@ -353,7 +350,7 @@ describe("Test: Reporting abuse", async () => { await new Promise(resolve => setTimeout(resolve, 1000)); // This should have redacted the message. - let newBadEvent = await this.mjolnir.client.getEvent(roomId, badEventId); + let 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/acceptInvitesFromSpaceTest.ts b/test/integration/acceptInvitesFromSpaceTest.ts index f6d795f7..cccc5315 100644 --- a/test/integration/acceptInvitesFromSpaceTest.ts +++ b/test/integration/acceptInvitesFromSpaceTest.ts @@ -1,6 +1,6 @@ import { MatrixClient } from "matrix-bot-sdk"; -import { Mjolnir } from "../../src/Mjolnir" import { newTestUser } from "./clientHelper"; +import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; describe("Test: Accept Invites From Space", function() { let client: MatrixClient|undefined; @@ -9,33 +9,36 @@ describe("Test: Accept Invites From Space", function() { await client.start(); }) this.afterEach(async function () { - await client?.stop(); + client?.stop(); }) - it("Mjolnir should accept an invite from a user in a nominated Space", async function() { + it("Mjolnir should accept an invite from a user in a nominated Space", async function(this: DraupnirTestContext) { this.timeout(20000); - const mjolnir: Mjolnir = this.mjolnir!; - const mjolnirUserId = await mjolnir.client.getUserId(); + const draupnir = this.draupnir; + const draupnirSyncClient = draupnirClient(); + if (draupnir === undefined || draupnirSyncClient === null) { + throw new TypeError("fixtures.ts didn't setup Draupnir"); + } const space = await client!.createSpace({ name: "mjolnir space invite test", - invites: [mjolnirUserId], + invites: [draupnir.clientUserID], isPublic: false }); - await this.mjolnir.client.joinRoom(space.roomId); + await draupnir.client.joinRoom(space.roomId); // we're mutating a static object, which may affect other tests :( - mjolnir.config.autojoinOnlyIfManager = false; - mjolnir.config.acceptInvitesFromSpace = space.roomId; + draupnir.config.autojoinOnlyIfManager = false; + draupnir.config.acceptInvitesFromSpace = space.roomId; const promise = new Promise(async resolve => { - const newRoomId = await client!.createRoom({ invite: [mjolnirUserId] }); + const newRoomId = await client!.createRoom({ invite: [draupnir.clientUserID] }); client!.on("room.event", (roomId, event) => { if ( roomId === newRoomId && event.type === "m.room.member" - && event.sender === mjolnirUserId + && event.sender === draupnir.clientUserID && event.content?.membership === "join" ) { resolve(null); @@ -43,5 +46,5 @@ describe("Test: Accept Invites From Space", function() { }); }); await promise; - }); + } as unknown as Mocha.AsyncFunc); }); diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts deleted file mode 100644 index a0320720..00000000 --- a/test/integration/banListTest.ts +++ /dev/null @@ -1,639 +0,0 @@ -import { strict as assert } from "assert"; -import { newTestUser } from "./clientHelper"; -import { LogService, MatrixClient, Permalinks, UserID, MembershipEvent } from "matrix-bot-sdk"; -import PolicyList, { ChangeType } from "../../src/models/PolicyList"; -import { ServerAcl } from "../../src/models/ServerAcl"; -import { getFirstReaction } from "./commands/commandUtils"; -import { getMessagesByUserIn } from "../../src/utils"; -import { Mjolnir } from "../../src/Mjolnir"; -import { ALL_RULE_TYPES, Recommendation, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/ListRule"; -import AccessControlUnit, { Access, EntityAccess } from "../../src/models/AccessControlUnit"; -import { randomUUID } from "crypto"; -import { MatrixSendClient } from "../../src/MatrixEmitter"; -import { MatrixRoomReference } from "../../src/commands/interface-manager/MatrixRoomReference"; -import { MjolnirTestContext } from "./mjolnirSetupUtils"; - -/** - * Create a policy rule in a policy room. - * @param client A matrix client that is logged in - * @param policyRoomId The room id to add the policy to. - * @param policyType The type of policy to add e.g. m.policy.rule.user. (Use RULE_USER though). - * @param entity The entity to ban e.g. @foo:example.org - * @param reason A reason for the rule e.g. 'Wouldn't stop posting spam links' - * @param template The template to use for the policy rule event. - * @returns The event id of the newly created policy rule. - */ -async function createPolicyRule(client: MatrixSendClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = { recommendation: 'm.ban' }, stateKey = `rule:${entity}`) { - return await client.sendStateEvent(policyRoomId, policyType, stateKey, { - entity, - reason, - ...template, - }); -} - -/** - * Remove a policy rule from a list. - * @param client A matrix client that is logged in - * @param policyRoomId The room id to add the policy to. - * @param policyType The type of policy to add e.g. m.policy.rule.user. (Use RULE_USER though). - * @param entity The entity to ban e.g. @foo:example.org - * @param stateKey The key for the rule. - * @returns The event id of the void rule that was created to override the old one. - */ -async function removePolicyRule(client: MatrixSendClient, policyRoomId: string, policyType: string, entity: string, stateKey = `rule:${entity}`) { - return await client.sendStateEvent(policyRoomId, policyType, stateKey, {}); -} - -describe("Test: Updating the PolicyList", function() { - it("Calculates what has changed correctly.", async function() { - this.timeout(10000); - const mjolnir: Mjolnir = this.mjolnir! - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] }); - const banList = new PolicyList(banListId, banListId, mjolnir.client); - await mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100); - - assert.equal(banList.allRules.length, 0); - - // Test adding a new rule - await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@added:localhost:9999', ''); - let { changes } = await banList.updateList(); - assert.equal(changes.length, 1, 'There should only be one change'); - assert.equal(changes[0].changeType, ChangeType.Added); - assert.equal(changes[0].sender, await mjolnir.client.getUserId()); - assert.equal(banList.userRules.length, 1); - assert.equal(banList.allRules.length, 1); - - // Test modifiying a rule - let originalEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@modified:localhost:9999', ''); - await banList.updateList(); - let modifyingEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@modified:localhost:9999', 'modified reason'); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Modified); - assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); - assert.equal(changes[0].event['event_id'], modifyingEventId); - let modifyingAgainEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@modified:localhost:9999', 'modified again'); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Modified); - assert.equal(changes[0].previousState['event_id'], modifyingEventId, 'There should be a previous state event for a modified rule'); - assert.equal(changes[0].event['event_id'], modifyingAgainEventId); - assert.equal(banList.userRules.length, 2, 'There should be two rules, one for @modified:localhost:9999 and one for @added:localhost:9999'); - - // Test redacting a rule - const redactThis = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@redacted:localhost:9999', ''); - await banList.updateList(); - assert.equal(banList.userRules.filter(r => r.entity === '@redacted:localhost:9999').length, 1); - await mjolnir.client.redactEvent(banListId, redactThis); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Removed); - assert.equal(changes[0].event['event_id'], redactThis, 'Should show the new version of the event with redacted content'); - assert.equal(Object.keys(changes[0].event['content']).length, 0, 'Should show the new version of the event with redacted content'); - assert.notEqual(Object.keys(changes[0].previousState['content']), 0, 'Should have a copy of the unredacted state'); - assert.notEqual(changes[0].rule, undefined, 'The previous rule should be present'); - assert.equal(banList.userRules.filter(r => r.entity === '@redacted:localhost:9999').length, 0, 'The rule should be removed.'); - - // Test soft redaction of a rule - const softRedactedEntity = '@softredacted:localhost:9999' - await createPolicyRule(mjolnir.client, banListId, RULE_USER, softRedactedEntity, ''); - await banList.updateList(); - assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 1); - await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${softRedactedEntity}`, {}); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Removed); - assert.equal(Object.keys(changes[0].event['content']).length, 0, 'Should show the new version of the event with redacted content'); - assert.notEqual(Object.keys(changes[0].previousState['content']), 0, 'Should have a copy of the unredacted state'); - assert.notEqual(changes[0].rule, undefined, 'The previous rule should be present'); - assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 0, 'The rule should have been removed'); - - // Now test a double soft redaction just to make sure stuff doesn't explode - await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${softRedactedEntity}`, {}); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 0, "It shouldn't detect a double soft redaction as a change, it should be seen as adding an invalid rule."); - assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 0, 'The rule should have been removed'); - - // Test that different (old) rule types will be modelled as the latest event type. - originalEventId = await createPolicyRule(mjolnir.client, banListId, 'org.matrix.mjolnir.rule.user', '@old:localhost:9999', ''); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Added); - assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1); - modifyingEventId = await createPolicyRule(mjolnir.client, banListId, 'm.room.rule.user', '@old:localhost:9999', 'modified reason'); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Modified); - assert.equal(changes[0].event['event_id'], modifyingEventId); - assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); - assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1); - modifyingAgainEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@old:localhost:9999', 'changes again'); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Modified); - assert.equal(changes[0].event['event_id'], modifyingAgainEventId); - assert.equal(changes[0].previousState['event_id'], modifyingEventId, 'There should be a previous state event for a modified rule'); - assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1); - }) - it("Will remove rules with old types when they are 'soft redacted' with a different but more recent event type.", async function() { - this.timeout(3000); - const mjolnir: Mjolnir = this.mjolnir! - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }} ); - const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] }); - const banList = new PolicyList(banListId, banListId, mjolnir.client); - await mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100); - - const entity = '@old:localhost:9999'; - let originalEventId = await createPolicyRule(mjolnir.client, banListId, 'm.room.rule.user', entity, ''); - let { changes } = await banList.updateList(); - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Added); - assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'There should be a rule stored that we just added...') - let softRedactingEventId = await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${entity}`, {}); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Removed); - assert.equal(changes[0].event['event_id'], softRedactingEventId); - assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); - assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.'); - }) - it("A rule of the most recent type won't be deleted when an old rule is deleted for the same entity.", async function() { - const mjolnir: Mjolnir = this.mjolnir! - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] }); - const banList = new PolicyList(banListId, banListId, mjolnir.client); - await mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100); - - const entity = '@old:localhost:9999'; - let originalEventId = await createPolicyRule(mjolnir.client, banListId, 'm.room.rule.user', entity, ''); - let { changes } = await banList.updateList(); - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Added); - assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'There should be a rule stored that we just added...') - let updatedEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, entity, ''); - changes = (await banList.updateList()).changes; - // If in the future you change this and it fails, it's really subjective whether this constitutes a modification, since the only thing that has changed - // is the rule type. The actual content is identical. - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Modified); - assert.equal(changes[0].event['event_id'], updatedEventId); - assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); - assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'Only the latest version of the rule gets returned.'); - - // Now we delete the old version of the rule without consequence. - await mjolnir.client.sendStateEvent(banListId, 'm.room.rule.user', `rule:${entity}`, {}); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 0); - assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'The rule should still be active.'); - - // And we can still delete the new version of the rule. - let softRedactingEventId = await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${entity}`, {}); - changes = (await banList.updateList()).changes; - assert.equal(changes.length, 1); - assert.equal(changes[0].changeType, ChangeType.Removed); - assert.equal(changes[0].event['event_id'], softRedactingEventId); - assert.equal(changes[0].previousState['event_id'], updatedEventId, 'There should be a previous state event for a modified rule'); - assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.'); - }) - it('Test: PolicyList Supports all entity types.', async function () { - const mjolnir: Mjolnir = this.mjolnir! - const banListId = await mjolnir.client.createRoom(); - const banList = new PolicyList(banListId, banListId, mjolnir.client); - for (let i = 0; i < ALL_RULE_TYPES.length; i++) { - await createPolicyRule(mjolnir.client, banListId, ALL_RULE_TYPES[i], `*${i}*`, ''); - } - let { changes } = await banList.updateList(); - assert.equal(changes.length, ALL_RULE_TYPES.length); - assert.equal(banList.allRules.length, ALL_RULE_TYPES.length); - }) -}); - -describe('Test: We will not be able to ban ourselves via ACL.', function() { - it('We do not ban ourselves when we put ourselves into the policy list.', async function() { - const mjolnir: Mjolnir = this.mjolnir - const serverName = new UserID(await mjolnir.client.getUserId()).domain; - const banListId = await mjolnir.client.createRoom(); - const banList = new PolicyList(banListId, banListId, mjolnir.client); - const aclUnit = new AccessControlUnit([banList]); - await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, serverName, ''); - await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, 'evil.com', ''); - await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, '*', ''); - // We should still intern the matching rules rule. - let { changes } = await banList.updateList(); - assert.equal(banList.serverRules.length, 3); - // But when we construct an ACL, we should be safe. - const acl = new ServerAcl(serverName) - changes.forEach(change => acl.denyServer(change.rule.entity)); - assert.equal(acl.safeAclContent().deny.length, 1); - assert.equal(acl.literalAclContent().deny.length, 3); - - const aclUnitAcl = aclUnit.compileServerAcl(serverName); - assert.equal(aclUnitAcl.literalAclContent().deny.length, 1); - - }) -}) - - -describe('Test: ACL updates will batch when rules are added in succession.', function() { - it('Will batch ACL updates if we spam rules into a PolicyList', async function() { - const mjolnir: Mjolnir = this.mjolnir! - const serverName: string = new UserID(await mjolnir.client.getUserId()).domain - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await moderator.joinRoom(mjolnir.managementRoomId); - const mjolnirId = await mjolnir.client.getUserId(); - - // Setup some protected rooms so we can check their ACL state later. - const protectedRooms: string[] = []; - for (let i = 0; i < 5; i++) { - const room = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(room); - await moderator.setUserPowerLevel(mjolnirId, room, 100); - await mjolnir.addProtectedRoom(room); - protectedRooms.push(room); - } - - // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.protectedRoomsTracker.syncLists(); - await Promise.all(protectedRooms.map(async room => { - // We're going to need timeline pagination I'm afraid. - const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", ""); - assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); - })); - - // Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms. - const banListId = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(banListId); - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromRoomId(banListId)); - const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*"); - const evilServerCount = 200; - for (let i = 0; i < evilServerCount; i++) { - const badServer = `${i}.evil.com`; - acl.denyServer(badServer); - await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule #${i}`); - // Give them a bit of a spread over time. - await new Promise(resolve => setTimeout(resolve, 5)); - } - // We do this because it should force us to wait until all the ACL events have been applied. - // Even if that does mean the last few events will not go through batching... - await mjolnir.protectedRoomsTracker.syncLists(); - - // At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following - // is a pita. - const list: PolicyList = this.mjolnir.policyListManager.lists[0]!; - assert.equal(list.serverRules.length, evilServerCount, `There should be ${evilServerCount} rules in here`); - - // Check each of the protected rooms for ACL events and make sure they were batched and are correct. - await Promise.all(protectedRooms.map(async room => { - const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", ""); - if (!acl.matches(roomAcl)) { - assert.fail(`Room ${room} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`) - } - let aclEventCount = 0; - await getMessagesByUserIn(mjolnir.client, mjolnirId, room, 100, events => { - events.forEach(event => event.type === 'm.room.server_acl' ? aclEventCount += 1 : null); - }); - LogService.debug('PolicyListTest', `aclEventCount: ${aclEventCount}`); - // If there's less than two then it means the ACL was updated by this test calling `this.mjolnir!.syncLists()` - // and not the listener that detects changes to ban lists (that we want to test!). - // It used to be 10, but it was too low, 30 seems better for CI. - assert.equal(aclEventCount < 30 && aclEventCount > 2, true, 'We should have sent less than 30 ACL events to each room because they should be batched') - })); - }) -}) - -describe('Test: unbaning entities via the PolicyList.', function() { - afterEach(function() { this.moderator?.stop(); }); - it('Will remove rules that have legacy types', async function() { - const mjolnir: Mjolnir = this.mjolnir! - const serverName: string = new UserID(await mjolnir.client.getUserId()).domain - const moderator: MatrixClient = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - this.moderator = moderator; - await moderator.joinRoom(mjolnir.managementRoomId); - const mjolnirId = await mjolnir.client.getUserId(); - - // We'll make 1 protected room to test ACLs in. - const protectedRoom = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(protectedRoom); - await moderator.setUserPowerLevel(mjolnirId, protectedRoom, 100); - await mjolnir.addProtectedRoom(protectedRoom); - - // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.protectedRoomsTracker.syncLists(); - // If this is not present, then it means the room isn't being protected, which is really bad. - const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); - assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); - - // Create some legacy rules on a PolicyList. - const banListId = await moderator.createRoom({ invite: [mjolnirId] }); - await moderator.setUserPowerLevel(await mjolnir.client.getUserId(), banListId, 100); - await moderator.sendStateEvent(banListId, 'org.matrix.mjolnir.shortcode', '', { shortcode: "unban-test" }); - await mjolnir.client.joinRoom(banListId); - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromRoomId(banListId)); - // we use this to compare changes. - const banList = new PolicyList(banListId, banListId, moderator); - // we need two because we need to test the case where an entity has all rule types in the list - // and another one that only has one (so that we would hit 404 while looking up state) - const olderBadServer = "old.evil.example" - const newerBadServer = "new.evil.example" - await Promise.all(SERVER_RULE_TYPES.map(type => createPolicyRule(moderator, banListId, type, olderBadServer, 'gregg rulz ok'))); - await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'this is bad sort it out.'); - await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'hidden with a non-standard state key', undefined, "rule_1"); - // Wait for the ACL event to be applied to our protected room. - await mjolnir.protectedRoomsTracker.syncLists(); - - await banList.updateList(); - // rules are normalized by rule type, that's why there should only be 3. - assert.equal(banList.allRules.length, 3); - - // Check that we have setup our test properly and therefore evil.example is banned. - const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(olderBadServer).denyServer(newerBadServer); - const protectedAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); - if (!acl.matches(protectedAcl)) { - assert.fail(`Room ${protectedRoom} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`); - } - - // Now unban the servers, we will go via the unban command for completeness sake. - try { - await moderator.start(); - for (const server of [olderBadServer, newerBadServer]) { - await getFirstReaction(moderator, mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${server} unban-test` }); - }); - } - } finally { - moderator.stop(); - } - - // Wait for mjolnir to sync protected rooms to update ACL. - await mjolnir.protectedRoomsTracker.syncLists(); - // Confirm that the server is unbanned. - await banList.updateList(); - assert.equal(banList.allRules.length, 0); - const aclAfter = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); - assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore'); - }) -}) - -describe('Test: should apply bans to the most recently active rooms first', function() { - it('Applies bans to the most recently active rooms first', async function() { - this.timeout(180000) - const mjolnir: Mjolnir = this.mjolnir! - const serverName: string = new UserID(await mjolnir.client.getUserId()).domain - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await moderator.joinRoom(mjolnir.managementRoomId); - const mjolnirId = await mjolnir.client.getUserId(); - - // Setup some protected rooms so we can check their ACL state later. - const protectedRooms: string[] = []; - for (let i = 0; i < 10; i++) { - const room = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(room); - await moderator.setUserPowerLevel(mjolnirId, room, 100); - await mjolnir.addProtectedRoom(room); - protectedRooms.push(room); - } - - // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.protectedRoomsTracker.syncLists(); - await Promise.all(protectedRooms.map(async room => { - const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? { deny: [] } : Promise.reject(e)); - assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); - })); - - // Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms. - const banListId = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(banListId); - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromRoomId(banListId)); - - await mjolnir.protectedRoomsTracker.syncLists(); - - // shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them. - for (let i = protectedRooms.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [protectedRooms[i], protectedRooms[j]] = [protectedRooms[j], protectedRooms[i]]; - } - // create some activity in the same order. - for (const roomId of protectedRooms.slice().reverse()) { - await moderator.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' }); - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // check the rooms are in the expected order - for (let i = 0; i < protectedRooms.length; i++) { - assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms[i]); - } - - // just ban one server - const badServer = `evil.com`; - const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer); - // collect all the rooms that received an ACL event. - const aclRooms: any[] = await new Promise(async resolve => { - const rooms: any[] = []; - this.mjolnir.client.on('room.event', (room: string, event: any) => { - if (protectedRooms.includes(room)) { - rooms.push(room); - } - if (rooms.length === protectedRooms.length) { - resolve(rooms) - } - }); - // create the rule that will ban the server. - await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); - }) - - // Wait until all the ACL events have been applied. - await mjolnir.protectedRoomsTracker.syncLists(); - - for (let i = 0; i < protectedRooms.length; i++) { - assert.equal(aclRooms[i], protectedRooms[i], "The ACL should have been applied to the active rooms first."); - } - - // Check that the most recently active rooms got the ACL update first. - let last_event_ts = 0; - for (const roomId of protectedRooms) { - let roomAclEvent: null | any; - // Can't be the best way to get the whole event, but ok. - await getMessagesByUserIn(mjolnir.client, mjolnirId, roomId, 1, events => roomAclEvent = events[0]); - const roomAcl = roomAclEvent!.content; - if (!acl.matches(roomAcl)) { - assert.fail(`Room ${roomId} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`) - } - assert.equal(roomAclEvent.origin_server_ts > last_event_ts, true, `This room was more recently active so should have the more recent timestamp`); - last_event_ts = roomAclEvent.origin_server_ts; - } - }) -}) - -/** - * Assert that the AccessUnitOutcome entity test has the right access. - * @param expected The Access we expect the entity to have, See Access. - * @param query The result of a test on AccessControlUnit e.g. `unit.getAccessForUser'@meow:localhost')` - * @param message A message for the console if the entity doesn't have the expected access. - */ -function assertAccess(expected: Access, query: EntityAccess, message?: string) { - assert.equal(query.outcome, expected, message); -} - -describe('Test: AccessControlUnit interaction with policy lists.', function() { - it('The AccessControlUnit correctly reflects the policies that have been set in its watched lists.', async function() { - const mjolnir: Mjolnir = this.mjolnir! - const policyListId = await mjolnir.client.createRoom(); - const policyList = new PolicyList(policyListId, Permalinks.forRoom(policyListId), mjolnir.client); - const aclUnit = new AccessControlUnit([policyList]); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@anyone:anywhere.example.com', "CHECK_SERVER"), 'Empty lists should implicitly allow.'); - - await createPolicyRule(mjolnir.client, policyListId, RULE_SERVER, 'matrix.org', '', { recommendation: Recommendation.Allow }); - // we want to imagine that the banned server was never taken off the allow after being banned. - await createPolicyRule(mjolnir.client, policyListId, RULE_SERVER, 'bad.example.com', '', { recommendation: Recommendation.Allow }, 'something-else'); - await createPolicyRule(mjolnir.client, policyListId, RULE_SERVER, 'bad.example.com', '', { recommendation: Recommendation.Ban }); - await createPolicyRule(mjolnir.client, policyListId, RULE_SERVER, '*.ddns.example.com', '', { recommendation: Recommendation.Ban }); - - await policyList.updateList(); - - assertAccess(Access.Allowed, aclUnit.getAccessForServer('matrix.org')); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@someone:matrix.org', "CHECK_SERVER")); - assertAccess(Access.NotAllowed, aclUnit.getAccessForServer('anywhere.else.example.com')); - assertAccess(Access.NotAllowed, aclUnit.getAccessForUser('@anyone:anywhere.else.example.com', "CHECK_SERVER")); - assertAccess(Access.Banned, aclUnit.getAccessForServer('bad.example.com')); - assertAccess(Access.Banned, aclUnit.getAccessForUser('@anyone:bad.example.com', "CHECK_SERVER")); - // They're not allowed in the first place, never mind that they are also banned. - assertAccess(Access.NotAllowed, aclUnit.getAccessForServer('meow.ddns.example.com')); - assertAccess(Access.NotAllowed, aclUnit.getAccessForUser('@anyone:meow.ddns.example.com', "CHECK_SERVER")); - - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@spam:matrix.org', "CHECK_SERVER")); - await createPolicyRule(mjolnir.client, policyListId, RULE_USER, '@spam:matrix.org', '', { recommendation: Recommendation.Ban }); - await policyList.updateList(); - assertAccess(Access.Banned, aclUnit.getAccessForUser('@spam:matrix.org', "CHECK_SERVER")); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@someone:matrix.org', "CHECK_SERVER")); - - // protect a room and check that only bad.example.com, *.ddns.example.com are in the deny ACL and not matrix.org - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromPermalink(policyList.roomRef)); - const protectedRoom = await mjolnir.client.createRoom(); - await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoom); - await mjolnir.protectedRoomsTracker.syncLists(); - const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); - assert.equal(roomAcl?.deny?.length ?? 0, 2, 'There should be two entries in the deny ACL.'); - for (const serverGlob of ["*.ddns.example.com", "bad.example.com"]) { - assert.equal((roomAcl?.deny ?? []).includes(serverGlob), true); - } - assert.equal(roomAcl.deny.includes("matrix.org"), false); - assert.equal(roomAcl.allow.includes("matrix.org"), true); - - // Now we remove the rules and hope that everything functions noramally. - await removePolicyRule(mjolnir.client, policyListId, RULE_SERVER, 'matrix.org'); - await removePolicyRule(mjolnir.client, policyListId, RULE_SERVER, 'bad.example.com', 'something-else'); - await removePolicyRule(mjolnir.client, policyListId, RULE_SERVER, 'bad.example.com'); - await removePolicyRule(mjolnir.client, policyListId, RULE_SERVER, '*.ddns.example.com'); - await removePolicyRule(mjolnir.client, policyListId, RULE_USER, "@spam:matrix.org"); - const { changes } = await policyList.updateList() - await mjolnir.protectedRoomsTracker.syncLists(); - - assert.equal(changes.length, 5, "The rules should have correctly been removed"); - assertAccess(Access.Allowed, aclUnit.getAccessForServer('matrix.org')); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@someone:matrix.org', "CHECK_SERVER")); - assertAccess(Access.Allowed, aclUnit.getAccessForServer('anywhere.else.example.com')); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@anyone:anywhere.else.example.com', "CHECK_SERVER")); - assertAccess(Access.Allowed, aclUnit.getAccessForServer('bad.example.com')); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@anyone:bad.example.com', "CHECK_SERVER")); - assertAccess(Access.Allowed, aclUnit.getAccessForServer('meow.ddns.example.com')); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@anyone:meow.ddns.example.com', "CHECK_SERVER")); - assertAccess(Access.Allowed, aclUnit.getAccessForUser('@spam:matrix.org', "CHECK_SERVER")); - - const roomAclAfter = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); - assert.equal(roomAclAfter.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); - assert.equal(roomAclAfter.allow?.length ?? 0, 1, 'There should be 1 entry in the allow ACL.'); - assert.equal(roomAclAfter.allow.includes("*"), true); - }) - it('removing a rule from a different list will not clobber anything.', async function() { - const mjolnir: Mjolnir = this.mjolnir! - const policyLists = await Promise.all([...Array(2).keys()].map(async _ => { - const policyListId = await mjolnir.client.createRoom(); - return new PolicyList(policyListId, Permalinks.forRoom(policyListId), mjolnir.client); - })); - const banMeServer = 'banme.example.com'; - const aclUnit = new AccessControlUnit(policyLists); - await Promise.all(policyLists.map(policyList => { - return createPolicyRule(mjolnir.client, policyList.roomId, RULE_SERVER, banMeServer, '', { recommendation: Recommendation.Ban }) - })); - await Promise.all(policyLists.map(list => list.updateList())); - assertAccess(Access.Banned, aclUnit.getAccessForServer(banMeServer)); - - // remove the rule that bans `banme.example.com` from just one of the lists. - await removePolicyRule(mjolnir.client, policyLists[0].roomId, RULE_SERVER, banMeServer); - await Promise.all(policyLists.map(list => list.updateList())); - assertAccess(Access.Banned, aclUnit.getAccessForServer(banMeServer), "Should still be banned at this point."); - await removePolicyRule(mjolnir.client, policyLists[1].roomId, RULE_SERVER, banMeServer); - await Promise.all(policyLists.map(list => list.updateList())); - assertAccess(Access.Allowed, aclUnit.getAccessForServer(banMeServer), "Should not longer be any rules"); - }) -}) - -describe('Test: Creating policy lists.', function() { - it('Will automatically invite and op users from invites', async function() { - const mjolnir: Mjolnir = this.mjolnir; - const testUsers = await Promise.all([...Array(2)].map(_ => newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }))) - const invite = await Promise.all(testUsers.map(client => client.getUserId())); - const policyListId = await PolicyList.createList( - mjolnir.client, - randomUUID(), - invite - ); - // Check power levels are right. - const powerLevelEvent = await mjolnir.client.getRoomStateEvent(policyListId, "m.room.power_levels", ""); - assert.equal(Object.keys(powerLevelEvent.users ?? {}).length, invite.length + 1); - // Check create event for MSC3784 support. - const createEvent = await mjolnir.client.getRoomStateEvent(policyListId, "m.room.create", ""); - assert.equal(createEvent.type, PolicyList.ROOM_TYPE); - // We can't create rooms without forgetting the type. - await assert.rejects( - async () => { - await PolicyList.createList(mjolnir.client, randomUUID(), [], { - creation_content: {} - }) - }, - TypeError - ); - }) -}) - -describe('Test: Continue to ban other marked members when one member cannot be banned', function() { - it('Failing to ban a moderator should not stop other members being banned.', async function(this: MjolnirTestContext) { - if (this.mjolnir === undefined) { - throw new TypeError("Mjolnir was never created.") - } - const mjolnir: Mjolnir = this.mjolnir; - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "mx-moderator" } }); - await moderator.joinRoom(mjolnir.managementRoomId); - const mjolnirId = await mjolnir.client.getUserId(); - - const protectedRoom = await moderator.createRoom({ invite: [mjolnirId], preset: 'public_chat'}); - await mjolnir.client.joinRoom(protectedRoom); - await moderator.setUserPowerLevel(mjolnirId, protectedRoom, 100); - await mjolnir.addProtectedRoom(protectedRoom); - - const banListId = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(banListId); - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromRoomId(banListId)); - - await mjolnir.protectedRoomsTracker.syncLists(); - - const spamUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "mx-spammer" } }); - const rudeUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "mx-rude" } }); - const sillyModerator = await newTestUser(this.config.homeserverUrl, { name: { contains: "mx-moderator" } }); - await Promise.all([spamUser, rudeUser, sillyModerator].map(c => c.joinRoom(protectedRoom))); - await moderator.setUserPowerLevel((await sillyModerator.getUserId()), protectedRoom, 100); - - await createPolicyRule(moderator, banListId, RULE_USER, '@*mx*:*', "don't like them go away."); - await mjolnir.protectedRoomsTracker.syncLists(); - - const roomMembers = await moderator.getRoomMembers(protectedRoom); - - const assertMembership = (userId: string, membership: string, members: MembershipEvent[]) => { - assert.equal(members.find(e => e.stateKey === userId)?.membership, membership); - }; - - assertMembership(await spamUser.getUserId(), 'ban', roomMembers); - assertMembership(await rudeUser.getUserId(), 'ban', roomMembers); - }) -}) diff --git a/test/integration/banPropagationTest.ts b/test/integration/banPropagationTest.ts index 86b5e4c3..a8de1a67 100644 --- a/test/integration/banPropagationTest.ts +++ b/test/integration/banPropagationTest.ts @@ -1,39 +1,47 @@ import expect from "expect"; -import { Mjolnir } from "../../src/Mjolnir"; import { newTestUser } from "./clientHelper"; import { getFirstEventMatching } from './commands/commandUtils'; -import { RULE_USER } from "../../src/models/ListRule"; -import { MatrixRoomReference } from "../../src/commands/interface-manager/MatrixRoomReference"; +import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; +import { MatrixRoomReference, PolicyRuleType, PropagationType, StringRoomID, 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() { - const mjolnir: Mjolnir = this.mjolnir - expect(mjolnir.protectionManager.getProtection("BanPropagationProtection")?.enabled).toBeTruthy(); - }) - it("Should prompt to add bans to a policy list, then add the ban", async function() { - const mjolnir: Mjolnir = this.mjolnir - const mjolnirId = await mjolnir.client.getUserId(); + 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 moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await moderator.joinRoom(mjolnir.managementRoomId); + await moderator.joinRoom(draupnir.managementRoomID); const protectedRooms = await Promise.all([...Array(5)].map(async _ => { - const room = await moderator.createRoom({ invite: [mjolnirId] }); - await mjolnir.client.joinRoom(room); - await moderator.setUserPowerLevel(mjolnirId, room, 100); - await mjolnir.addProtectedRoom(room); + const room = await moderator.createRoom({ invite: [draupnir.clientUserID] }); + await draupnir.client.joinRoom(room); + await moderator.setUserPowerLevel(draupnir.clientUserID, room, 100); + await draupnir.protectedRoomsSet.protectedRoomsConfig.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: [mjolnirId] }); - await moderator.setUserPowerLevel(mjolnirId, policyListId, 100); - await mjolnir.client.joinRoom(policyListId); - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromRoomId(policyListId)); + 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: mjolnir.matrixEmitter, - targetRoom: mjolnir.managementRoomId, + matrix: draupnirClient()!, + 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"); @@ -45,20 +53,21 @@ describe("Ban propagation test", function() { }) // select the prompt await moderator.unstableApis.addReactionToEvent( - mjolnir.managementRoomId, promptEvent['event_id'], '1.' + draupnir.managementRoomID, promptEvent['event_id'], '1️⃣' ); // check the policy list, after waiting a few seconds. await new Promise(resolve => setTimeout(resolve, 10000)); - const policyList = mjolnir.policyListManager.lists[0]; - const rules = policyList.rulesMatchingEntity('@test:example.com', RULE_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'); // now unban them >:3 const unbanPrompt = await getFirstEventMatching({ - matrix: mjolnir.matrixEmitter, - targetRoom: mjolnir.managementRoomId, + matrix: draupnirClient()!, + 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]); @@ -70,10 +79,12 @@ describe("Ban propagation test", function() { }); await moderator.unstableApis.addReactionToEvent( - mjolnir.managementRoomId, unbanPrompt['event_id'], 'unban from all' + draupnir.managementRoomID, unbanPrompt['event_id'], 'unban from all' ); await new Promise(resolve => setTimeout(resolve, 10000)); - const rulesAfterUnban = policyList.rulesMatchingEntity('@test:example.com', RULE_USER); + 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 ce9fe5c1..606fd954 100644 --- a/test/integration/clientHelper.ts +++ b/test/integration/clientHelper.ts @@ -183,6 +183,6 @@ export function noticeListener(targetRoomdId: string, cb: (event: any) => void) return (roomId: string, event: any) => { if (roomId !== targetRoomdId) return; if (event?.content?.msgtype !== "m.notice") return; - cb(event); + cb(event); } } diff --git a/test/integration/commands/commandUtils.ts b/test/integration/commands/commandUtils.ts index e1a98556..4aa7a15f 100644 --- a/test/integration/commands/commandUtils.ts +++ b/test/integration/commands/commandUtils.ts @@ -1,7 +1,7 @@ import { MatrixClient } from "matrix-bot-sdk"; import { strict as assert } from "assert"; import * as crypto from "crypto"; -import { MatrixEmitter } from "../../../src/MatrixEmitter"; +import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; /** * Returns a promise that resolves to the first event replying to the event produced by targetEventThunk. diff --git a/test/integration/commands/hijackRoomCommandTest.ts b/test/integration/commands/hijackRoomCommandTest.ts index 3c630f15..87f5eda0 100644 --- a/test/integration/commands/hijackRoomCommandTest.ts +++ b/test/integration/commands/hijackRoomCommandTest.ts @@ -1,35 +1,31 @@ -import { strict as assert } from "assert"; -import { MjolnirTestContext } from "../mjolnirSetupUtils"; +import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; import { getFirstReaction } from "./commandUtils"; +import { DraupnirTestContext, draupnirClient } from "../mjolnirSetupUtils"; describe("Test: The make admin command", function () { - it('Mjölnir make the bot self room administrator', async function (this: MjolnirTestContext) { + it('Mjölnir make the bot self room administrator', async function (this: DraupnirTestContext) { this.timeout(90000); if (!this.config.admin?.enableMakeRoomAdminCommand) { this.done(); } - const mjolnir = this.mjolnir!; - const mjolnirUserId = await mjolnir.client.getUserId(); + const draupnir = this.draupnir!; 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(); - this.moderator = moderator; - this.userA = userA; - await moderator.joinRoom(this.config.managementRoom); - let targetRoom = await moderator.createRoom({ invite: [mjolnirUserId], preset: "public_chat" }); - await moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}` }); + await moderator.joinRoom(draupnir.managementRoomID); + let 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 mjolnir.client.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); - await mjolnir.matrixEmitter.start(); - assert.notEqual(powerLevelsBefore["users"][mjolnirUserId], 100, `Bot should not yet be an admin of ${targetRoom}`); - await getFirstReaction(mjolnir.matrixEmitter, mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir hijack room ${targetRoom} ${mjolnirUserId}` }); + 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(draupnirClient()!, draupnir.managementRoomID, '✅', async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { msgtype: 'm.text', body: `!draupnir hijack room ${targetRoom} ${draupnir.clientUserID}` }); }); - const powerLevelsAfter = await mjolnir.client.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); - assert.equal(powerLevelsAfter["users"][mjolnirUserId], 100, "Bot should be a room admin."); + 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], (0 || 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 e969726c..cb151913 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -1,15 +1,22 @@ import { strict as assert } from "assert"; - import { newTestUser } from "../clientHelper"; import { getMessagesByUserIn } from "../../../src/utils"; import { LogService } from "matrix-bot-sdk"; import { getFirstReaction } from "./commandUtils"; +import { DraupnirTestContext } from "../mjolnirSetupUtils"; +import { MatrixClient } from "matrix-bot-sdk"; + +interface RedactionTestContext extends DraupnirTestContext { + 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(); }); + 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() { + 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. let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); @@ -22,22 +29,28 @@ describe("Test: The redaction command", function () { let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]}); await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); await badUser.joinRoom(targetRoom); - moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}`}); - + try { + await moderator.start(); + await getFirstReaction(moderator, this.draupnir!.managementRoomID, '✅', async () => { + return moderator.sendMessage(this.draupnir!.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); + }); + } finally { + moderator.stop(); + } 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}`}))); + 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"}); } - await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); + 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"}); try { await moderator.start(); - await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir redact ${badUserId} ${targetRoom}` }); + await getFirstReaction(moderator, this.draupnir!.managementRoomID, '✅', async () => { + return await moderator.sendMessage(this.draupnir!.managementRoomID, { msgtype: 'm.text', body: `!draupnir redact ${badUserId} --room ${targetRoom}` }); }); } finally { moderator.stop(); @@ -52,15 +65,15 @@ describe("Test: The redaction command", function () { } }) }); - }) + } as unknown as Mocha.AsyncFunc) - it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id in multiple rooms.', async function() { + 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. let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); let badUserId = await badUser.getUserId(); - const mjolnir = this.config.RUNTIME.client! - let mjolnirUserId = await mjolnir.getUserId(); + const draupnir = this.draupnir!; + let mjolnirUserId = await draupnir.client.getUserId(); let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = moderator; await moderator.joinRoom(this.config.managementRoom); @@ -69,23 +82,23 @@ describe("Test: The redaction command", function () { let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]}); await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); await badUser.joinRoom(targetRoom); - await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `!mjolnir rooms add ${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}`}))); + 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 Promise.all([...Array(50).keys()].map((j) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${j}`}))); + 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"}); } try { await moderator.start(); - await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir redact ${badUserId}` }); + await getFirstReaction(moderator, draupnir.managementRoomID, '✅', async () => { + return await moderator.sendMessage(draupnir.managementRoomID, { msgtype: 'm.text', body: `!draupnir redact ${badUserId}` }); }); } finally { moderator.stop(); @@ -102,26 +115,25 @@ describe("Test: The redaction command", function () { }) }) }); - }); - it("Redacts a single event when instructed to.", async function () { + } 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. let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); - const mjolnir = this.config.RUNTIME.client! - let mjolnirUserId = await mjolnir.getUserId(); + const draupnir = this.draupnir!; let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = moderator; await moderator.joinRoom(this.config.managementRoom); - let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]}); - await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), draupnir.clientUserID]}); + await moderator.setUserPowerLevel(draupnir.clientUserID, targetRoom, 100); await badUser.joinRoom(targetRoom); - moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}`}); + moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); let eventToRedact = await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); try { await moderator.start(); - await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`}); + await getFirstReaction(moderator, draupnir.managementRoomID, '✅', async () => { + return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`}); }); } finally { moderator.stop(); @@ -129,5 +141,5 @@ describe("Test: The redaction command", function () { let 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 bc8ab5e1..a1cbf553 100644 --- a/test/integration/commands/roomsTest.ts +++ b/test/integration/commands/roomsTest.ts @@ -1,39 +1,46 @@ import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; import { getFirstReaction, getFirstReply } from "./commandUtils"; +import { DraupnirTestContext } from "../mjolnirSetupUtils"; +import { MatrixClient } from 'matrix-bot-sdk'; + +interface RoomsTestContext extends DraupnirTestContext { + 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(); }); + afterEach(function() { + this.moderator?.stop(); + }); - it('Mjolnir can protect a room, show that it is protected and then stop protecting the room.', async function() { + 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 mjolnir = this.mjolnir.client; - let mjolnirUserId = await mjolnir.getUserId(); + const draupnir = this.draupnir!; let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = moderator; await moderator.joinRoom(this.config.managementRoom); - let targetRoom = await moderator.createRoom({ invite: [mjolnirUserId]}); - await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + let targetRoom = await moderator.createRoom({ invite: [draupnir.clientUserID]}); + await moderator.setUserPowerLevel(draupnir.clientUserID, targetRoom, 100); try { await moderator.start(); - await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms add ${targetRoom}`}); + await getFirstReaction(moderator, draupnir.managementRoomID, '✅', async () => { + return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms add ${targetRoom}`}); }); - let protectedRoomsMessage = await getFirstReply(moderator, this.mjolnir.managementRoomId, async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms`}); + let protectedRoomsMessage = await getFirstReply(moderator, draupnir.managementRoomID, async () => { + return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms`}); }) - assert.equal(protectedRoomsMessage['content']?.['body']?.includes('1'), true, "There should be one protected room"); - await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms remove ${targetRoom}`}); + assert.equal(protectedRoomsMessage['content']?.['body']?.includes('2'), true, "There should be two protected rooms (including the management room)"); + await getFirstReaction(moderator, draupnir.managementRoomID, '✅', async () => { + return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms remove ${targetRoom}`}); }); - protectedRoomsMessage = await getFirstReply(moderator, this.mjolnir.managementRoomId, async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms`}); + protectedRoomsMessage = await getFirstReply(moderator, draupnir.managementRoomID, async () => { + return await moderator.sendMessage(draupnir.managementRoomID, {msgtype: 'm.text', body: `!draupnir rooms`}); }) - assert.equal(protectedRoomsMessage['content']?.['body']?.includes('0'), true, "There room should no longer be protected"); + assert.equal(protectedRoomsMessage['content']?.['body']?.includes('1'), true, "Only the management room should be protected."); } finally { moderator.stop(); } - }) + } as unknown as Mocha.AsyncFunc) }) diff --git a/test/integration/commands/shutdownCommandTest.ts b/test/integration/commands/shutdownCommandTest.ts index ace5fda4..2bd0ea8c 100644 --- a/test/integration/commands/shutdownCommandTest.ts +++ b/test/integration/commands/shutdownCommandTest.ts @@ -1,28 +1,31 @@ import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; +import { DraupnirTestContext, draupnirClient } from "../mjolnirSetupUtils"; +import { MatrixClient } from "matrix-bot-sdk"; describe("Test: shutdown command", function() { - let client; + let client: MatrixClient; this.beforeEach(async function () { client = await newTestUser(this.config.homeserverUrl, { name: { contains: "shutdown-command" }}); await client.start(); }) this.afterEach(async function () { - await client.stop(); + client.stop(); }) - it("Mjolnir asks synapse to shut down a channel", async function() { + it("Mjolnir asks synapse to shut down a channel", async function(this: DraupnirTestContext) { this.timeout(20000); const badRoom = await client.createRoom(); - await client.joinRoom(this.mjolnir.managementRoomId); + const draupnir = this.draupnir!; + await client.joinRoom(draupnir.managementRoomID); - let reply1 = new Promise(async (resolve, reject) => { - const msgid = await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: `!mjolnir shutdown room ${badRoom} closure test`}); + let reply1 = new Promise(async (resolve) => { + 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 === this.mjolnir.managementRoomId + roomId === draupnir.managementRoomID && event?.type === "m.reaction" - && event.sender === this.mjolnir.client.userId + && event.sender === draupnir.clientUserID && event.content?.["m.relates_to"]?.event_id === msgid ) { resolve(event); @@ -30,13 +33,13 @@ describe("Test: shutdown command", function() { }); }); - const reply2 = new Promise((resolve, reject) => { - this.mjolnir.client.on('room.event', (roomId, event) => { + const reply2 = new Promise((resolve) => { + draupnirClient()!.on('room.event', (roomId, event) => { if ( - roomId !== this.mjolnir.managementRoomId + roomId !== draupnir.managementRoomID && roomId !== badRoom && event?.type === "m.room.message" - && event.sender === this.mjolnir.client.userId + && event.sender === draupnir.clientUserID && event.content?.body === "closure test" ) { resolve(event); @@ -52,5 +55,5 @@ describe("Test: shutdown command", function() { 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/detectFederationLagTest.ts b/test/integration/detectFederationLagTest.ts deleted file mode 100644 index 16cd23bb..00000000 --- a/test/integration/detectFederationLagTest.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { strict as assert } from "assert"; - -import { UserID } from "matrix-bot-sdk"; -import { Suite } from "mocha"; -import { Mjolnir } from "../../src/Mjolnir"; -import { DetectFederationLag, LAG_STATE_EVENT } from "../../src/protections/DetectFederationLag"; -import { getFirstReply } from "./commands/commandUtils"; -import { newTestUser } from "./clientHelper"; - -const LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS = 180_000; -const LOCAL_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS = 100_000; -const FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS = 300_000; -const FEDERATED_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS = 200_000; -const BUCKET_DURATION_MS = 100; -const SAMPLE_SIZE = 100; -const NUMBER_OF_LAGGING_FEDERATED_HOMESERVERS_ENTER_WARNING_ZONE = 2; - -const RE_STATS = /(\{(:?.|\n)*\})[^}]*$/m; - -describe("Test: DetectFederationLag protection", function() { - // In this entire test, we call `handleEvent` directly, injecting - // - events that simulate lag; - // - a progression through time, to make sure that histograms get processed. - beforeEach(async function() { - // Setup an instance of DetectFederationLag - this.detector = new DetectFederationLag(); - await this.mjolnir.protectionManager.registerProtection(this.detector); - await this.mjolnir.protectionManager.enableProtection("DetectFederationLag"); - - // Setup a moderator. - this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await this.moderator.joinRoom(this.mjolnir.managementRoomId); - - const SETTINGS = { - // The protection should kick in immediately. - initialDelayGrace: 0, - // Make histograms progress quickly. - bucketDuration: BUCKET_DURATION_MS, - // Three homeservers should be sufficient to raise an alert. - numberOfLaggingFederatedHomeserversEnterWarningZone: NUMBER_OF_LAGGING_FEDERATED_HOMESERVERS_ENTER_WARNING_ZONE, - - localHomeserverLagEnterWarningZone: LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, - localHomeserverLagExitWarningZone: LOCAL_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS, - - federatedHomeserverLagEnterWarningZone: FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, - federatedHomeserverLagExitWarningZone: FEDERATED_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS, - }; - for (let key of Object.keys(SETTINGS)) { - this.detector.settings[key].setValue(SETTINGS[key]); - } - this.localDomain = new UserID(await this.mjolnir.client.getUserId()).domain; - this.protectedRoomId = `!room1:${this.localDomain}`; - this.mjolnir.addProtectedRoom(this.protectedRoomId); - - this.simulateLag = async (senders: string[], lag: number, start: Date) => { - const content = {}; - const origin_server_ts = start.getTime() - lag; - for (let i = 0; i < SAMPLE_SIZE; ++i) { - // We call directly `this.detector.handleEvent` to be able to forge old values of `origin_server_ts`. - await this.detector.handleEvent(this.mjolnir, this.protectedRoomId, { - sender: senders[i % senders.length], - origin_server_ts, - content, - }, - // Make sure that time progresses through histogram buckets. - simulateDate(start, i) - ); - } - }; - - this.getAlertEvent = async () => { - try { - let event = await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, LAG_STATE_EVENT, this.protectedRoomId); - if (Object.keys(event).length == 0) { - // Event was redacted. - return null; - } - return event; - } catch (ex) { - // No such event. - return null; - } - }; - - this.getCommandStatus = async () => { - const protectedRoomReply = await getFirstReply(this.mjolnir.client, this.mjolnir.managementRoomId, () => { - const command = `!mjolnir status protection DetectFederationLag ${this.protectedRoomId}`; - return this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); - }); - const globalReply = await getFirstReply(this.mjolnir.client, this.mjolnir.managementRoomId, () => { - const command = `!mjolnir status protection DetectFederationLag *`; - return this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); - }); - const protectedRoomStatsStr = protectedRoomReply.content.body.match(RE_STATS)[0]; - const globalStatsStr = globalReply.content.body.match(RE_STATS)[0]; - return { - protectedRoomStats: protectedRoomStatsStr ? JSON.parse(protectedRoomStatsStr) : null, - globalStats: globalStatsStr ? JSON.parse(globalStatsStr) : null, - } - } - }); - - afterEach(async function() { - await this.detector.cleanup(); - this.detector.dispose(); - await this.moderator?.stop(); - }); - - let simulateDate = (start: Date, progress: number = SAMPLE_SIZE) => - new Date(start.getTime() + 2 * progress * BUCKET_DURATION_MS / SAMPLE_SIZE); - - it('DetectFederationLag doesn\'t detect lag when there isn\'t any', async function() { - this.timeout(60000); - const MULTIPLIERS = [0, 0.5, 0.9]; - - // In this test, all the events we send have a lag < {local, federated}HomeserverLagEnterWarningZoneMS. - const start = new Date(); - - // Ensure that no alert has been emitted yet. - assert.equal(await this.getAlertEvent(), null, "Initially, there should be no alert"); - - // First, let's send events from the local homeserver. - const LOCAL_SENDERS = [`@local_user:${this.localDomain}`]; - for (let multiplier of MULTIPLIERS) { - const LAG = multiplier * LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS; - await this.simulateLag(LOCAL_SENDERS, LAG, start); - assert.equal(await this.getAlertEvent(), null, `We have sent lots of local pseudo-events with a small lag of ${LAG}, there should be NO alert`); - } - - // Three distinct remote servers should be sufficient to trigger an alert, if they all lag. - const REMOTE_SENDERS = [ - "@user2:left.example.com", - "@user3:right.example.com", - "@user4:middle.example.com", - ]; - for (let multiplier of MULTIPLIERS) { - const LAG = multiplier * FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS; - await this.simulateLag(REMOTE_SENDERS, LAG, start); - assert.equal(await this.getAlertEvent(), null, `We have sent lots of remote pseudo-events with a small lag of ${LAG}, there should be NO alert`); - } - - const {protectedRoomStats, globalStats} = await this.getCommandStatus(); - assert.ok(protectedRoomStats, "We should see stats for our room"); - assert.ok(protectedRoomStats.min >= 0, `min ${protectedRoomStats.min} >= 0`); - assert.ok(protectedRoomStats.min < protectedRoomStats.max); - assert.ok(protectedRoomStats.mean > 0); - assert.ok(protectedRoomStats.mean < protectedRoomStats.max); - assert.ok(protectedRoomStats.median < protectedRoomStats.max); - assert.ok(protectedRoomStats.median > 0); - assert.ok(protectedRoomStats.max >= MULTIPLIERS[MULTIPLIERS.length - 1] * FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS); - assert.ok(protectedRoomStats.max < FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS); - assert.deepEqual(globalStats, { [this.protectedRoomId]: protectedRoomStats }); - }); - - it('DetectFederationLag detects lag on local homeserver', async function() { - this.timeout(60000); - // In this test, all the events we send have a lag > localHomeserverLagEnterWarningZoneMS. - const start = new Date(); - const stop = simulateDate(start); - - // Ensure that no alert has been emitted yet. - assert.equal(await this.getAlertEvent(), null, "Initially, there should be no alert"); - - // Simulate lagging events from the local homeserver. This should trigger an alarm. - const SENDERS = [`@local_user_1:${this.localDomain}`]; - await this.simulateLag(SENDERS, 1.5 * LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, start); - - let lagEvent = await this.getAlertEvent(); - console.debug(lagEvent); - - assert(lagEvent, "Local lag should be reported"); - assert.equal(JSON.stringify(lagEvent.domains), JSON.stringify([this.localDomain]), "Lag report should mention only the local domain"); - assert.equal(lagEvent.roomId, this.protectedRoomId, "Lag report should mention the right room"); - assert(new Date(lagEvent.since) >= start, "Lag report should have happened since `now`"); - assert(new Date(lagEvent.since) < stop, "Lag should have been detected before the end of the bombardment"); - - { - const {protectedRoomStats, globalStats} = await this.getCommandStatus(); - assert.ok(protectedRoomStats, "We should see stats for our room"); - assert.ok(protectedRoomStats.min >= 0, `min ${protectedRoomStats.min} >= 0`); - assert.ok(protectedRoomStats.min < protectedRoomStats.max); - assert.ok(protectedRoomStats.mean > 0); - assert.ok(protectedRoomStats.mean < protectedRoomStats.max); - assert.ok(protectedRoomStats.median < protectedRoomStats.max); - assert.ok(protectedRoomStats.median > 0); - assert.ok(protectedRoomStats.max >= 1.5 * LOCAL_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS); - assert.deepEqual(globalStats, { [this.protectedRoomId]: protectedRoomStats }) - } - - // Simulate non-lagging events from the local homeserver. After a while, this should rescind the alarm. - // We switch to a new (pseudo-)user to simplify reading logs. - const SENDERS_2 = [`@local_user_2:${this.localDomain}`]; - const start2 = new Date(stop.getTime() + 1_000); - await this.simulateLag(SENDERS_2, 0.75 * LOCAL_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS, start2); - - assert.equal(await this.getAlertEvent(), null, "The alert should now be rescinded"); - - { - const {protectedRoomStats, globalStats} = await this.getCommandStatus(); - assert.ok(protectedRoomStats, "We should see stats for our room"); - assert.ok(protectedRoomStats.min >= 0, `min ${protectedRoomStats.min} >= 0`); - assert.ok(protectedRoomStats.min < protectedRoomStats.max); - assert.ok(protectedRoomStats.mean > 0); - assert.ok(protectedRoomStats.mean < protectedRoomStats.max); - assert.ok(protectedRoomStats.median < protectedRoomStats.max); - assert.ok(protectedRoomStats.median > 0); - assert.ok(protectedRoomStats.max >= 0.75 * LOCAL_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS); - assert.ok(protectedRoomStats.max < FEDERATED_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS); - assert.deepEqual(globalStats, { [this.protectedRoomId]: protectedRoomStats }) - } - }); - - it('DetectFederationLag doesn\'t report lag when only one federated homeserver lags', async function() { - this.timeout(60000); - // In this test, all the events we send have a lag > federatedHomeserverLagEnterWarningZoneMS. - const start = new Date(); - - // Ensure that no alert has been emitted yet. - assert.equal(await this.getAlertEvent(), null, "Initially, there should be no alert"); - - // First, let's send events from the local homeserver. - const SENDERS = ["@left:left.example.com"]; - await this.simulateLag(SENDERS, 1.5 * FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, start); - - let lagEvent = await this.getAlertEvent(); - assert.equal(lagEvent, null, "With only one federated homeserver lagging, we shouldn't report any lag"); - }); - - it('DetectFederationLag reports lag when three federated homeservers lag', async function() { - this.timeout(60000); - // In this test, all the events we send have a lag > federatedHomeserverLagEnterWarningZoneMS. - const start = new Date(); - const stop = simulateDate(start); - - // Ensure that no alert has been emitted yet. - assert.equal(await this.getAlertEvent(), null, "Initially, there should be no alert"); - - // Simulate lagging events from remote homeservers. This should trigger an alarm. - const SENDERS = [ - "@left:left.example.com", - "@middle:middle.example.com", - "@right:right.example.com", - ]; - await this.simulateLag(SENDERS, 1.5 * FEDERATED_HOMESERVER_LAG_ENTER_WARNING_ZONE_MS, start); - - let lagEvent = await this.getAlertEvent(); - console.debug(lagEvent); - assert(lagEvent, "Local lag should be reported"); - assert.equal(JSON.stringify(lagEvent.domains.sort()), JSON.stringify(["left.example.com", "middle.example.com", "right.example.com"]), "Lag report should mention only the local domain"); - assert.equal(lagEvent.roomId, this.protectedRoomId, "Lag report should mention the right room"); - assert(new Date(lagEvent.since) >= start, "Lag report should have happened since `now`"); - assert(new Date(lagEvent.since) < stop, "Lag should have been detected before the end of the bombardment"); - - // Simulate non-lagging events from remote homeservers. After a while, this should rescind the alarm. - // We switch to new (pseudo-)users to simplify reading logs. - const SENDERS_2 = [ - "@left_2:left.example.com", - "@middle_2:middle.example.com", - "@right_2:right.example.com", - ]; - const start2 = new Date(stop.getTime() + 1_000); - await this.simulateLag(SENDERS_2, 0.75 * FEDERATED_HOMESERVER_LAG_EXIT_WARNING_ZONE_MS, start2); - - assert.equal(await this.getAlertEvent(), null, "The alert should now be rescinded"); - }); -}); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 72e1599a..e8f5b570 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -1,7 +1,8 @@ +import { 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 { WATCHED_LISTS_EVENT_TYPE } from "../../src/models/PolicyList"; import { patchMatrixClient } from "../../src/utils"; -import { makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils"; +import { DraupnirTestContext, draupnirClient, makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils"; patchMatrixClient(); @@ -12,34 +13,45 @@ patchMatrixClient(); // So there is some code in here to "undo" the mutation after we stop Mjolnir syncing. export const mochaHooks = { beforeEach: [ - async function() { - console.error("---- entering test", JSON.stringify(this.currentTest.title)); // Makes MatrixClient error logs a bit easier to parse. + 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"); // Sometimes it takes a little longer to register users. this.timeout(30000); const config = this.config = configRead(); this.managementRoomAlias = config.managementRoom; - this.mjolnir = await makeMjolnir(config); - config.RUNTIME.client = this.mjolnir.client; + this.draupnir = await makeMjolnir(config); + config.RUNTIME.client = draupnirClient()!; await Promise.all([ - this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }), - this.mjolnir.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { references: [] }), + this.draupnir.client.setAccountData(MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, { rooms: [] }), + this.draupnir.client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, { references: [] }), ]); - await this.mjolnir.start(); + await this.draupnir.start(); + this.apis = constructWebAPIs(this.draupnir); + await this.apis.start(); + await draupnirClient()?.start(); console.log("mochaHooks.beforeEach DONE"); } ], afterEach: [ - async function() { + async function(this: DraupnirTestContext) { this.timeout(10000) - await this.mjolnir.stop(); - await Promise.all([ - this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }), - this.mjolnir.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { references: [] }), - ]); + this.apis?.stop(); + draupnirClient()?.stop(); + this.draupnir?.stop(); + // remove alias from management room and leave it. - await teardownManagementRoom(this.mjolnir.client, this.mjolnir.managementRoomId, this.managementRoomAlias); - console.error("---- completed test", JSON.stringify(this.currentTest.title), "\n\n"); // Makes MatrixClient error logs a bit easier to parse. + 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 47a33841..cdc4425f 100644 --- a/test/integration/helloTest.ts +++ b/test/integration/helloTest.ts @@ -1,29 +1,29 @@ -import config from "../../src/config"; +import { MatrixClient } from "matrix-bot-sdk"; import { newTestUser, noticeListener } from "./clientHelper" +import { DraupnirTestContext } from "./mjolnirSetupUtils"; describe("Test: !help command", function() { - let client; - this.beforeEach(async function () { + let client: MatrixClient; + this.beforeEach(async function (this: DraupnirTestContext) { client = await newTestUser(this.config.homeserverUrl, { name: { contains: "-" }});; await client.start(); - }) + } as any) this.afterEach(async function () { - await client.stop(); - }) - it('Mjolnir responded to !mjolnir help', async function() { + client?.stop(); + } as any) + it('Mjolnir responded to !mjolnir help', async function(this: DraupnirTestContext) { this.timeout(30000); // send a messgage await client.joinRoom(this.config.managementRoom); // listener for getting the event reply let reply = new Promise((resolve, reject) => { - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Print status information")) { + client.on('room.message', noticeListener(this.draupnir!.managementRoomID, (event) => { + if (event.content.body.includes("which can be used")) { resolve(event); } - }))}); - // check we get one back - console.log(config); - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir help"}) + })) + }); + await client.sendMessage(this.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 eee1f094..f8843440 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -2,11 +2,19 @@ * This file is used to launch mjolnir for manual testing, creating a user and management room automatically if it doesn't already exist. */ -import { makeMjolnir } from "./mjolnirSetupUtils"; +import { draupnirClient, makeMjolnir } from "./mjolnirSetupUtils"; 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"; (async () => { const config = configRead(); - let mjolnir = await makeMjolnir(config); + let 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 d1a7947d..527e0e61 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -21,15 +21,25 @@ import { LogLevel, RichConsoleLogger } from "matrix-bot-sdk"; -import { Mjolnir} from '../../src/Mjolnir'; 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 { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEventDecoder, RoomStateBackingStore } from "matrix-protection-suite"; +import { WebAPIs } from "../../src/webapis/WebAPIs"; patchMatrixClient(); -export interface MjolnirTestContext extends Mocha.Context { - mjolnir?: Mjolnir +// they are add [key: string]: any to their interface, amazing. +export type SafeMochaContext = Pick + +export interface DraupnirTestContext extends SafeMochaContext { + draupnir?: Draupnir + managementRoomAlias?: string, + apis?: WebAPIs, + config: IConfig, } /** @@ -68,19 +78,19 @@ async function configureMjolnir(config: IConfig) { }; } -export function mjolnir(): Mjolnir | null { +export function draupnir(): Draupnir | null { return globalMjolnir; } -export function matrixClient(): MatrixClient | null { +export function draupnirClient(): MatrixClient | null { return globalClient; } let globalClient: MatrixClient | null -let globalMjolnir: Mjolnir | null; +let globalMjolnir: Draupnir | null; /** * Return a test instance of Mjolnir. */ -export async function makeMjolnir(config: IConfig): Promise { +export async function makeMjolnir(config: IConfig, backingStore?: RoomStateBackingStore): Promise { await configureMjolnir(config); LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); @@ -89,7 +99,7 @@ export async function makeMjolnir(config: IConfig): Promise { const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password); await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId()); await ensureAliasedRoomExists(client, config.managementRoom); - let mj = await Mjolnir.setupMjolnirFromConfig(client, client, config); + let mj = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config, backingStore); globalClient = client; globalMjolnir = mj; return mj; diff --git a/test/integration/policyConsumptionTest.ts b/test/integration/policyConsumptionTest.ts deleted file mode 100644 index 9b2cc0be..00000000 --- a/test/integration/policyConsumptionTest.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { strict as assert } from "assert"; - -import { newTestUser } from "./clientHelper"; -import { Mjolnir } from "../../src/Mjolnir"; -import { read as configRead } from "../../src/config"; -import { getRequestFn, LogService } from "matrix-bot-sdk"; -import { createBanList, getFirstReaction } from "./commands/commandUtils"; - -/** - * Get a copy of the rules from the ruleserver. - */ -async function currentRules(mjolnir: Mjolnir): Promise<{ start: object, stop: object, since: string }> { - return await new Promise((resolve, reject) => getRequestFn()({ - uri: `http://${mjolnir.config.web.address}:${mjolnir.config.web.port}/api/1/ruleserver/updates/`, - method: "GET" - }, (error: object, _response: any, body: any) => { - if (error) { - reject(error) - } else { - resolve(body) - } - })); -} - -/** - * Wait for the rules to change as a result of the thunk. The returned promise will resolve when the rules being served have changed. - * @param thunk Should cause the rules the RuleServer is serving to change some way. - */ -async function waitForRuleChange(mjolnir: Mjolnir, thunk: any): Promise { - const initialRules = await currentRules(mjolnir); - let rules = initialRules; - // We use JSON.stringify like this so that it is pretty printed in the log and human readable. - LogService.debug('policyConsumptionTest', `Rules before we wait for them to change: ${JSON.stringify(rules, null, 2)}`); - await thunk(); - while (rules.since === initialRules.since) { - await new Promise(resolve => { - setTimeout(resolve, 500); - }) - rules = await currentRules(mjolnir); - }; - // The problem is, we have no idea how long a consumer will take to process the changed rules. - // We know the pull peroid is 1 second though. - await new Promise(resolve => { - setTimeout(resolve, 1500); - }) - LogService.debug('policyConsumptionTest', `Rules after they have changed: ${JSON.stringify(rules, null, 2)}`); -} - -describe("Test: that policy lists are consumed by the associated synapse module", function () { - this.afterEach(async function () { - if (this.config.web.ruleServer.enabled) { - this.timeout(5000) - LogService.debug('policyConsumptionTest', `Rules at end of test ${JSON.stringify(await currentRules(this.mjolnir), null, 2)}`); - // Clear any state associated with the account. - await this.mjolnir.client.setAccountData('org.matrix.mjolnir.watched_lists', { - references: [], - }); - } - }) - this.beforeAll(async function() { - let config = configRead(); - if (!config?.web?.ruleServer?.enabled) { - LogService.warn("policyConsumptionTest", "Skipping policy consumption test because the ruleServer is not enabled") - this.skip(); - } - }) - it('blocks users in antispam when they are banned from sending messages and invites serverwide.', async function() { - this.timeout(20000); - // Create a few users and a room. - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); - let badUserId = await badUser.getUserId(); - let mjolnirUserId = await this.mjolnir.client.getUserId(); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - this.moderator = moderator; - await moderator.joinRoom(this.mjolnir.managementRoomId); - let unprotectedRoom = await badUser.createRoom({ invite: [await moderator.getUserId()]}); - // We do this so the moderator can send invites, no other reason. - await badUser.setUserPowerLevel(await moderator.getUserId(), unprotectedRoom, 100); - await moderator.joinRoom(unprotectedRoom); - const banList = await createBanList(this.mjolnir.managementRoomId, this.mjolnir.client, moderator); - await badUser.sendMessage(unprotectedRoom, {msgtype: 'm.text', body: 'Something bad and mean'}); - - await waitForRuleChange(this.mjolnir, async () => { - await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${badUserId} ${banList}` }); - }); - }); - await assert.rejects(badUser.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'}), 'The bad user should be banned and unable to send messages.'); - await assert.rejects(badUser.inviteUser(mjolnirUserId, unprotectedRoom), 'They should also be unable to send invitations.'); - assert.ok(await moderator.inviteUser('@test:localhost:9999', unprotectedRoom), 'The moderator is not banned though so should still be able to invite'); - assert.ok(await moderator.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'}), 'They should be able to send messages still too.'); - - // Test we can remove the rules. - await waitForRuleChange(this.mjolnir, async () => { - await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${badUserId} ${banList}` }); - }); - }); - assert.ok(await badUser.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'})); - assert.ok(await badUser.inviteUser(mjolnirUserId, unprotectedRoom)); - }) - it('Test: Cannot send message to a room that is listed in a policy list and cannot invite a user to the room either', async function () { - this.timeout(20000); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - await moderator.joinRoom(this.mjolnir.managementRoomId); - const banList = await createBanList(this.mjolnir.managementRoomId, this.mjolnir.client, moderator); - let badRoom = await badUser.createRoom(); - let unrelatedRoom = await badUser.createRoom(); - await badUser.sendMessage(badRoom, {msgtype: 'm.text', body: "Very Bad Stuff in this room"}); - await waitForRuleChange(this.mjolnir, async () => { - await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${badRoom} ${banList}` }); - }); - }); - await assert.rejects(badUser.sendMessage(badRoom, { msgtype: 'm.text', body: 'test'}), 'should not be able to send messagea to a room which is listed.'); - await assert.rejects(badUser.inviteUser(await moderator.getUserId(), badRoom), 'should not be able to invite people to a listed room.'); - assert.ok(await badUser.sendMessage(unrelatedRoom, { msgtype: 'm.text.', body: 'hey'}), 'should be able to send messages to unrelated room'); - assert.ok(await badUser.inviteUser(await moderator.getUserId(), unrelatedRoom), 'They should still be able to invite to other rooms though'); - // Test we can remove these rules. - await waitForRuleChange(this.mjolnir, async () => { - await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${badRoom} ${banList}` }); - }); - }); - - assert.ok(await badUser.sendMessage(badRoom, { msgtype: 'm.text', body: 'test'}), 'should now be able to send messages to the room.'); - assert.ok(await badUser.inviteUser(await moderator.getUserId(), badRoom), 'should now be able to send messages to the room.'); - }) - it('Test: When a list becomes unwatched, the associated policies are stopped.', async function () { - this.timeout(20000); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - await moderator.joinRoom(this.mjolnir.managementRoomId); - const banList = await createBanList(this.mjolnir.managementRoomId, this.mjolnir.client, moderator); - let targetRoom = await moderator.createRoom(); - await moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: "Fluffy Foxes."}); - await waitForRuleChange(this.mjolnir, async () => { - await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${targetRoom} ${banList}` }); - }); - }); - await assert.rejects(moderator.sendMessage(targetRoom, { msgtype: 'm.text', body: 'test'}), 'should not be able to send messages to a room which is listed.'); - - await waitForRuleChange(this.mjolnir, async () => { - await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, '✅', async () => { - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unwatch #${banList}:localhost:9999` }); - }); - }); - - assert.ok(await moderator.sendMessage(targetRoom, { msgtype: 'm.text', body: 'test'}), 'should now be able to send messages to the room.'); - }) -}); diff --git a/test/integration/protectedRoomsConfigTest.ts b/test/integration/protectedRoomsConfigTest.ts deleted file mode 100644 index d2ffe1ac..00000000 --- a/test/integration/protectedRoomsConfigTest.ts +++ /dev/null @@ -1,64 +0,0 @@ - -import { strict as assert } from "assert"; -import { MatrixClient, Permalinks } from "matrix-bot-sdk"; -import { MatrixRoomReference } from "../../src/commands/interface-manager/MatrixRoomReference"; -import { MatrixSendClient } from "../../src/MatrixEmitter"; -import { Mjolnir } from "../../src/Mjolnir"; -import PolicyList from "../../src/models/PolicyList"; -import { newTestUser } from "./clientHelper"; -import { createBanList } from "./commands/commandUtils"; - -async function createPolicyList(client: MatrixClient): Promise { - const policyListId = await client.createRoom({ preset: "public_chat" }); - return new PolicyList(policyListId, Permalinks.forRoom(policyListId), client); -} - -async function getProtectedRoomsFromAccountData(client: MatrixSendClient): Promise { - const rooms: { rooms?: string[] } = await client.getAccountData("org.matrix.mjolnir.protected_rooms"); - return rooms.rooms!; -} - -describe('Test: config.protectAllJoinedRooms behaves correctly.', function() { - it('does not clobber the account data.', async function() { - // set up account data for a protected room with your own list and a watched list. - const mjolnir: Mjolnir = this.mjolnir!; - - // moderator sets up some rooms, that aren't explicitly protected - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await moderator.joinRoom(mjolnir.managementRoomId); - const implicitlyProtectedRooms = await Promise.all( - [...Array(2).keys()].map(_ => moderator.createRoom({ preset: "public_chat" })) - ); - await Promise.all( - implicitlyProtectedRooms.map(roomId => mjolnir.client.joinRoom(roomId)) - ); - - // we sync and check that none of them end up in account data - await mjolnir.protectedRoomsTracker.syncLists(); - (await getProtectedRoomsFromAccountData(mjolnir.client)) - .forEach(roomId => assert.equal(implicitlyProtectedRooms.includes(roomId), false)); - - // ... but they are protected - mjolnir.protectedRoomsTracker.getProtectedRooms() - .forEach(roomId => assert.equal(implicitlyProtectedRooms.includes(roomId), true)); - - // We create one policy list with Mjolnir, and we watch another that is maintained by someone else. - const policyListShortcode = await createBanList(mjolnir.managementRoomId, mjolnir.matrixEmitter, moderator); - const unprotectedWatchedList = await createPolicyList(moderator); - await mjolnir.policyListManager.watchList(MatrixRoomReference.fromPermalink(unprotectedWatchedList.roomRef)); - await mjolnir.protectedRoomsTracker.syncLists(); - - // We expect that the watched list will not be protected, despite config.protectAllJoinedRooms being true - // this is necessary so that it doesn't try change acl, ban users etc in someone else's list. - assert.equal(mjolnir.protectedRoomsTracker.getProtectedRooms().includes(unprotectedWatchedList.roomId), false); - const accountDataAfterListSetup = await getProtectedRoomsFromAccountData(mjolnir.client); - assert.equal(accountDataAfterListSetup.includes(unprotectedWatchedList.roomId), false); - // But our own list should be protected AND stored in account data - assert.equal(accountDataAfterListSetup.length, 1); - const policyListId = accountDataAfterListSetup[0]; - assert.equal(mjolnir.protectedRoomsTracker.getProtectedRooms().includes(policyListId), true); - // Confirm that it is the right room, since we only get the shortcode back when using the command to create a list. - const shortcodeInfo = await mjolnir.client.getRoomStateEvent(policyListId, "org.matrix.mjolnir.shortcode", ""); - assert.equal(shortcodeInfo.shortcode, policyListShortcode); - }) -}); diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts deleted file mode 100644 index 8eb36d1c..00000000 --- a/test/integration/protectionSettingsTest.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { strict as assert } from "assert"; -import { MatrixClient } from "matrix-bot-sdk"; -import { Protection } from "../../src/protections/Protection"; -import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; -import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; -import { newTestUser, noticeListener } from "./clientHelper"; - -describe("Test: Protection settings", function() { - let syncingUser: MatrixClient|undefined; - this.beforeEach(async function () { - syncingUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }}); - await syncingUser.start(); - }) - this.afterEach(async function () { - await syncingUser?.stop(); - }) - it("Mjolnir refuses to save invalid protection setting values", async function() { - this.timeout(20000); - await assert.rejects( - async () => await this.mjolnir.protectionManager.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}), - ProtectionSettingValidationError - ); - }); - it("Mjolnir successfully saves valid protection setting values", async function() { - this.timeout(20000); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "05OVMS"; - description = "A test protection"; - settings = { test: new NumberProtectionSetting(3) }; - }); - - await this.mjolnir.protectionManager.setProtectionSettings("05OVMS", { test: 123 }); - assert.equal( - (await this.mjolnir.protectionManager.getProtectionSettings("05OVMS"))["test"], - 123 - ); - }); - it("Mjolnir should accumulate changed settings", async function() { - this.timeout(20000); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "HPUjKN"; - description = "A test protection"; - settings = { - test1: new NumberProtectionSetting(3), - test2: new NumberProtectionSetting(4) - }; - }); - - await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test1: 1 }); - await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test2: 2 }); - const settings = await this.mjolnir.protectionManager.getProtectionSettings("HPUjKN"); - assert.equal(settings["test1"], 1); - assert.equal(settings["test2"], 2); - }); - it("Mjolnir responds to !set correctly", async function() { - this.timeout(20000); - const client = (assert.notEqual(undefined, syncingUser), syncingUser!); - await client.joinRoom(this.config.managementRoom); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "JY2TPN"; - description = "A test protection"; - settings = { test: new StringProtectionSetting() }; - }); - - - let reply = new Promise((resolve, reject) => { - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Changed JY2TPN.test ")) { - resolve(event); - } - })) - }); - - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"}) - await reply - - const settings = await this.mjolnir.protectionManager.getProtectionSettings("JY2TPN"); - assert.equal(settings["test"], "asd"); - }); - it("Mjolnir adds a value to a list setting", async function() { - this.timeout(20000); - const client = (assert.notEqual(undefined, syncingUser), syncingUser!); - await client.joinRoom(this.config.managementRoom); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "r33XyT"; - description = "A test protection"; - settings = { test: new StringListProtectionSetting() }; - }); - - - let reply = new Promise((resolve, reject) => { - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Changed r33XyT.test ")) { - resolve(event); - } - })) - }); - - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"}) - await reply - - assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("r33XyT"), { "test": ["asd"] }); - }); - it("Mjolnir removes a value from a list setting", async function() { - this.timeout(20000); - const client = (assert.notEqual(undefined, syncingUser), syncingUser!); - await client.joinRoom(this.config.managementRoom); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "oXzT0E"; - description = "A test protection"; - settings = { test: new StringListProtectionSetting() }; - }); - - let reply = () => new Promise((resolve, reject) => { - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Changed oXzT0E.test ")) { - resolve(event); - } - })) - }); - - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add oXzT0E.test asd"}) - await reply(); - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"}) - await reply(); - - assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("oXzT0E"), { "test": [] }); - }); - it("Mjolnir will change a protection setting in-place", async function() { - this.timeout(20000); - const client = (assert.notEqual(undefined, syncingUser), syncingUser!); - await client.joinRoom(this.config.managementRoom); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "d0sNrt"; - description = "A test protection"; - settings = { test: new StringProtectionSetting() }; - }); - - let replyPromise = new Promise((resolve, reject) => { - let i = 0; - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Changed d0sNrt.test ")) { - if (++i === 2) { - resolve(event); - } - } - })) - }); - - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd1"}) - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd2"}) - assert.equal( - (await replyPromise as any).content.body.split("\n", 3)[2], - "Changed d0sNrt.test to asd2 (was asd1)" - ) - }); -}); diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 606ed5e8..49e6d30b 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -1,33 +1,49 @@ -import { Mjolnir } from "../../src/Mjolnir"; -import { Protection } from "../../src/protections/Protection"; +import { MatrixClient } from "matrix-bot-sdk"; import { newTestUser } from "./clientHelper"; +import { DraupnirTestContext } from "./mjolnirSetupUtils"; +import { ActionResult, MatrixRoomReference, Ok, Protection, ProtectionDescription, StandardProtectionSettings, StringRoomID } from "matrix-protection-suite"; describe("Test: Report polling", function() { - let client; + 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() { + it("Mjolnir correctly retrieves a report from synapse", async function(this: DraupnirTestContext) { this.timeout(40000); - - let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await client.getUserId()] }); + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`Test didn't setup properly`); + } + let protectedRoomId = await draupnir.client.createRoom({ invite: [await client.getUserId()] }); await client.joinRoom(protectedRoomId); - await this.mjolnir.addProtectedRoom(protectedRoomId); + await draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID)); const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); await new Promise(async resolve => { - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "jYvufI"; - description = "A test protection"; - settings = { }; - handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { }; - handleReport = async (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) => { - if (reason === "x5h1Je") { - resolve(null); - } - }; - }); - await this.mjolnir.protectionManager.enableProtection("jYvufI"); + 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: [] + }) + }, + protectionSettings: new StandardProtectionSettings( + {}, + {} + ) + } + await draupnir.protectedRoomsSet.protections.addProtection(testProtectionDescription, {}, draupnir.protectedRoomsSet, draupnir); await client.doRequest( "POST", `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { @@ -42,5 +58,5 @@ describe("Test: Report polling", function() { // 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/roomMembersTest.ts b/test/integration/roomMembersTest.ts deleted file mode 100644 index 498f3c33..00000000 --- a/test/integration/roomMembersTest.ts +++ /dev/null @@ -1,739 +0,0 @@ -import { strict as assert } from "assert"; -import { randomUUID } from "crypto"; -import { Mjolnir } from "../../src/Mjolnir"; -import { RoomMemberManager } from "../../src/RoomMembers"; -import { newTestUser } from "./clientHelper"; -import { getFirstReply, getNthReply } from "./commands/commandUtils"; - -describe("Test: Testing RoomMemberManager", function() { - it("RoomMemberManager counts correctly when we call handleEvent manually", async function() { - let manager: RoomMemberManager = this.mjolnir.roomJoins; - let start = new Date(Date.now() - 100_000_000); - const ROOMS = [ - "!room_0@localhost", - "!room_1@localhost" - ]; - for (let room of ROOMS) { - manager.addRoom(room); - } - - let joinDate = (i: number) => new Date(start.getTime() + i * 100_000); - let userId = (i: number) => `@sender_${i}:localhost`; - - // First, add a number of joins. - const SAMPLE_SIZE = 100; - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const event = { - type: 'm.room.member', - state_key: userId(i), - sender: userId(i), - content: { - membership: "join" - } - }; - await manager.handleEvent(ROOMS[i % ROOMS.length], event, joinDate(i)); - } - - { - const joins0 = manager.getUsersInRoom(ROOMS[0], start, 100_000); - const joins1 = manager.getUsersInRoom(ROOMS[1], start, 100_000); - - const joins0ByUserId = new Map(joins0.map(join => [join.userId, join.timestamp])); - const joins1ByUserId = new Map(joins1.map(join => [join.userId, join.timestamp])); - - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const user = userId(i); - let map = i % 2 === 0 ? joins0ByUserId : joins1ByUserId; - const ts = map.get(user); - assert.ok(ts, `User ${user} should have been seen joining room ${i % 2}`); - assert.equal(ts, joinDate(i).getTime(), `User ${user} should have been seen joining the room at the right timestamp`); - map.delete(user); - } - - assert.equal(joins0ByUserId.size, 0, "We should have found all the users in room 0"); - assert.equal(joins1ByUserId.size, 0, "We should have found all the users in room 1"); - } - - // Now, let's add a few leave events. - let leaveDate = (i: number) => new Date(start.getTime() + (SAMPLE_SIZE + i) * 100_000); - - for (let i = 0; i < SAMPLE_SIZE / 3; ++i) { - const user = userId(i * 3); - const event = { - type: 'm.room.member', - state_key: user, - sender: user, - content: { - membership: "leave" - }, - unsigned: { - prev_content: { - membership: "join" - } - } - }; - await manager.handleEvent(ROOMS[0], event, leaveDate(i)); - await manager.handleEvent(ROOMS[1], event, leaveDate(i)); - } - - // Let's see if we have properly updated the joins/leaves - { - const joins0 = manager.getUsersInRoom(ROOMS[0], start, 100_000); - const joins1 = manager.getUsersInRoom(ROOMS[1], start, 100_000); - - const joins0ByUserId = new Map(joins0.map(join => [join.userId, join.timestamp])); - const joins1ByUserId = new Map(joins1.map(join => [join.userId, join.timestamp])); - - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const user = userId(i); - let map = i % 2 === 0 ? joins0ByUserId : joins1ByUserId; - let isStillJoined = i % 3 !== 0; - const ts = map.get(user); - if (isStillJoined) { - assert.ok(ts, `User ${user} should have been seen joining room ${i % 2}`); - assert.equal(ts, joinDate(i).getTime(), `User ${user} should have been seen joining the room at the right timestamp`); - map.delete(user); - } else { - assert.ok(!ts, `User ${user} should not be seen as a member of room ${i % 2} anymore`); - } - } - - assert.equal(joins0ByUserId.size, 0, "We should have found all the users in room 0"); - assert.equal(joins1ByUserId.size, 0, "We should have found all the users in room 1"); - } - - // Now let's make a few of these users rejoin. - let rejoinDate = (i: number) => new Date(start.getTime() + (SAMPLE_SIZE * 2 + i) * 100_000); - - for (let i = 0; i < SAMPLE_SIZE / 9; ++i) { - const user = userId(i * 9); - const event = { - type: 'm.room.member', - state_key: user, - sender: user, - content: { - membership: "join" - }, - unsigned: { - prev_content: { - membership: "leave" - } - } - }; - const room = ROOMS[i * 9 % 2]; - await manager.handleEvent(room, event, rejoinDate(i * 9)); - } - - // Let's see if we have properly updated the joins/leaves - { - const joins0 = manager.getUsersInRoom(ROOMS[0], start, 100_000); - const joins1 = manager.getUsersInRoom(ROOMS[1], start, 100_000); - - const joins0ByUserId = new Map(joins0.map(join => [join.userId, join.timestamp])); - const joins1ByUserId = new Map(joins1.map(join => [join.userId, join.timestamp])); - - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const user = userId(i); - let map = i % 2 === 0 ? joins0ByUserId : joins1ByUserId; - let hasLeft = i % 3 === 0; - let hasRejoined = i % 9 === 0; - const ts = map.get(user); - if (hasRejoined) { - assert.ok(ts, `User ${user} should have been seen rejoining room ${i % 2}`); - assert.equal(ts, rejoinDate(i).getTime(), `User ${user} should have been seen rejoining the room at the right timestamp, got ${ts}`); - map.delete(user); - } else if (hasLeft) { - assert.ok(!ts, `User ${user} should not be seen as a member of room ${i % 2} anymore`); - } else { - assert.ok(ts, `User ${user} should have been seen joining room ${i % 2}`); - assert.equal(ts, joinDate(i).getTime(), `User ${user} should have been seen joining the room at the right timestamp`); - map.delete(user); - } - } - - assert.equal(joins0ByUserId.size, 0, "We should have found all the users in room 0"); - assert.equal(joins1ByUserId.size, 0, "We should have found all the users in room 1"); - } - - // Now let's check only the most recent joins. - { - const joins0 = manager.getUsersInRoom(ROOMS[0], rejoinDate(-1), 100_000); - const joins1 = manager.getUsersInRoom(ROOMS[1], rejoinDate(-1), 100_000); - - const joins0ByUserId = new Map(joins0.map(join => [join.userId, join.timestamp])); - const joins1ByUserId = new Map(joins1.map(join => [join.userId, join.timestamp])); - - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const user = userId(i); - let map = i % 2 === 0 ? joins0ByUserId : joins1ByUserId; - let hasRejoined = i % 9 === 0; - const ts = map.get(user); - if (hasRejoined) { - assert.ok(ts, `User ${user} should have been seen rejoining room ${i % 2}`); - assert.equal(ts, rejoinDate(i).getTime(), `User ${user} should have been seen rejoining the room at the right timestamp, got ${ts}`); - map.delete(user); - } else { - assert.ok(!ts, `When looking only at recent entries, user ${user} should not be seen as a member of room ${i % 2} anymore`); - } - } - - assert.equal(joins0ByUserId.size, 0, "We should have found all the users who recently joined room 0"); - assert.equal(joins1ByUserId.size, 0, "We should have found all the users who recently joined room 1"); - } - - // Perform a cleanup on both rooms, check that we have the same results. - for (let roomId of ROOMS) { - manager.cleanup(roomId); - } - - // Let's see if we have properly updated the joins/leaves - { - const joins0 = manager.getUsersInRoom(ROOMS[0], start, 100_000); - const joins1 = manager.getUsersInRoom(ROOMS[1], start, 100_000); - - const joins0ByUserId = new Map(joins0.map(join => [join.userId, join.timestamp])); - const joins1ByUserId = new Map(joins1.map(join => [join.userId, join.timestamp])); - - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const user = userId(i); - let map = i % 2 === 0 ? joins0ByUserId : joins1ByUserId; - let hasLeft = i % 3 === 0; - let hasRejoined = i % 9 === 0; - const ts = map.get(user); - if (hasRejoined) { - assert.ok(ts, `After cleanup, user ${user} should have been seen rejoining room ${i % 2}`); - assert.equal(ts, rejoinDate(i).getTime(), `After cleanup, user ${user} should have been seen rejoining the room at the right timestamp, got ${ts}`); - map.delete(user); - } else if (hasLeft) { - assert.ok(!ts, `After cleanup, user ${user} should not be seen as a member of room ${i % 2} anymore`); - } else { - assert.ok(ts, `After cleanup, user ${user} should have been seen joining room ${i % 2}`); - assert.equal(ts, joinDate(i).getTime(), `After cleanup, user ${user} should have been seen joining the room at the right timestamp`); - map.delete(user); - } - } - - assert.equal(joins0ByUserId.size, 0, "After cleanup, we should have found all the users in room 0"); - assert.equal(joins1ByUserId.size, 0, "After cleanup, we should have found all the users in room 1"); - } - - // Now let's check only the most recent joins. - { - const joins0 = manager.getUsersInRoom(ROOMS[0], rejoinDate(-1), 100_000); - const joins1 = manager.getUsersInRoom(ROOMS[1], rejoinDate(-1), 100_000); - - const joins0ByUserId = new Map(joins0.map(join => [join.userId, join.timestamp])); - const joins1ByUserId = new Map(joins1.map(join => [join.userId, join.timestamp])); - - for (let i = 0; i < SAMPLE_SIZE; ++i) { - const user = userId(i); - let map = i % 2 === 0 ? joins0ByUserId : joins1ByUserId; - let hasRejoined = i % 9 === 0; - const ts = map.get(user); - if (hasRejoined) { - assert.ok(ts, `After cleanup, user ${user} should have been seen rejoining room ${i % 2}`); - assert.equal(ts, rejoinDate(i).getTime(), `After cleanup, user ${user} should have been seen rejoining the room at the right timestamp, got ${ts}`); - map.delete(user); - } else { - assert.ok(!ts, `After cleanup, when looking only at recent entries, user ${user} should not be seen as a member of room ${i % 2} anymore`); - } - } - - assert.equal(joins0ByUserId.size, 0, "After cleanup, we should have found all the users who recently joined room 0"); - assert.equal(joins1ByUserId.size, 0, "After cleanup, we should have found all the users who recently joined room 1"); - } - }); - - afterEach(async function() { - await this.moderator?.stop(); - for (let array of [this.users, this.goodUsers, this.badUsers]) { - for (let client of array || []) { - await client.stop(); - } - } - }); - - it("RoomMemberManager counts correctly when we actually join/leave/get banned from the room", async function() { - this.timeout(60000); - const start = new Date(Date.now() - 10_000); - const mjolnir: Mjolnir = this.mjolnir!; - mjolnir.protectionManager.disableProtection("BanPropagationProtection"); // don't respond to room level bans. - - // Setup a moderator. - this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await mjolnir.client.inviteUser(await this.moderator.getUserId(), mjolnir.managementRoomId) - await this.moderator.joinRoom(mjolnir.managementRoomId); - - // Create a few users and two rooms. - this.users = []; - const SAMPLE_SIZE = 10; - for (let i = 0; i < SAMPLE_SIZE; ++i) { - this.users.push(await newTestUser(this.config.homeserverUrl, { name: { contains: `user_${i}_room_member_test` } })); - } - const userIds = []; - for (let client of this.users) { - userIds.push(await client.getUserId()); - } - const roomId1 = await this.moderator.createRoom({ - invite: userIds, - preset: "public_chat", - }); - const roomId2 = await this.moderator.createRoom({ - invite: userIds, - preset: "public_chat", - }); - const roomIds = [roomId1, roomId2]; - - for (let roomId of roomIds) { - await this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${roomId}` }); - } - - let protectedRoomsUpdated = false; - do { - let protectedRooms = mjolnir.protectedRoomsTracker.getProtectedRooms(); - protectedRoomsUpdated = true; - for (let roomId of roomIds) { - if (!protectedRooms.includes(roomId)) { - protectedRoomsUpdated = false; - await new Promise(resolve => setTimeout(resolve, 1_000)); - } - } - } while (!protectedRoomsUpdated); - - - // Initially, we shouldn't know about any user in these rooms... except Mjölnir itself. - const manager: RoomMemberManager = mjolnir.roomJoins; - for (let roomId of roomIds) { - const joined = manager.getUsersInRoom(roomId, start, 100); - assert.equal(joined.length, 1, "Initially, we shouldn't know about any other user in these rooms"); - assert.equal(joined[0].userId, await mjolnir.client.getUserId(), "Initially, Mjölnir should be the only known user in these rooms"); - } - - // Initially, the command should show that same result. - for (let roomId of roomIds) { - const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { - const command = `!mjolnir joins ${roomId}`; - return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); - }); - const body = reply["content"]?.["body"] as string; - assert.ok(body.includes("\n1 recent joins"), "Initially the command should respond with 1 user"); - } - - // Now join a few rooms. - for (let i = 0; i < userIds.length; ++i) { - await this.users[i].joinRoom(roomIds[i % roomIds.length]); - } - - // Lists should have been updated. - for (let i = 0; i < roomIds.length; ++i) { - const roomId = roomIds[i]; - const joined = manager.getUsersInRoom(roomId, start, 100); - assert.equal(joined.length, SAMPLE_SIZE / 2 /* half of the users */ + 1 /* mjolnir */, "We should now see all joined users in the room"); - const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { - const command = `!mjolnir joins ${roomId}`; - return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); - }); - const body = reply["content"]?.["body"] as string; - assert.ok(body.includes(`\n${joined.length} recent joins`), `After joins, the command should respond with ${joined.length} users`); - for (let j = 0; j < userIds.length; ++j) { - if (j % roomIds.length === i) { - assert.ok(body.includes(userIds[j]), `After joins, the command should display user ${userIds[j]} in room ${roomId}`); - } else { - assert.ok(!body.includes(userIds[j]), `After joins, the command should NOT display user ${userIds[j]} in room ${roomId}`); - } - } - } - - // Let's kick/ban a few users and see if they still show up. - const removedUsers = new Set(); - for (let i = 0; i < SAMPLE_SIZE / 2; ++i) { - const roomId = roomIds[i % roomIds.length]; - const userId = userIds[i]; - if (i % 3 === 0) { - await this.moderator.kickUser(userId, roomId); - removedUsers.add(userIds[i]); - } else if (i % 3 === 1) { - await this.moderator.banUser(userId, roomId); - removedUsers.add(userId); - } - } - - // Lists should have been updated. - - for (let i = 0; i < roomIds.length; ++i) { - const roomId = roomIds[i]; - const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { - const command = `!mjolnir joins ${roomId}`; - return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); - }); - const body = reply["content"]?.["body"] as string; - for (let j = 0; j < userIds.length; ++j) { - const userId = userIds[j]; - if (j % roomIds.length === i && !removedUsers.has(userId)) { - assert.ok(body.includes(userId), `After kicks, the command should display user ${userId} in room ${roomId}`); - } else { - assert.ok(!body.includes(userId), `After kicks, the command should NOT display user ${userId} in room ${roomId}`); - } - } - } - }); - - it("!mjolnir since kicks the correct users", async function() { - this.timeout(600_000); - const start = new Date(Date.now() - 10_000); - const mjolnir: Mjolnir = this.mjolnir!; - - // Setup a moderator. - this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await this.moderator.joinRoom(mjolnir.managementRoomId); - - // Create a few users. - this.goodUsers = []; - this.badUsers = []; - const SAMPLE_SIZE = 10; - for (let i = 0; i < SAMPLE_SIZE; ++i) { - this.goodUsers.push(await newTestUser(this.config.homeserverUrl, { name: { contains: `good_user_${i}_room_member_test` } })); - this.badUsers.push(await newTestUser(this.config.homeserverUrl, { name: { contains: `bad_user_${i}_room_member_test` } })); - } - const goodUserIds: string[] = []; - const badUserIds: string[] = []; - for (let client of this.goodUsers) { - goodUserIds.push(await client.getUserId()); - } - for (let client of this.badUsers) { - badUserIds.push(await client.getUserId()); - } - - // Create and protect rooms. - // - // We reserve two control rooms: - // - room 0, also known as the "control unprotected room" is unprotected - // (we're not calling `!mjolnir rooms add` for this room), so none - // of the operations of `!mjolnir since` shoud affect it. We are - // using it to control, at the end of each experiment, that none of - // the `!mjolnir since` operations affect it. - // - room 1, also known as the "control protected room" is protected - // (we are calling `!mjolnir rooms add` for this room), but we are - // never directly requesting any `!mjolnir since` action against - // this room. We are using it to control, at the end of each experiment, - // that none of the `!mjolnir since` operations that should target - // one single other room also affect that room. It is, however, affected - // by general operations that are designed to affect all protected rooms. - const NUMBER_OF_ROOMS = 18; - const allRoomIds: string[] = []; - const allRoomAliases: string[] = []; - const mjolnirUserId = await mjolnir.client.getUserId(); - for (let i = 0; i < NUMBER_OF_ROOMS; ++i) { - const roomId = await this.moderator.createRoom({ - invite: [mjolnirUserId, ...goodUserIds, ...badUserIds], - }); - allRoomIds.push(roomId); - - const alias = `#since-test-${randomUUID()}:localhost:9999`; - await this.moderator.createRoomAlias(alias, roomId); - allRoomAliases.push(alias); - } - for (let i = 1; i < allRoomIds.length; ++i) { - // Protect all rooms except allRoomIds[0], as control. - const roomId = allRoomIds[i]; - await mjolnir.client.joinRoom(roomId); - await this.moderator.setUserPowerLevel(mjolnirUserId, roomId, 100); - await this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${roomId}` }); - } - - let protectedRoomsUpdated = false; - do { - let protectedRooms = mjolnir.protectedRoomsTracker.getProtectedRooms(); - protectedRoomsUpdated = true; - for (let i = 1; i < allRoomIds.length; ++i) { - const roomId = allRoomIds[i]; - if (!protectedRooms.includes(roomId)) { - protectedRoomsUpdated = false; - await new Promise(resolve => setTimeout(resolve, 1_000)); - } - } - } while (!protectedRoomsUpdated); - - // Good users join before cut date. - for (let user of this.goodUsers) { - for (let roomId of allRoomIds) { - await user.joinRoom(roomId); - } - } - - await new Promise(resolve => setTimeout(resolve, 5_000)); - - const cutDate = new Date(); - - await new Promise(resolve => setTimeout(resolve, 5_000)); - - // Bad users join after cut date. - for (let user of this.badUsers) { - for (let roomId of allRoomIds) { - await user.joinRoom(roomId); - } - } - - // Finally, prepare our control rooms and separate them - // from the regular rooms. - const CONTROL_UNPROTECTED_ROOM_ID = allRoomIds[0]; - const CONTROL_PROTECTED_ID = allRoomIds[1]; - const roomIds = allRoomIds.slice(2); - const roomAliases = allRoomAliases.slice(2); - - enum Method { - kick, - ban, - mute, - unmute, - } - class Experiment { - // A human-readable name for the command. - readonly name: string; - // If `true`, this command should affect room `CONTROL_PROTECTED_ID`. - // Defaults to `false`. - readonly shouldAffectControlProtected: boolean; - // The actual command-line. - readonly command: (roomId: string, roomAlias: string) => string; - // The number of responses we expect to this command. - // Defaults to `1`. - readonly n: number; - // How affected users should leave the room. - readonly method: Method; - - // If `true`, should this experiment look at the same room as the previous one. - // Defaults to `false`. - readonly isSameRoomAsPrevious: boolean; - - // The index of the room on which we're acting. - // - // Initialized by `addTo`. - roomIndex: number | undefined; - - constructor({name, shouldAffectControlProtected, command, n, method, sameRoom}: {name: string, command: (roomId: string, roomAlias: string) => string, shouldAffectControlProtected?: boolean, n?: number, method: Method, sameRoom?: boolean}) { - this.name = name; - this.shouldAffectControlProtected = typeof shouldAffectControlProtected === "undefined" ? false : shouldAffectControlProtected; - this.command = command; - this.n = typeof n === "undefined" ? 1 : n; - this.method = method; - this.isSameRoomAsPrevious = typeof sameRoom === "undefined" ? false : sameRoom; - } - - // Add an experiment to the list of experiments. - // - // This is how `roomIndex` gets initialized. - addTo(experiments: Experiment[]) { - if (this.isSameRoomAsPrevious) { - this.roomIndex = experiments[experiments.length - 1].roomIndex; - } else if (experiments.length === 0) { - this.roomIndex = 0; - } else { - this.roomIndex = experiments[experiments.length - 1].roomIndex! + 1; - } - experiments.push(this); - } - } - const EXPERIMENTS: Experiment[] = []; - for (let experiment of [ - // Kick bad users in one room, using duration syntax, no reason. - new Experiment({ - name: "kick with duration", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomId}`, - method: Method.kick, - }), - // Ban bad users in one room, using duration syntax, no reason. - new Experiment({ - name: "ban with duration", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms ban 100 ${roomId}`, - method: Method.ban, - }), - // Mute bad users in one room, using duration syntax, no reason. - new Experiment({ - name: "mute with duration", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms mute 100 ${roomId}`, - method: Method.mute, - }), - new Experiment({ - name: "unmute with duration", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId}`, - method: Method.unmute, - sameRoom: true, - }), - // Kick bad users in one room, using date syntax, no reason. - new Experiment({ - name: "kick with date", - command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId}`, - method: Method.kick, - }), - // Ban bad users in one room, using date syntax, no reason. - new Experiment({ - name: "ban with date", - command: (roomId: string) => `!mjolnir since "${cutDate}" ban 100 ${roomId}`, - method: Method.ban, - }), - // Mute bad users in one room, using date syntax, no reason. - new Experiment({ - name: "mute with date", - command: (roomId: string) => `!mjolnir since "${cutDate}" mute 100 ${roomId}`, - method: Method.mute, - }), - new Experiment({ - name: "unmute with date", - command: (roomId: string) => `!mjolnir since "${cutDate}" unmute 100 ${roomId}`, - method: Method.unmute, - sameRoom: true, - }), - - // Kick bad users in one room, using duration syntax, with reason. - new Experiment({ - name: "kick with duration and reason", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomId} bad, bad user`, - method: Method.kick, - }), - // Ban bad users in one room, using duration syntax, with reason. - new Experiment({ - name: "ban with duration and reason", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms ban 100 ${roomId} bad, bad user`, - method: Method.ban, - }), - // Mute bad users in one room, using duration syntax, with reason. - new Experiment({ - name: "mute with duration and reason", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms mute 100 ${roomId} bad, bad user`, - method: Method.mute, - }), - new Experiment({ - name: "unmute with duration and reason", - command: (roomId: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId} bad, bad user`, - method: Method.unmute, - sameRoom: true, - }), - - // Kick bad users in one room, using date syntax, with reason. - new Experiment({ - name: "kick with date and reason", - command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId} bad, bad user`, - shouldAffectControlProtected: false, - n: 1, - method: Method.kick, - }), - // Ban bad users in one room, using date syntax, with reason. - new Experiment({ - name: "ban with date and reason", - command: (roomId: string) => `!mjolnir since "${cutDate}" ban 100 ${roomId} bad, bad user`, - method: Method.ban, - }), - // Mute bad users in one room, using date syntax, with reason. - new Experiment({ - name: "mute with date and reason", - command: (roomId: string) => `!mjolnir since "${cutDate}" mute 100 ${roomId} bad, bad user`, - method: Method.mute, - }), - new Experiment({ - name: "unmute with date and reason", - command: (roomId: string) => `!mjolnir since "${cutDate}" unmute 100 ${roomId} bad, bad user`, - method: Method.unmute, - sameRoom: true, - }), - - // Kick bad users in one room, using duration syntax, without reason, using alias. - new Experiment({ - name: "kick with duration, no reason, alias", - command: (_: string, roomAlias: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias}`, - method: Method.kick, - }), - // Kick bad users in one room, using duration syntax, with reason, using alias. - new Experiment({ - name: "kick with duration, reason and alias", - command: (_: string, roomAlias: string) => `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias} for some reason`, - method: Method.kick, - }), - - // Kick bad users everywhere, no reason - new Experiment({ - name: "kick with date everywhere", - command: () => `!mjolnir since "${cutDate}" kick 100 * bad, bad user`, - shouldAffectControlProtected: true, - n: NUMBER_OF_ROOMS - 1, - method: Method.kick, - }), - ]) { - experiment.addTo(EXPERIMENTS); - } - - // Just-in-case health check, before starting. - { - const usersInUnprotectedControlProtected = await mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID); - const usersInControlProtected = await mjolnir.client.getJoinedRoomMembers(CONTROL_PROTECTED_ID); - for (let userId of goodUserIds) { - assert.ok(usersInUnprotectedControlProtected.includes(userId), `Initially, good user ${userId} should be in the unprotected control room`); - assert.ok(usersInControlProtected.includes(userId), `Initially, good user ${userId} should be in the control room`); - } - for (let userId of badUserIds) { - assert.ok(usersInUnprotectedControlProtected.includes(userId), `Initially, bad user ${userId} should be in the unprotected control room`); - assert.ok(usersInControlProtected.includes(userId), `Initially, bad user ${userId} should be in the control room`); - } - } - - for (let i = 0; i < EXPERIMENTS.length; ++i) { - const experiment = EXPERIMENTS[i]; - const index = experiment.roomIndex!; - const roomId = roomIds[index]; - const roomAlias = roomAliases[index]; - const joined = mjolnir.roomJoins.getUsersInRoom(roomId, start, 100); - console.debug(`Running experiment ${i} "${experiment.name}" in room index ${index} (${roomId} / ${roomAlias}): \`${experiment.command(roomId, roomAlias)}\``); - assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`); - - // Run experiment. - await getNthReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, experiment.n, async () => { - const command = experiment.command(roomId, roomAlias); - let result = await this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); - return result; - }); - - // Check post-conditions. - const usersInRoom = await mjolnir.client.getJoinedRoomMembers(roomId); - const usersInUnprotectedControlProtected = await mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID); - const usersInControlProtected = await mjolnir.client.getJoinedRoomMembers(CONTROL_PROTECTED_ID); - for (let userId of goodUserIds) { - assert.ok(usersInRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in affected room`); - assert.ok(usersInControlProtected.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in control room (${CONTROL_PROTECTED_ID})`); - assert.ok(usersInUnprotectedControlProtected.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected control room (${CONTROL_UNPROTECTED_ROOM_ID})`); - } - if (experiment.method === Method.mute) { - for (let userId of goodUserIds) { - let canSpeak = await mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); - assert.ok(canSpeak, `After a ${experiment.name}, good user ${userId} should still be allowed to speak in the room`); - } - for (let userId of badUserIds) { - let canSpeak = await mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); - assert.ok(!canSpeak, `After a ${experiment.name}, bad user ${userId} should NOT be allowed to speak in the room`); - } - } else if (experiment.method === Method.unmute) { - for (let userId of goodUserIds) { - let canSpeak = await mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); - assert.ok(canSpeak, `After a ${experiment.name}, good user ${userId} should still be allowed to speak in the room`); - } - for (let userId of badUserIds) { - let canSpeak = await mjolnir.client.userHasPowerLevelFor(userId, roomId, "m.message", false); - assert.ok(canSpeak, `After a ${experiment.name}, bad user ${userId} should AGAIN be allowed to speak in the room`); - } - } else { - for (let userId of badUserIds) { - assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`); - assert.equal(usersInControlProtected.includes(userId), !experiment.shouldAffectControlProtected, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectControlProtected ? "NOT" : "still"} be in control room`); - assert.ok(usersInUnprotectedControlProtected.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected control room`); - const leaveEvent = await mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId); - switch (experiment.method) { - case Method.kick: - assert.equal(leaveEvent.membership, "leave"); - break; - case Method.ban: - assert.equal(leaveEvent.membership, "ban"); - break; - } - } - } - } - }); -}); diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts deleted file mode 100644 index af0287ab..00000000 --- a/test/integration/standardConsequenceTest.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Mjolnir } from "../../src/Mjolnir"; -import { Protection } from "../../src/protections/Protection"; -import { newTestUser, noticeListener } from "./clientHelper"; -import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence"; - -describe("Test: standard consequences", function() { - let badUser; - let goodUser; - this.beforeEach(async function () { - badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "standard-consequences" }}); - goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "standard-consequences" }}); - await badUser.start(); - await goodUser.start(); - }) - this.afterEach(async function () { - await badUser.stop(); - await goodUser.stop(); - }) - it("Mjolnir applies a standard consequence redaction", async function() { - this.timeout(20000); - - let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); - await badUser.joinRoom(this.mjolnir.managementRoomId); - await badUser.joinRoom(protectedRoomId); - await this.mjolnir.addProtectedRoom(protectedRoomId); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "JY2TPN"; - description = "A test protection"; - settings = { }; - handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { - if (event.content.body === "ngmWkF") { - return [new ConsequenceRedact("asd")]; - } - }; - }); - await this.mjolnir.protectionManager.enableProtection("JY2TPN"); - - let reply = new Promise(async (resolve, reject) => { - const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); - let redaction; - badUser.on('room.event', (roomId, event) => { - if ( - roomId === protectedRoomId - && event?.type === "m.room.redaction" - && event.redacts === messageId - ) { - redaction = event - } - if ( - roomId === this.mjolnir.managementRoomId - && event?.type === "m.room.message" - && event?.content?.body?.startsWith("protection JY2TPN enacting redact against ") - && redaction !== undefined - ) { - resolve([redaction, event]) - } - }); - }); - - const [eventRedact, eventMessage] = await reply - }); - it("Mjolnir applies a standard consequence ban", async function() { - this.timeout(20000); - - let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); - await badUser.joinRoom(this.mjolnir.managementRoomId); - await badUser.joinRoom(protectedRoomId); - await this.mjolnir.addProtectedRoom(protectedRoomId); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "0LxMTy"; - description = "A test protection"; - settings = { }; - handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { - if (event.content.body === "7Uga3d") { - return [new ConsequenceBan("asd")]; - } - }; - }); - await this.mjolnir.protectionManager.enableProtection("0LxMTy"); - - let reply = new Promise(async (resolve, reject) => { - const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); - let ban; - badUser.on('room.leave', (roomId, event) => { - if ( - roomId === protectedRoomId - && event?.type === "m.room.member" - && event.content?.membership === "ban" - && event.state_key === badUser.userId - ) { - ban = event; - } - }); - badUser.on('room.event', (roomId, event) => { - if ( - roomId === this.mjolnir.managementRoomId - && event?.type === "m.room.message" - && event?.content?.body?.startsWith("protection 0LxMTy enacting ban against ") - && ban !== undefined - ) { - resolve([ban, event]) - } - }); - }); - - const [eventBan, eventMessage] = await reply - }); - it("Mjolnir doesn't ban a good user", async function() { - this.timeout(20000); - - let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await goodUser.getUserId(), await badUser.getUserId()] }); - await badUser.joinRoom(protectedRoomId); - await goodUser.joinRoom(protectedRoomId); - await this.mjolnir.addProtectedRoom(protectedRoomId); - - await this.mjolnir.protectionManager.registerProtection(new class extends Protection { - name = "95B1Cr"; - description = "A test protection"; - settings = { }; - handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { - if (event.content.body === "8HUnwb") { - return [new ConsequenceBan("asd")]; - } - }; - }); - await this.mjolnir.protectionManager.enableProtection("95B1Cr"); - - let reply = new Promise(async (resolve, reject) => { - this.mjolnir.client.on('room.message', async (roomId, event) => { - if (event?.content?.body === "SUwvFT") { - await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "8HUnwb"}); - } - }); - - this.mjolnir.client.on('room.event', (roomId, event) => { - if ( - roomId === protectedRoomId - && event?.type === "m.room.member" - && event.content?.membership === "ban" - ) { - if (event.state_key === goodUser.userId) { - reject("good user has been banned"); - } else if (event.state_key === badUser.userId) { - resolve(null); - } - } - }); - }); - await goodUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "SUwvFT"}); - - await reply - }); -}); diff --git a/test/integration/throttleQueueTest.ts b/test/integration/throttleQueueTest.ts index 1d83c120..0de2c4ec 100644 --- a/test/integration/throttleQueueTest.ts +++ b/test/integration/throttleQueueTest.ts @@ -1,6 +1,3 @@ -import { strict as assert } from "assert"; - -import { UserID } from "matrix-bot-sdk"; import { ThrottlingQueue } from "../../src/queues/ThrottlingQueue"; describe("Test: ThrottlingQueue", function() { diff --git a/test/integration/utilsTest.ts b/test/integration/utilsTest.ts index 4c090464..0e7cbeaa 100644 --- a/test/integration/utilsTest.ts +++ b/test/integration/utilsTest.ts @@ -1,33 +1,34 @@ import { strict as assert } from "assert"; import { LogLevel } from "matrix-bot-sdk"; -import ManagementRoomOutput from "../../src/ManagementRoomOutput"; +import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; describe("Test: utils", function() { - it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() { + it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function(this: DraupnirTestContext) { const managementRoomAlias = this.config.managementRoom; - const managementRoomOutput: ManagementRoomOutput = this.mjolnir.managementRoomOutput; - await this.mjolnir.client.sendStateEvent( - this.mjolnir.managementRoomId, + const draupnir = this.draupnir!; + const managementRoomOutput = draupnir.managementRoomOutput; + await draupnir.client.sendStateEvent( + draupnir.managementRoomID, "m.room.canonical_alias", "", { alias: managementRoomAlias } ); const message: any = await new Promise(async resolve => { - this.mjolnir.client.on('room.message', (roomId, event) => { - if (roomId === this.mjolnir.managementRoomId) { + draupnirClient()!.on('room.message', (roomId, event) => { + if (roomId === draupnir.managementRoomID) { if (event.content?.body?.startsWith("it's")) { resolve(event); } } }) await managementRoomOutput.logMessage(LogLevel.INFO, 'replaceRoomIdsWithPills test', - `it's fun here in ${this.mjolnir.managementRoomId}`, - [this.mjolnir.managementRoomId, "!myfaketestid:example.com"]); + `it's fun here in ${draupnir.managementRoomID}`, + [draupnir.managementRoomID, "!myfaketestid:example.com"]); }); 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 6b2818d6..6bf70a69 100644 --- a/test/scripts/memberQueryTest.ts +++ b/test/scripts/memberQueryTest.ts @@ -85,10 +85,10 @@ function calculateMedian (arr: number[]): number | undefined { } for (const method of [MemberFetchMethod.JoinedMembers, MemberFetchMethod.Members, MemberFetchMethod.State]) { - const times = getTimes(method)!; - const sum = times.reduce((a, b) => a + b, 0); - const mean = (sum / times.length) || 0; - const median = calculateMedian(times); + const nextTimes = getTimes(method)!; + 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 new file mode 100644 index 00000000..d64dd1ed --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "include": ["."], + "compilerOptions": { + "noEmit": true, + } +} diff --git a/tsconfig.json b/tsconfig.json index 046c87d5..5e425e6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,15 +23,6 @@ "jsxFactory": "JSXFactory" }, "include": [ - "./src/**/*", - "./test/appservice/**/*", - "./test/integration/manualLaunchScript.ts", - "./test/integration/roomMembersTest.ts", - "./test/integration/banListTest.ts", - "./test/integration/reportPollingTest", - "./test/integration/policyConsumptionTest.ts", - "./test/integration/protectionSettingsTest.ts", - "./test/integration/banPropagationTest.ts", - "./test/integration/protectedRoomsConfigTest.ts", - ] + "./src/**/*" + ], } diff --git a/yarn.lock b/yarn.lock index 1431bc46..b5ac2dde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -79,7 +79,12 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== -"@humanwhocodes/config-array@^0.11.13": +"@eslint/js@8.57.0": + version "8.57.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" + integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== + +"@humanwhocodes/config-array@^0.11.13", "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== @@ -153,6 +158,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + "@selderee/plugin-htmlparser2@^0.6.0": version "0.6.0" resolved "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz" @@ -211,6 +221,18 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@sinclair/typebox@~0.31.15": + version "0.31.28" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.31.28.tgz#b68831e7bc7d09daac26968ea32f42bedc968ede" + integrity sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ== + +"@types/better-sqlite3@^7.6.9": + version "7.6.9" + resolved "https://registry.yarnpkg.com/@types/better-sqlite3/-/better-sqlite3-7.6.9.tgz#4bff3eb7c5eaaae26f8099606c69279146561c50" + integrity sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.19.2" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" @@ -675,6 +697,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + basic-auth@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" @@ -689,6 +716,14 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +better-sqlite3@^9.4.3: + version "9.4.3" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-9.4.3.tgz#7df39ba3273fbb7c0561cf913572547142868cc4" + integrity sha512-ud0bTmD9O3uWJGuXDltyj3R47Nz0OHX8iqPOT5PMspGqlu/qQFn+5S2eFBUCrySpavTjFXbi4EgrfVvPAHlImw== + dependencies: + bindings "^1.5.0" + prebuild-install "^7.1.1" + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" @@ -701,11 +736,27 @@ binary-search-tree@0.2.5: dependencies: underscore "~1.4.4" +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bintrees@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz" integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.5.0: version "3.7.2" resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz" @@ -784,6 +835,14 @@ buffer-writer@2.0.0: resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + bytes@3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" @@ -844,6 +903,11 @@ chokidar@3.5.3: optionalDependencies: fsevents "~2.3.2" +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + ci-info@^3.2.0: version "3.8.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" @@ -913,6 +977,11 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + commander@^2.19.0: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" @@ -976,7 +1045,7 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@^4.2.0: +crypto-js@^4.1.1, crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== @@ -1027,6 +1096,18 @@ decimal.js@^10.4.3: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -1052,6 +1133,11 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +detect-libc@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -1159,6 +1245,16 @@ editorconfig@^0.15.0: semver "^5.6.0" sigmund "^1.0.1" +editorconfig@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" + integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "9.0.1" + semver "^7.5.3" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" @@ -1179,6 +1275,13 @@ encodeurl@~1.0.2: resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + entities@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz" @@ -1214,6 +1317,15 @@ escape-string-regexp@^2.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== +eslint-plugin-editorconfig@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-editorconfig/-/eslint-plugin-editorconfig-4.0.3.tgz#d0ab6179f16cc487eeacb4c9f54cf867b2841d6e" + integrity sha512-5YeDxm6mlv75DrTbRBK9Jw2ogqhjiz8ZCvv9bkuz/MXq0603q9FpQvQlamtas4bX1Gji4YcksY7dq7stPeGaLQ== + dependencies: + editorconfig "^1.0.2" + eslint "^8.40.0" + klona "^2.0.4" + eslint-scope@^7.2.2: version "7.2.2" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" @@ -1227,6 +1339,50 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint@^8.40.0: + version "8.57.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" + integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.0" + "@humanwhocodes/config-array" "^0.11.14" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + eslint@^8.56: version "8.56.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.56.0.tgz#4957ce8da409dc0809f99ab07a1b94832ab74b15" @@ -1314,6 +1470,11 @@ eventemitter3@^4.0.4: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" @@ -1434,6 +1595,11 @@ file-stream-rotator@^0.6.1: dependencies: moment "^2.29.1" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz" @@ -1532,6 +1698,11 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1582,6 +1753,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -1831,6 +2007,11 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^5.2.0, ignore@^5.2.4: version "5.2.4" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" @@ -1841,6 +2022,11 @@ immediate@~3.0.5: resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== +immutable@^5.0.0-beta.4: + version "5.0.0-beta.4" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.0-beta.4.tgz#6c12ac4ad8b16aeec4d064c9d2f4024bb270b7ca" + integrity sha512-sl9RE3lqd2LoQSESc8VV0k8qE9y57iT7dinq3Q+8mR2dqReHDZlgUrudzmFfZhDXBLXlNJMVWv3SG1YpQIokig== + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" @@ -1862,11 +2048,16 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + ip-address@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-7.1.0.tgz#4a9c699e75b51cbeb18b38de8ed216efa1a490c5" @@ -2141,6 +2332,11 @@ jsprim@^1.2.2: json-schema "0.4.0" verror "1.10.0" +klona@^2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22" + integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA== + kuler@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz" @@ -2309,6 +2505,22 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.12.1": + version "0.12.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.12.1.tgz#6b2bd4cb0a389c39e42f8a642c965c44417d2570" + integrity sha512-zQurJhrfyPYWXLQJMI53eQy6c2HjNFeK/EKgJm2frpll3FPPF0IVa++tQbaO68XFyuQp+T9Pav3aqsJ41KoreQ== + +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.12.1": + version "0.12.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.12.1.tgz#d85da915b4414cf88de026934f6b41ecba263d96" + integrity sha512-S63GLiS2EUWFY2OMYiKbfgwDSleSNQ7SnTEyf/kr8N5ng3QJMP4BwuLEj9e7W6kpIqtq6Nq0MSQfsw9iSgtUNg== + dependencies: + await-lock "^2.2.2" + crypto-js "^4.1.1" + glob-to-regexp "^0.4.1" + immutable "^5.0.0-beta.4" + ulidx "^2.1.0" + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -2354,6 +2566,11 @@ mime@1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz" @@ -2366,6 +2583,13 @@ minimatch@5.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + minimatch@9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" @@ -2385,6 +2609,16 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.3: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@^0.5.1: version "0.5.5" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz" @@ -2477,6 +2711,11 @@ nanoid@^3.3.6: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -2508,6 +2747,13 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +node-abi@^3.3.0: + version "3.56.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.56.0.tgz#ca807d5ff735ac6bbbd684ae3ff2debc1c2a40a7" + integrity sha512-fZjdhDOeRcaS+rcpve7XuwHBmktS1nS1gzgghwKUQQ8nTy2FdSDr6ZT8k6YhvlJeHmmQMYiT/IH9hfco5zeW2Q== + dependencies: + semver "^7.3.5" + node-downloader-helper@^2.1.5: version "2.1.9" resolved "https://registry.yarnpkg.com/node-downloader-helper/-/node-downloader-helper-2.1.9.tgz#a59ee7276b2bf708bbac2cc5872ad28fc7cd1b0e" @@ -2564,7 +2810,7 @@ on-headers@~1.0.2: resolved "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -once@^1.3.0: +once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -2803,6 +3049,24 @@ postgres@^3.3.1: resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.3.2.tgz#91f2e209e4a08ca7101eb7178734e4c0e4d23eb3" integrity sha512-NaPqFpUC6C7aCQkJXLvuO/3RKNKL4en8opY53YrcXK3//xXra6CZ2qX6290lxuQ1dW1LbRGYCmsawRlCxSBonQ== +prebuild-install@^7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.2.tgz#a5fd9986f5a6251fbc47e1e5c65de71e68c0a056" + integrity sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -2847,6 +3111,14 @@ psl@^1.1.33: resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" @@ -2924,11 +3196,30 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + react-is@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +readable-stream@^3.1.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz" @@ -3083,6 +3374,13 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== +semver@^7.3.5, semver@^7.5.3: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== + dependencies: + lru-cache "^6.0.0" + semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" @@ -3162,6 +3460,20 @@ sigmund@^1.0.1: resolved "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz" integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz" @@ -3279,6 +3591,11 @@ strip-json-comments@3.1.1, strip-json-comments@^3.1.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + supports-color@8.1.1: version "8.1.1" resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" @@ -3305,6 +3622,27 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tdigest@^0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz" @@ -3452,7 +3790,7 @@ typescript@^5.3.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== -ulidx@^2.2.1: +ulidx@^2.1.0, ulidx@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ulidx/-/ulidx-2.2.1.tgz#9dd6e48630f7a4ba612461523bc86d9101038996" integrity sha512-DU9F5t1tihdafuNyW3fIrXUFHHiHxmwuQSGVGIbSpqkc93IH4P0dU8nPhk0gOW7ARxaFu4+P/9cxVwn6PdnIaQ==