From edb10cd7c3d401433a395563021603f87bb2b61b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 1 Nov 2023 16:37:14 +0000 Subject: [PATCH 001/160] install matrix-protection-suite --- package.json | 1 + yarn.lock | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7ad08ba5..f39722f9 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", + "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/yarn.lock b/yarn.lock index 1431bc46..c143281b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -211,6 +211,11 @@ 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.21" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.31.21.tgz#d52d8e35f71e5651042aa0237e918e4b21fbbbf8" + integrity sha512-Wtq/K44EMkREaXytK+2c5DrygtYsH7ZxT0StQL8HMJz2BoOM7NZ/xfrUFBVuZxDrhJCoXf5Im282P2CCz5DHwQ== + "@types/body-parser@*": version "1.19.2" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" @@ -976,7 +981,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== @@ -1841,6 +1846,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" @@ -2309,6 +2319,16 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" +"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git": + version "0.4.0" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#172058d9129d703e1ef4e43770fc6302e4555d06" + dependencies: + "@sinclair/typebox" "^0.31.15" + 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" @@ -3452,7 +3472,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== From 2929266c376e6ded36c8add9ae5815c35847abe6 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 2 Nov 2023 14:27:21 +0000 Subject: [PATCH 002/160] bs --- package.json | 3 +- src/MatrixEmitter.ts | 66 ---- src/Mjolnir.ts | 12 +- src/ProtectedRoomsConfig.ts | 142 ------- src/ProtectedRoomsSet.ts | 511 -------------------------- src/RoomMembers.ts | 270 -------------- src/models/AccessControlUnit.ts | 325 ---------------- src/models/ListRule.ts | 333 ----------------- src/models/PolicyList.ts | 631 -------------------------------- src/models/PolicyListManager.ts | 194 ---------- src/models/ServerAcl.ts | 147 -------- yarn.lock | 16 +- 12 files changed, 11 insertions(+), 2639 deletions(-) delete mode 100644 src/MatrixEmitter.ts delete mode 100644 src/ProtectedRoomsConfig.ts delete mode 100644 src/ProtectedRoomsSet.ts delete mode 100644 src/RoomMembers.ts delete mode 100644 src/models/AccessControlUnit.ts delete mode 100644 src/models/ListRule.ts delete mode 100644 src/models/PolicyList.ts delete mode 100644 src/models/PolicyListManager.ts delete mode 100644 src/models/ServerAcl.ts diff --git a/package.json b/package.json index f39722f9..a2a36cfd 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git", + "matrix-protection-suite": "link:/home/user/experiments/matrix-protection-suite", + "matrix-protection-suite-for-matrix-bot-sdk": "link:/home/user/experiments/matrix-protection-suite-for-matrix-bot-sdk", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", 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 index 603f60b8..d89e3458 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -31,7 +31,6 @@ import { 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"; @@ -41,15 +40,11 @@ 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"; +import { ProtectedRoomsSet } from "matrix-protection-suite"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -66,15 +61,12 @@ 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; /** @@ -95,7 +87,6 @@ export class Mjolnir { public readonly reportManager: ReportManager; private readonly commandTable = findCommandTable("mjolnir"); - public readonly policyListManager: PolicyListManager; public readonly reactionHandler: MatrixReactionHandler; @@ -178,6 +169,7 @@ export class Mjolnir { public readonly matrixEmitter: MatrixEmitter, public readonly managementRoomId: string, public readonly config: IConfig, + private readonly protectedRoomsSet: ProtectedRoomsSet, // Combines the rules from ban lists so they can be served to a homeserver module or another consumer. public readonly ruleServer: RuleServer | null, ) { 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/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/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/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/yarn.lock b/yarn.lock index c143281b..221585e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2319,15 +2319,13 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git": - version "0.4.0" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#172058d9129d703e1ef4e43770fc6302e4555d06" - dependencies: - "@sinclair/typebox" "^0.31.15" - crypto-js "^4.1.1" - glob-to-regexp "^0.4.1" - immutable "^5.0.0-beta.4" - ulidx "^2.1.0" +"matrix-protection-suite-for-matrix-bot-sdk@link:/home/user/experiments/matrix-protection-suite-for-matrix-bot-sdk": + version "0.0.0" + uid "" + +"matrix-protection-suite@link:../../experiments/matrix-protection-suite": + version "0.0.0" + uid "" media-typer@0.3.0: version "0.3.0" From 67adb530bc35328c6aa40b104a75acfdebaa6a46 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 13 Nov 2023 18:04:22 +0000 Subject: [PATCH 003/160] Start cleaning up Mjolnir into Draupnir, starting with listeners. --- src/Draupnir.ts | 112 +++++++++++++++++++++++++++++++++ src/commands/CommandHandler.ts | 92 +++++++++++++++++++-------- 2 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 src/Draupnir.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts new file mode 100644 index 00000000..bee61503 --- /dev/null +++ b/src/Draupnir.ts @@ -0,0 +1,112 @@ +/** + * 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 { Logger, Ok, ProtectedRoomsSet, Task, TextMessageContent, Value } from "matrix-protection-suite"; +import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; +import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; +import { WebAPIs } from "./webapis/WebAPIs"; +import { ThrottlingQueue } from "./queues/ThrottlingQueue"; +import ManagementRoomOutput from "./ManagementRoomOutput"; +import { ReportPoller } from "./report/ReportPoller"; +import { ProtectionManager } from "./protections/ProtectionManager"; +import { ReportManager } from "./report/ReportManager"; +import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; +import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { IConfig } from "./config"; +import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; + +const log = new Logger('Draupnir'); + +export class Draupnir { + private displayName: string; + private localpart: string; + /** + * 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 webapis: WebAPIs; + private readonly commandTable = findCommandTable("mjolnir"); + 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 legacyProtectionManager: ProtectionManager; + /** + * Handle user reports from the homeserver. + */ + public readonly reportManager: ReportManager; + + public readonly reactionHandler: MatrixReactionHandler; + private constructor( + public readonly client: MatrixSendClient, + private readonly clientUserId: string, + public readonly matrixEmitter: SafeMatrixEmitter, + public readonly managementRoomId: string, + public readonly config: IConfig, + public readonly protectedRoomsSet: ProtectedRoomsSet + ) { + this.reactionHandler = new MatrixReactionHandler(this.managementRoomId, client, clientUserId); + this.setupMatrixEmitterListeners(); + } + + private setupMatrixEmitterListeners(): void { + this.matrixEmitter.on("room.message", (roomID, event) => { + if (roomID !== this.managementRoomId) { + return; + } + if (Value.Check(TextMessageContent, event.content)) { + const commandBeingRun = extractCommandFromMessageBody( + event.content.body, + { + prefix: COMMAND_PREFIX, + localpart: this.localpart, + displayName: this.displayName, + 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))); + } + }); + } +} diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f2212db3..4e180882 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -73,75 +73,113 @@ import "./Rules"; import "./WatchUnwatchCommand"; import "./Help"; import "./SetDisplayNameCommand"; +import { RoomMessage, StringRoomID } from "matrix-protection-suite"; 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); +export async function handleCommand( + roomID: StringRoomID, + event: RoomMessage, + normalisedCommand: string, + mjolnir: Mjolnir, + commandTable: CommandTable +) { + const parts = normalisedCommand.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); + const tokens = tokenize(normalisedCommand.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); try { if (parts[1] === 'joins') { - return await showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 2)); + return await showJoinsStatus(roomID, event, mjolnir, parts.slice(/* ["joins"] */ 2)); } else if (parts[1] === 'sync') { - return await execSyncCommand(roomId, event, mjolnir); + return await execSyncCommand(roomID, event, mjolnir); } else if (parts[1] === 'verify') { - return await execPermissionCheckCommand(roomId, event, mjolnir); + return await execPermissionCheckCommand(roomID, event, mjolnir); } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { - return await execCreateListCommand(roomId, event, mjolnir, parts); + return await execCreateListCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'redact' && parts.length > 1) { - return await execRedactCommand(roomId, event, mjolnir, parts); + return await execRedactCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'import' && parts.length > 2) { - return await execImportCommand(roomId, event, mjolnir, parts); + return await execImportCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'default' && parts.length > 2) { - return await execSetDefaultListCommand(roomId, event, mjolnir, parts); + return await execSetDefaultListCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'protections') { - return await execListProtections(roomId, event, mjolnir, parts); + return await execListProtections(roomID, event, mjolnir, parts); } else if (parts[1] === 'enable' && parts.length > 1) { - return await execEnableProtection(roomId, event, mjolnir, parts); + return await execEnableProtection(roomID, event, mjolnir, parts); } else if (parts[1] === 'disable' && parts.length > 1) { - return await execDisableProtection(roomId, event, mjolnir, parts); + 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)) + 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)) + 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)) + 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)) + return await execConfigGetProtection(roomID, event, mjolnir, parts.slice(3)) } else if (parts[1] === 'resolve' && parts.length > 2) { - return await execResolveCommand(roomId, event, mjolnir, parts); + return await execResolveCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'powerlevel' && parts.length > 3) { - return await execSetPowerLevelCommand(roomId, event, mjolnir, parts); + return await execSetPowerLevelCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'since') { - return await execSinceCommand(roomId, event, mjolnir, tokens); + return await execSinceCommand(roomID, event, mjolnir, tokens); } else if (parts[1] === 'kick' && parts.length > 2) { - return await execKickCommand(roomId, event, mjolnir, parts); + return await execKickCommand(roomID, event, mjolnir, parts); } else { - const readItems = readCommand(cmd).slice(1); // remove "!mjolnir" + const readItems = readCommand(normalisedCommand).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, + mjolnir, roomId: 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)); + await tickCrossRenderer.call(mjolnirContext, mjolnir.client, roomID, event, CommandResult.Err(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 mjolnir.client.sendMessage(roomID, reply); + } +} + +export function extractCommandFromMessageBody( + body: string, + { prefix, + localpart, + displayName, + userId, + additionalPrefixes, + allowNoPrefix + }: { + prefix: string, + localpart: string, + displayName: string, + userId: string, + additionalPrefixes: string[], + allowNoPrefix: boolean +}): string | undefined { + const plainPrefixes = [prefix, localpart, displayName, 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}`; } From c3bdf2c74a64b9de804add7bc9a2b5d4a0134ee3 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 13 Nov 2023 18:34:57 +0000 Subject: [PATCH 004/160] Unfinished listeners from Mjolnir --- src/Draupnir.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index bee61503..1b6047f3 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Logger, Ok, ProtectedRoomsSet, Task, TextMessageContent, Value } from "matrix-protection-suite"; +import { Logger, Ok, ProtectedRoomsSet, StringRoomID, Task, TextMessageContent, Value } from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; import { WebAPIs } from "./webapis/WebAPIs"; @@ -83,12 +83,35 @@ export class Draupnir { this.setupMatrixEmitterListeners(); } + private handleEvent(roomID: StringRoomID, event: RoomEvent): void { + + // 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); + } + } + private setupMatrixEmitterListeners(): void { this.matrixEmitter.on("room.message", (roomID, event) => { if (roomID !== this.managementRoomId) { return; } if (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.managementRoomId}.`); + 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, { From 2d3b4d1e0986d6bfdc259b3c3d351f15926d4404 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Nov 2023 16:47:19 +0000 Subject: [PATCH 005/160] Begin implementing matrix-protection-suite support. We could really do with some prettier for these newfiles. --- src/Draupnir.ts | 62 +++++---- src/DraupnirBotMode.ts | 195 ++++++++++++++++++++++++++++ src/StandardConsequenceProvider.tsx | 187 ++++++++++++++++++++++++++ 3 files changed, 422 insertions(+), 22 deletions(-) create mode 100644 src/DraupnirBotMode.ts create mode 100644 src/StandardConsequenceProvider.tsx diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 1b6047f3..b7c83f02 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Logger, Ok, ProtectedRoomsSet, StringRoomID, Task, TextMessageContent, Value } from "matrix-protection-suite"; +import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value } from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; import { WebAPIs } from "./webapis/WebAPIs"; @@ -35,9 +35,10 @@ import { ReportPoller } from "./report/ReportPoller"; import { ProtectionManager } from "./protections/ProtectionManager"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultStateTrackingMeta, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; +import { makeProtectedRoomsSet } from "./DraupnirBotMode"; const log = new Logger('Draupnir'); @@ -48,7 +49,7 @@ export class Draupnir { * 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(); + public unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); private webapis: WebAPIs; private readonly commandTable = findCommandTable("mjolnir"); @@ -73,40 +74,57 @@ export class Draupnir { public readonly reactionHandler: MatrixReactionHandler; private constructor( public readonly client: MatrixSendClient, - private readonly clientUserId: string, + private readonly clientUserID: StringUserID, public readonly matrixEmitter: SafeMatrixEmitter, - public readonly managementRoomId: string, + public readonly managementRoom: MatrixRoomID, public readonly config: IConfig, public readonly protectedRoomsSet: ProtectedRoomsSet ) { - this.reactionHandler = new MatrixReactionHandler(this.managementRoomId, client, clientUserId); + this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIdOrAlias(), client, clientUserID); this.setupMatrixEmitterListeners(); } - private handleEvent(roomID: StringRoomID, event: RoomEvent): void { - - // 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) - } - } + public static async makeDraupnirBot( + client: MatrixSendClient, + matrixEmitter: SafeMatrixEmitter, + clientUserID: StringUserID, + managementRoom: MatrixRoomID, + config: IConfig + ): Promise { + const managerManager = new ManagerManagerForMatrixEmitter( + matrixEmitter, + DefaultStateTrackingMeta, + DefaultEventDecoder, + client + ); + const protectedRoomsSet = await makeProtectedRoomsSet( + managementRoom, + managerManager, + client, + clientUserID + ) + return new Draupnir( + client, + clientUserID, + matrixEmitter, + managementRoom, + config, + protectedRoomsSet + ) + } - if (event.sender !== this.clientUserId) { - this.protectedRoomsTracker.handleEvent(roomId, event); - } + private handleEvent(roomID: StringRoomID, event: RoomEvent): void { + this.protectedRoomsSet.handleTimelineEvent(roomID, event); } private setupMatrixEmitterListeners(): void { this.matrixEmitter.on("room.message", (roomID, event) => { - if (roomID !== this.managementRoomId) { + if (roomID !== this.managementRoom.toRoomIdOrAlias()) { return; } if (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.managementRoomId}.`); + 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))); @@ -118,7 +136,7 @@ export class Draupnir { prefix: COMMAND_PREFIX, localpart: this.localpart, displayName: this.displayName, - userId: this.clientUserId, + userId: this.clientUserID, additionalPrefixes: this.config.commands.additionalPrefixes, allowNoPrefix: this.config.commands.allowNoPrefix, } diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts new file mode 100644 index 00000000..c8b5c3f8 --- /dev/null +++ b/src/DraupnirBotMode.ts @@ -0,0 +1,195 @@ +/** + * 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 { + MjolnirPolicyRoomsConfig, + PolicyListConfig, + PolicyRoomManager, + ProtectedRoomsConfig, + ResolveRoom, + MjolnirProtectedRoomsConfig, + StandardProtectedRoomsSet, + isError, + RoomStateManager, + MjolnirProtectionsConfig, + MjolnirEnabledProtectionsEvent, + MjolnirEnabledProtectionsEventType, + MatrixRoomID, + MjolnirProtectionSettingsEventType, + StandardSetMembership, + RoomMembershipManager, + SetMembership, + StringUserID, + ProtectedRoomsSet, + MatrixRoomReference, + isStringUserID, +} from "matrix-protection-suite"; +import { + BotSDKMatrixAccountData, + BotSDKMatrixStateData, + BotSDKMjolnirProtectedRoomsStore, + BotSDKMjolnirWatchedPolicyRoomsStore, + ManagerManager, + MatrixSendClient, + SafeMatrixEmitter +} from 'matrix-protection-suite-for-matrix-bot-sdk'; +import { makeStandardConsequenceProvider, renderProtectionFailedToStart } from "./StandardConsequenceProvider"; +import { IConfig } from "./config"; +import { Draupnir } from "./Draupnir"; + +/** + * 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. + */ + +async function makePolicyListConfig( + client: MatrixSendClient, + policyRoomManager: PolicyRoomManager +): Promise { + const result = await MjolnirPolicyRoomsConfig.createFromStore( + new BotSDKMjolnirWatchedPolicyRoomsStore( + client + ), + policyRoomManager, + client as unknown as { resolveRoom: ResolveRoom } + ); + if (isError(result)) { + throw result.error; + } + return result.ok; +} + +async function makeProtectedRoomsConfig( + client: MatrixSendClient, +): Promise { + const result = await MjolnirProtectedRoomsConfig.createFromStore( + new BotSDKMjolnirProtectedRoomsStore( + client + ) + ); + if (isError(result)) { + throw result.error; + } + return result.ok; +} + +async function makeSetMembership( + roomMembershipManager: RoomMembershipManager, + protectedRoomsConfig: ProtectedRoomsConfig +): Promise { + const membershipSet = await StandardSetMembership.create( + roomMembershipManager, + protectedRoomsConfig + ); + if (isError(membershipSet)) { + throw membershipSet.error; + } + return membershipSet.ok; +} + +async function makeProtectionConfig( + client: MatrixSendClient, + roomStateManager: RoomStateManager, + managementRoom: MatrixRoomID +) { + const result = await roomStateManager.getRoomStateRevisionIssuer( + managementRoom + ); + if (isError(result)) { + throw result.error; + } + return new MjolnirProtectionsConfig( + new BotSDKMatrixAccountData( + MjolnirEnabledProtectionsEventType, + MjolnirEnabledProtectionsEvent, + client + ), + new BotSDKMatrixStateData( + MjolnirProtectionSettingsEventType, + result.ok, + client + ) + ) +} + +export async function makeProtectedRoomsSet( + managementRoom: MatrixRoomID, + managerManager: ManagerManager, + client: MatrixSendClient, + userID: StringUserID +): Promise { + const protectedRoomsConfig = await makeProtectedRoomsConfig(client) + const membershipSet = await makeSetMembership( + managerManager.roomMembershipManager, + protectedRoomsConfig + ); + const protectedRoomsSet = new StandardProtectedRoomsSet( + await makePolicyListConfig(client, managerManager.policyRoomManager), + protectedRoomsConfig, + await makeProtectionConfig( + client, + managerManager.roomStateManager, + managementRoom + ), + membershipSet, + userID, + ); + // FIXME: this should be in the factory method of StandardProtectedRoomsSet. + const loadResult = await protectedRoomsSet.protections.loadProtections( + makeStandardConsequenceProvider(client, managementRoom.toRoomIdOrAlias()), + protectedRoomsSet, + (error, description) => renderProtectionFailedToStart( + client, managementRoom.toRoomIdOrAlias(), error, description + ) + ); + if (isError(loadResult)) { + throw loadResult.error; + } + return protectedRoomsSet; +} + +export async function makeDraupnirBotModeFromConfig( + client: MatrixSendClient, + matrixEmitter: SafeMatrixEmitter, + config: IConfig +): Promise { + const clientUserId = await client.getUserId(); + if (!isStringUserID(clientUserId)) { + throw new TypeError(`${clientUserId} is not a valid mxid`); + } + const managementRoom = await MatrixRoomReference.fromRoomIdOrAlias(config.managementRoom).resolve(client as unknown as { resolveRoom: ResolveRoom }); + return await Draupnir.makeDraupnirBot( + client, + matrixEmitter, + clientUserId, + managementRoom, + config + ); +} diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx new file mode 100644 index 00000000..b8f22e9a --- /dev/null +++ b/src/StandardConsequenceProvider.tsx @@ -0,0 +1,187 @@ +/** + * 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 { ActionError, ActionException, ActionExceptionKind, ActionResult, ConsequenceProvider, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership } 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"; +import { DocumentNode } from "./commands/interface-manager/DeadDocument"; + +interface ProviderContext { + client: MatrixSendClient; + managementRoomID: StringRoomID; +} + +async function renderConsequenceForEvent(client: MatrixSendClient, managementRoomID: StringRoomID, protection: ProtectionDescriptionInfo, roomID: StringRoomID, eventID: StringEventID, reason: string): Promise> { + await renderMatrixAndSend( + + Protection {protection.name}: Redacting event {Permalinks.forEvent(roomID, eventID)} for {reason}. + , + managementRoomID, + undefined, + client + ) + return Ok(undefined); +} + +const consequenceForEvent: ConsequenceProvider['consequenceForEvent'] = async function( + this: ProviderContext, protection, roomID, eventID, reason +): Promise> { + Task(renderConsequenceForEvent(this.client, this.managementRoomID, protection, roomID, eventID, reason)) + return this.client.redactEvent(roomID, eventID, reason).then( + (_) => Ok(undefined), + (exception) => ActionException.Result( + `Unable to redact the event ${eventID} when enforcing a consequence`, + { exception, exceptionKind: ActionExceptionKind.Unknown } + ) + ) +} + +async function renderConsequenceForUserInRoom(client: MatrixSendClient, managementRoomID: StringRoomID, protection: ProtectionDescriptionInfo, roomID: StringRoomID, userID: StringUserID, reason: string): Promise> { + await renderMatrixAndSend( + + Protection: {protection.name}: Banning user {userID} in {Permalinks.forRoom(roomID)} for {reason}. + , + managementRoomID, + undefined, + client + ); + return Ok(undefined); +} + +function banUser(client: MatrixSendClient, protection: ProtectionDescriptionInfo, roomID: StringRoomID, userID: StringUserID, reason: string): Promise> { + return client.banUser( + userID, roomID, reason + ).then( + (_) => Ok(undefined), + (exception) => ActionException.Result( + `Unable to ban the user ${userID} in ${roomID} when enforcing a consequence`, + { exception, exceptionKind: ActionExceptionKind.Unknown } + ) + ) +} + +const consequenceForUserInRoom: ConsequenceProvider['consequenceForUserInRoom'] = async function( + this: ProviderContext, protection, roomID, userID, reason +): Promise> { + Task(renderConsequenceForUserInRoom(this.client, this.managementRoomID, protection, roomID, userID, reason)); + return banUser(this.client, protection, roomID, userID, reason); +} + +function renderSetMembershipBans(title: DocumentNode, map: SetMemberBanResultMap): DocumentNode { + return + {title}, + { + [...map.entries()].map(([userID, roomResults]) => { + return
    + {userID} will be banned from {roomResults.size} rooms. +
      {[...roomResults.entries()].map((roomID) => { + return
    • {roomID}
    • + })}
    +
    + }) + } +
    +} + +const consequenceForUsersInRevision: ConsequenceProvider['consequenceForUsersInRevision'] = async function( + this: ProviderContext, description, setMembership, revision +) { + const results = await applyPolicyRevisionToSetMembership( + description, + revision, + setMembership, + (_description, roomID, userID, reason) => banUser(this.client, description, roomID, userID, reason) + ); + Task(renderMatrixAndSend( + renderSetMembershipBans( + Banning {results.size} users in protected rooms., + results + ), + this.managementRoomID, + undefined, + this.client + ).then((_) => Ok(undefined))) + return Ok(undefined); +} + +const consequenceForServerACL: ConsequenceProvider['consequenceForServerACL'] = async function( + this: ProviderContext, aclContent +): Promise> { + // nothing to do + return Ok(undefined) +} + +const consequenceForServerACLInRoom: ConsequenceProvider['consequenceForServerACLInRoom'] = async function( + this: ProviderContext, _protection, roomID, aclContent +): Promise> { + return this.client.sendStateEvent(roomID, 'm.room.server_acl', '', aclContent).then( + (_) => Ok(undefined), + (exception) => ActionException.Result( + `Unable to set the server ACL in the room ${roomID}`, + { exception, exceptionKind: ActionExceptionKind.Unknown } + ) + ) +} + +const consequenceForServerInRoom: ConsequenceProvider['consequenceForServerInRoom'] = async function( +) { + return Ok(undefined); +} + +export function makeStandardConsequenceProvider( + client: MatrixSendClient, + managementRoomID: StringRoomID +): ConsequenceProvider { + return { + consequenceForEvent, + consequenceForServerACL, + consequenceForUserInRoom, + consequenceForServerACLInRoom, + consequenceForServerInRoom, + consequenceForUsersInRevision, + client, + managementRoomID + } as unknown as ConsequenceProvider; +} + +export async function renderProtectionFailedToStart( + client: MatrixSendClient, + managementRoomID: StringRoomID, + error: ActionError, + protectionDescription?: ProtectionDescription +): Promise { + await renderMatrixAndSend( + + A protection {protectionDescription?.name} failed to start for the following reason: + {error.message} + , + managementRoomID, + undefined, + client + ) +} From 6d28ac81b0ae1a8097f6c343ed5f1d0e700813e2 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Nov 2023 17:00:48 +0000 Subject: [PATCH 006/160] Remove the RuleServer. It was never used and we don't test it. We can't really support it. --- config/harness.yaml | 7 +- src/Mjolnir.ts | 8 +- src/config.ts | 6 - src/models/RuleServer.ts | 331 ---------------------- src/webapis/WebAPIs.ts | 35 +-- test/integration/policyConsumptionTest.ts | 152 ---------- 6 files changed, 6 insertions(+), 533 deletions(-) delete mode 100644 src/models/RuleServer.ts delete mode 100644 test/integration/policyConsumptionTest.ts 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/src/Mjolnir.ts b/src/Mjolnir.ts index d89e3458..a2e12358 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -37,7 +37,6 @@ 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 ManagementRoomOutput from "./ManagementRoomOutput"; @@ -156,8 +155,7 @@ export class Mjolnir { 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); + const mjolnir = new Mjolnir(client, await client.getUserId(), matrixEmitter, managementRoomId, config); await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); return mjolnir; @@ -170,8 +168,6 @@ export class Mjolnir { public readonly managementRoomId: string, public readonly config: IConfig, private readonly protectedRoomsSet: ProtectedRoomsSet, - // 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); @@ -247,7 +243,7 @@ export class Mjolnir { // Setup Web APIs console.log("Creating Web APIs"); this.reportManager = new ReportManager(this); - this.webapis = new WebAPIs(this.reportManager, this.config, this.ruleServer); + this.webapis = new WebAPIs(this.reportManager, this.config); if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); } diff --git a/src/config.ts b/src/config.ts index cc139750..8bf80f58 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,9 +125,6 @@ export interface IConfig { abuseReporting: { enabled: boolean; } - ruleServer?: { - enabled: boolean; - } } // Experimental usage of the matrix-bot-sdk rust crypto. // This can not be used with Pantalaimon. @@ -209,9 +206,6 @@ const defaultConfig: IConfig = { abuseReporting: { enabled: false, }, - ruleServer: { - enabled: false, - }, }, experimentalRustCrypto: false, 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/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 76492fae..731f71fe 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -27,8 +27,7 @@ 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"; @@ -38,13 +37,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()); } @@ -79,22 +78,6 @@ export class WebAPIs { }); 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() { @@ -205,16 +188,4 @@ export class WebAPIs { 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/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.'); - }) -}); From b51566000a269e97974a3d60ef295e2671969f1e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Nov 2023 18:43:13 +0000 Subject: [PATCH 007/160] Integrate report poller into new Draupnir class. --- src/Draupnir.ts | 22 +++++++++++++++++++++- src/report/ReportPoller.ts | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index b7c83f02..985da2c5 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -42,6 +42,12 @@ import { makeProtectedRoomsSet } from "./DraupnirBotMode"; 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 { private displayName: string; private localpart: string; @@ -51,7 +57,6 @@ export class Draupnir { */ public unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); - private webapis: WebAPIs; private readonly commandTable = findCommandTable("mjolnir"); public taskQueue: ThrottlingQueue; /** @@ -68,6 +73,7 @@ export class Draupnir { public readonly legacyProtectionManager: ProtectionManager; /** * Handle user reports from the homeserver. + * FIXME: ReportManager should be a protection. */ public readonly reportManager: ReportManager; @@ -82,6 +88,10 @@ export class Draupnir { ) { this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIdOrAlias(), client, clientUserID); this.setupMatrixEmitterListeners(); + this.reportManager = new ReportManager(this); + if (config.pollReports) { + this.reportPoller = new ReportPoller(this, this.reportManager); + } } public static async makeDraupnirBot( @@ -150,4 +160,14 @@ export class Draupnir { } }); } + + public async start(): Promise { + if (this.reportPoller) { + const reportPollSetting = await ReportPoller.getReportPollSetting( + this.client, + this.managementRoomOutput + ); + this.reportPoller.start(reportPollSetting); + } + } } diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index e298cabe..abcd7f85 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -25,12 +25,16 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir"; import { ReportManager } from './ReportManager'; import { LogLevel } from "matrix-bot-sdk"; +import ManagementRoomOutput from "../ManagementRoomOutput"; class InvalidStateError extends Error { } +export type ReportPollSetting = { from: number }; + /** * A class to poll synapse's report endpoint, so we can act on new reports * @@ -141,7 +145,20 @@ export class ReportPoller { 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(); From 977c4a39de9a2ea5314770205dd31671477aa516 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 23 Nov 2023 14:21:55 +0000 Subject: [PATCH 008/160] Update ReportManager to use Draupnir (mps). --- src/Draupnir.ts | 12 +-- src/report/ReportManager.ts | 171 ++++++++++++++++++++---------------- 2 files changed, 102 insertions(+), 81 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 985da2c5..e73514df 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,10 +25,9 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value } from "matrix-protection-suite"; +import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, userLocalpart } from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; -import { WebAPIs } from "./webapis/WebAPIs"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; @@ -49,8 +48,7 @@ const log = new Logger('Draupnir'); // And giving it to the class was a dumb easy way of doing that. export class Draupnir { - private displayName: string; - private localpart: string; + private readonly displayName: string; /** * This is for users who are not listed on a watchlist, * but have been flagged by the automatic spam detection as suispicous @@ -63,6 +61,7 @@ export class Draupnir { * 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 */ @@ -80,12 +79,13 @@ export class Draupnir { public readonly reactionHandler: MatrixReactionHandler; private constructor( public readonly client: MatrixSendClient, - private readonly clientUserID: StringUserID, + public readonly clientUserID: StringUserID, public readonly matrixEmitter: SafeMatrixEmitter, public readonly managementRoom: MatrixRoomID, public readonly config: IConfig, public readonly protectedRoomsSet: ProtectedRoomsSet ) { + this.managementRoomID = this.managementRoom.toRoomIdOrAlias(); this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIdOrAlias(), client, clientUserID); this.setupMatrixEmitterListeners(); this.reportManager = new ReportManager(this); @@ -144,7 +144,7 @@ export class Draupnir { event.content.body, { prefix: COMMAND_PREFIX, - localpart: this.localpart, + localpart: userLocalpart(this.clientUserID), displayName: this.displayName, userId: this.clientUserID, additionalPrefixes: this.config.commands.additionalPrefixes, diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index 35ac536d..ab55fd4d 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -31,7 +31,8 @@ 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, 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]`. @@ -87,14 +88,14 @@ enum Kind { */ export class ReportManager extends EventEmitter { private displayManager: DisplayManager; - constructor(public mjolnir: Mjolnir) { + constructor(public draupnir: Draupnir) { super(); // Configure bot interactions. - mjolnir.matrixEmitter.on("room.event", async (roomId, event) => { + draupnir.matrixEmitter.on("room.event", async (roomID, event) => { try { switch (event["type"]) { case "m.reaction": { - await this.handleReaction({ roomId, event }); + await this.handleReaction({ roomID, event }); break; } } @@ -112,51 +113,56 @@ 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.emit("report.new", { roomID: roomID, reporterId: reporterId, 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 +194,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 +218,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 +259,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 +282,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 +303,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 +326,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 +336,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 +351,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 +359,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 +460,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 +479,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 +499,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 +526,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 +537,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 +552,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 +564,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 +578,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 +590,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 +604,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 +616,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 +637,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(`
  • ${action.emoji} ${await action.help(manager, report)}
  • `); } } - if (!await ACTIONS.get("ban-accused")!.canExecute(manager, report, moderationRoomId)) { + if (!await ACTIONS.get("ban-accused")!.canExecute(manager, report, moderationroomID)) { list.push(`
  • Some actions were disabled because Mjölnir is not moderator in room ${htmlEscape(report.room_alias_or_id)}
  • `) } let body = `
      ${list.join("\n")}
    `; @@ -639,13 +660,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 +677,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 +713,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 +753,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 +897,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 +908,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, From e5e771aaea55d7ad5b609ec3a3258bc3a816f602 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 23 Nov 2023 14:43:17 +0000 Subject: [PATCH 009/160] Update ReportPoller to use Draupnir (mps). --- src/Mjolnir.ts | 6 ------ src/report/ReportPoller.ts | 35 +++++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index a2e12358..4ad94e4e 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -50,12 +50,6 @@ 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; diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index abcd7f85..71de50ca 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -26,10 +26,17 @@ limitations under the License. */ import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir"; 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 { isStringRoomID } 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 { } @@ -38,7 +45,7 @@ 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 { @@ -53,7 +60,7 @@ export class ReportPoller { private timeout: ReturnType | null = null; constructor( - private mjolnir: Mjolnir, + private draupnir: Draupnir, private manager: ReportManager, ) { } @@ -80,7 +87,7 @@ export class ReportPoller { next_token: number | undefined } | undefined; try { - response_ = await this.mjolnir.client.doRequest( + response_ = await this.draupnir.client.doRequest( "GET", "/_synapse/admin/v1/event_reports", { @@ -90,24 +97,28 @@ 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)) { + if (!isStringRoomID(report.room_id)) { + LogService.error(`ReportPoller`, `Malformed room ID, skipping report ${report.room_id}`); + continue; + } + if (!this.draupnir.protectedRoomsSet.isProtectedRoom(report.room_id)) { continue; } let event: any; // `any` because `handleServerAbuseReport` uses `any` try { - event = (await this.mjolnir.client.doRequest( + event = (await this.draupnir.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}`); + this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); continue; } @@ -127,9 +138,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}`); } } } @@ -140,7 +151,7 @@ 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(); From 7a5aafde7d7e9df3bcbcb2a74f35e3dfddd75f68 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 24 Nov 2023 16:35:33 +0000 Subject: [PATCH 010/160] Update ReportPoller to use Draupnir (mps). --- src/report/ReportPoller.ts | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 71de50ca..b81994c3 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -30,7 +30,7 @@ import { ReportManager } from './ReportManager'; import { LogLevel, LogService } from "matrix-bot-sdk"; import ManagementRoomOutput from "../ManagementRoomOutput"; import { Draupnir } from "../Draupnir"; -import { isStringRoomID } from "matrix-protection-suite"; +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 @@ -83,7 +83,7 @@ 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 { @@ -102,31 +102,37 @@ export class ReportPoller { } const response = response_!; - for (let report of response.event_reports) { - if (!isStringRoomID(report.room_id)) { - LogService.error(`ReportPoller`, `Malformed room ID, skipping report ${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; } + const report = reportResult.ok; if (!this.draupnir.protectedRoomsSet.isProtectedRoom(report.room_id)) { continue; } - - let event: any; // `any` because `handleServerAbuseReport` uses `any` - try { - event = (await this.draupnir.client.doRequest( - "GET", - `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` - )).event; - } catch (ex) { - this.draupnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); + // 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, }); } From ecd6dbef71ba93b776401a1c3f685711ba27d338 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 24 Nov 2023 19:06:38 +0000 Subject: [PATCH 011/160] Update Draupnir for new protection context in mps. --- src/Draupnir.ts | 24 ++++++++++++++++-------- src/DraupnirBotMode.ts | 11 ----------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index e73514df..5e0145a6 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,19 +25,19 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, userLocalpart } from "matrix-protection-suite"; +import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, 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 { ProtectionManager } from "./protections/ProtectionManager"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; import { DefaultStateTrackingMeta, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; import { makeProtectedRoomsSet } from "./DraupnirBotMode"; +import { makeStandardConsequenceProvider, renderProtectionFailedToStart } from "./StandardConsequenceProvider"; const log = new Logger('Draupnir'); @@ -66,10 +66,6 @@ export class Draupnir { * 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 legacyProtectionManager: ProtectionManager; /** * Handle user reports from the homeserver. * FIXME: ReportManager should be a protection. @@ -113,14 +109,26 @@ export class Draupnir { client, clientUserID ) - return new Draupnir( + const draupnir = new Draupnir( client, clientUserID, matrixEmitter, managementRoom, config, protectedRoomsSet - ) + ); + const loadResult = await protectedRoomsSet.protections.loadProtections( + makeStandardConsequenceProvider(client, draupnir.managementRoomID), + protectedRoomsSet, + draupnir, + (error, description) => renderProtectionFailedToStart( + client, managementRoom.toRoomIdOrAlias(), error, description + ) + ); + if (isError(loadResult)) { + throw loadResult.error; + } + return draupnir; } private handleEvent(roomID: StringRoomID, event: RoomEvent): void { diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index c8b5c3f8..1d462e7e 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -161,17 +161,6 @@ export async function makeProtectedRoomsSet( membershipSet, userID, ); - // FIXME: this should be in the factory method of StandardProtectedRoomsSet. - const loadResult = await protectedRoomsSet.protections.loadProtections( - makeStandardConsequenceProvider(client, managementRoom.toRoomIdOrAlias()), - protectedRoomsSet, - (error, description) => renderProtectionFailedToStart( - client, managementRoom.toRoomIdOrAlias(), error, description - ) - ); - if (isError(loadResult)) { - throw loadResult.error; - } return protectedRoomsSet; } From 89f2b5258056fbe13d02de276f44e010c81781cc Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 27 Nov 2023 14:18:34 +0000 Subject: [PATCH 012/160] Add consequence for unbanning user (MPS). --- src/StandardConsequenceProvider.tsx | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx index b8f22e9a..18ef0f89 100644 --- a/src/StandardConsequenceProvider.tsx +++ b/src/StandardConsequenceProvider.tsx @@ -25,11 +25,12 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionException, ActionExceptionKind, ActionResult, ConsequenceProvider, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership } from "matrix-protection-suite"; +import { ActionError, ActionException, ActionExceptionKind, ActionResult, ConsequenceProvider, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, RoomUpdateError, RoomUpdateException, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership, isError } 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"; import { DocumentNode } from "./commands/interface-manager/DeadDocument"; +import { printActionResult } from "./models/RoomUpdateError"; interface ProviderContext { client: MatrixSendClient; @@ -153,6 +154,33 @@ const consequenceForServerInRoom: ConsequenceProvider['consequenceForServerInRoo return Ok(undefined); } +const unbanUserFromRoomsInSet: ConsequenceProvider['unbanUserFromRoomsInSet'] = async function( + this: ProviderContext, _protection, userID, protectedRoomsSet +): Promise> { + const errors: RoomUpdateError[] = []; + for (const room of protectedRoomsSet.protectedRoomsConfig.allRooms) { + const unbanResult = await this.client.unbanUser(userID, room.toRoomIDOrAlias()) + .then( + (_) => Ok(undefined), + (exception) => RoomUpdateException.Result( + `Unable to ban the user ${userID} from the room ${room.toPermalink()}`, { + exception, + exceptionKind: ActionExceptionKind.Unknown, + room, + } + ) + ); + if (isError(unbanResult)) { + errors.push(unbanResult.error); + } + } + Task(printActionResult(this.client, this.managementRoomID, errors, { + title: `There were errors unbanning ${userID} from protected rooms.`, + noErrorsText: `Done unbanning ${userID} from protected rooms - no errors.` + })); + return Ok(undefined); +} + export function makeStandardConsequenceProvider( client: MatrixSendClient, managementRoomID: StringRoomID @@ -164,6 +192,7 @@ export function makeStandardConsequenceProvider( consequenceForServerACLInRoom, consequenceForServerInRoom, consequenceForUsersInRevision, + unbanUserFromRoomsInSet, client, managementRoomID } as unknown as ConsequenceProvider; From e1d009bd690ad080074fbc0ff8c03ac33e9fae2c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 27 Nov 2023 14:19:09 +0000 Subject: [PATCH 013/160] Update RoomUpdateError to refer to MPS. --- src/models/RoomUpdateError.tsx | 71 ++++++++++------------------------ 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/src/models/RoomUpdateError.tsx b/src/models/RoomUpdateError.tsx index e760d281..1b14ede7 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
  • - {error.roomId} - {error.message} + {error.room.toRoomIDOrAlias} - {error.message}
  • } @@ -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 + } + ) ) } From 388eaf6bb135c21026bb5577a5b46c94bdba8cc0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 27 Nov 2023 14:20:48 +0000 Subject: [PATCH 014/160] Update BanPropagationProtection to be an MPS protection. --- src/Draupnir.ts | 25 +- src/commands/Rules.tsx | 7 +- .../interface-manager/MatrixRoomReference.ts | 118 ------- src/protections/BanPropagation.tsx | 301 ++++++++++-------- 4 files changed, 184 insertions(+), 267 deletions(-) delete mode 100644 src/commands/interface-manager/MatrixRoomReference.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 5e0145a6..9036caa8 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, userLocalpart } from "matrix-protection-suite"; +import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, serverName, userLocalpart } from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; @@ -33,7 +33,7 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { DefaultStateTrackingMeta, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; import { makeProtectedRoomsSet } from "./DraupnirBotMode"; @@ -79,10 +79,11 @@ export class Draupnir { public readonly matrixEmitter: SafeMatrixEmitter, public readonly managementRoom: MatrixRoomID, public readonly config: IConfig, - public readonly protectedRoomsSet: ProtectedRoomsSet + public readonly protectedRoomsSet: ProtectedRoomsSet, + public readonly managerManager: ManagerManager ) { - this.managementRoomID = this.managementRoom.toRoomIdOrAlias(); - this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIdOrAlias(), client, clientUserID); + this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); + this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID); this.setupMatrixEmitterListeners(); this.reportManager = new ReportManager(this); if (config.pollReports) { @@ -115,14 +116,15 @@ export class Draupnir { matrixEmitter, managementRoom, config, - protectedRoomsSet + protectedRoomsSet, + managerManager ); const loadResult = await protectedRoomsSet.protections.loadProtections( makeStandardConsequenceProvider(client, draupnir.managementRoomID), protectedRoomsSet, draupnir, (error, description) => renderProtectionFailedToStart( - client, managementRoom.toRoomIdOrAlias(), error, description + client, managementRoom.toRoomIDOrAlias(), error, description ) ); if (isError(loadResult)) { @@ -137,7 +139,7 @@ export class Draupnir { private setupMatrixEmitterListeners(): void { this.matrixEmitter.on("room.message", (roomID, event) => { - if (roomID !== this.managementRoom.toRoomIdOrAlias()) { + if (roomID !== this.managementRoom.toRoomIDOrAlias()) { return; } if (Value.Check(TextMessageContent, event.content)) { @@ -178,4 +180,11 @@ export class Draupnir { this.reportPoller.start(reportPollSetting); } } + + public createRoomReference(roomID: StringRoomID): MatrixRoomID { + return new MatrixRoomID( + roomID, + [serverName(this.clientUserID)] + ); + } } diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index 5116d7fe..f1e3cb3d 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -37,6 +37,7 @@ import { CommandResult } from "./interface-manager/Validation"; import { UserID } from "matrix-bot-sdk"; import { MatrixSendClient } from "../MatrixEmitter"; import { ListRule } from "../models/ListRule"; +import { PolicyRule } from "matrix-protection-suite"; async function renderListMatches( this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: any, result: CommandResult @@ -67,8 +68,7 @@ export function renderListRules(list: ListMatches) { }; return - {list.roomId} - {list.shortcode ? `(shortcode: ${list.shortcode})` : ''}:
    + {list.roomId}
      {list.matches.length === 0 ?
    • No rules
    • @@ -78,10 +78,9 @@ export function renderListRules(list: ListMatches) { } interface ListMatches { - shortcode: string, roomRef: string, roomId: string, - matches: ListRule[] + matches: PolicyRule[] } defineInterfaceCommand({ 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/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 432387a9..0a560a5e 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -28,26 +28,24 @@ 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 { printActionResult } from "../models/RoomUpdateError"; +import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, Protection, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; + +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 rooms.reduce((map, room, index) => (map.set(`${index + 1}.`, room.toPermalink()), map), new Map()) } // would be nice to be able to use presentation types here idk. @@ -64,41 +62,43 @@ 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.managerManager.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}
      , - 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, + draupnir: Draupnir, event: any, roomId: string, - rulesMatchingUser: Map -): Promise { + rulesMatchingUser: Map +): 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( @@ -110,19 +110,18 @@ async function promptUnbanPropagation( { [...rulesMatchingUser.entries()] .map(([list, rules]) =>
    • {renderListRules({ - shortcode: list.listShortcode, - roomRef: list.roomRef, - roomId: list.roomId, + roomRef: draupnir.createRoomReference(list).toPermalink(), + roomId: list, matches: rules })}
    • ) }
    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, { @@ -131,124 +130,152 @@ async function promptUnbanPropagation( } ) )).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 class BanPropagationProtection extends AbstractProtection implements Protection { -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); + constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + ) { + super(description, consequenceProvider, protectedRoomsSet, [], []); + // 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> { + const bans = changes.filter(change => change.membershipChangeType === MembershipChangeType.Banned && 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) { + this.handleUnban(unban); } + 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 handleUnban(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 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; + } + Task(promptUnbanPropagation( + this.draupnir, + change, + change.roomID, + rulesMatchingUser.reduce((map, rule) => addRule(map, rule), new Map()) + )); } - 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.managerManager.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.managerManager.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.managerManager.policyRoomManager.getPolicyRoomEditor(editablePolicyRoom); + if (isError(editorResult)) { + errors.push(RoomActionError.fromActionError(editablePolicyRoom, editorResult.error)); + continue; + } + const editor = editorResult.ok; + const unbanResult = await editor.unbanEntity(PolicyRuleType.User, context.target); + if (isError(unbanResult)) { + errors.push(RoomActionError.fromActionError(editablePolicyRoom, unbanResult.error)); + continue; + } + } + if (errors.length > 0) { + Task(printActionResult( + this.draupnir.client, + this.draupnir.managementRoomID, + errors, + { title: `There were errors unbanning ${context.target} from all lists.`} + )); + } else { + this.consequenceProvider.unbanUserFromRoomsInSet( + this.description, + context.target as StringUserID, + this.protectedRoomsSet + ) } - // 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); + } 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.", + factory: (decription, consequenceProvider, protectedRoomsSet, draupnir, _settings) => + Ok( + new BanPropagationProtection( + decription, + consequenceProvider, + protectedRoomsSet, + draupnir + ) + ), + }); From 928d9e3fc5522c467c4ad32dfafeb41ead0bccb0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 27 Nov 2023 14:41:22 +0000 Subject: [PATCH 015/160] Delete InterfaceManager components that can be sourced from MPS. --- .../interface-manager/CommandException.ts | 50 ------- src/commands/interface-manager/Permalinks.ts | 133 ------------------ src/commands/interface-manager/Validation.ts | 104 -------------- 3 files changed, 287 deletions(-) delete mode 100644 src/commands/interface-manager/CommandException.ts delete mode 100644 src/commands/interface-manager/Permalinks.ts delete mode 100644 src/commands/interface-manager/Validation.ts 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/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/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)); - } -} From 98f3471ea0f7471293c20b37f3ae9fc18e5180b7 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 27 Nov 2023 14:41:45 +0000 Subject: [PATCH 016/160] Update CommandReader to use MPS. --- .../interface-manager/CommandReader.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index 7cd12b0a..ac0014bb 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 { MatrixRoomReference, Permalinks, UserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; export interface ISuperCoolStream { readonly source: T @@ -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,23 @@ defineReadItem('-', readKeyword); defineReadItem(':', readKeyword); definePostReadReplace(/^https:\/\/matrix\.to/, input => { - const url = Permalinks.parseUrl(input); - if (url.eventId !== undefined) { + const parseResult = Permalinks.parseUrl(input); + if (isError(parseResult)) { + // it's an invalid URI. + return input; + } + const url = parseResult.ok; + if (url.eventID !== undefined) { // don't know what to turn event references into yet. return input; + } 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; + } } }) From 68bfd61ccf8edce3bd44c2586c3f4cdb546013ed Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 27 Nov 2023 16:00:23 +0000 Subject: [PATCH 017/160] Update all of InterfaceManager to use MPS. --- .../interface-manager/DeadDocumentMatrix.ts | 2 +- .../interface-manager/InterfaceCommand.ts | 6 +- .../interface-manager/MatrixHelpRenderer.tsx | 35 +++--- .../MatrixInterfaceAdaptor.ts | 26 ++--- .../interface-manager/MatrixPresentations.tsx | 4 +- .../MatrixPromptForAccept.tsx | 13 +-- .../interface-manager/MatrixPromptUX.ts | 24 ++--- .../MatrixReactionHandler.ts | 6 +- .../interface-manager/ParameterParsing.ts | 102 +++++++++--------- .../interface-manager/PromptForAccept.ts | 8 +- 10 files changed, 113 insertions(+), 113 deletions(-) diff --git a/src/commands/interface-manager/DeadDocumentMatrix.ts b/src/commands/interface-manager/DeadDocumentMatrix.ts index 81f1489f..2a38b669 100644 --- a/src/commands/interface-manager/DeadDocumentMatrix.ts +++ b/src/commands/interface-manager/DeadDocumentMatrix.ts @@ -3,7 +3,7 @@ * All rights reserved. */ -import { MatrixSendClient } from "../../MatrixEmitter"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { AbstractNode, DocumentNode, FringeWalker, NodeTag } from "./DeadDocument"; import { HTML_RENDERER } from "./DeadDocumentHtml"; import { MARKDOWN_RENDERER } from "./DeadDocumentMarkdown"; diff --git a/src/commands/interface-manager/InterfaceCommand.ts b/src/commands/interface-manager/InterfaceCommand.ts index 263435ca..40466dcf 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: any) => Promise>; type CommandLookupEntry = { next?: Map>, @@ -200,7 +200,7 @@ export class InterfaceCommand public async parseThenInvoke(context: ThisParameterType, stream: IArgumentStream): Promise> { const parameterDescription = await this.parseArguments(stream); - if (parameterDescription.isErr()) { + if (isError(parameterDescription)) { // 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>; } diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 9a91cbc1..882454cc 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 { 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, 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 async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: any, 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}
    diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 02b5fae6..d3b0d132 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -29,28 +29,28 @@ 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 { 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 { ParameterDescription } from "./ParameterParsing"; import { matrixPromptForAccept } from "./MatrixPromptForAccept"; +import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ActionError, ActionResult, ResultError, RoomEvent, RoomMessage, StringRoomID, isError } from "matrix-protection-suite"; export interface MatrixContext { client: MatrixSendClient, - emitter: MatrixEmitter, - roomId: string, - event: any, + emitter: SafeMatrixEmitter, + roomID: StringRoomID, + event: RoomMessage, } type RendererSignature = ( this: MatrixInterfaceAdaptor, client: MatrixClient, commandRoomId: string, - event: any, + event: RoomEvent, result: Awaited>) => Promise; export class MatrixInterfaceAdaptor implements InterfaceAcceptor { @@ -58,7 +58,7 @@ export class MatrixInterfaceAdaptor, private readonly renderer: RendererSignature, - private readonly validationErrorHandler?: (client: MatrixClient, roomId: string, event: any, validationError: CommandError) => Promise + private readonly validationErrorHandler?: (client: MatrixClient, roomID: StringRoomID, event: RoomEvent, validationError: ActionError) => Promise ) { } @@ -75,13 +75,13 @@ export class MatrixInterfaceAdaptor>(this.interfaceCommand, executorContext, matrixContext); const stream = new PromptableArgumentStream(args, this, invocationRecord); const executorResult: Awaited> = await this.interfaceCommand.parseThenInvoke(executorContext, stream); - if (executorResult.isErr()) { - this.reportValidationError(matrixContext.client, matrixContext.roomId, matrixContext.event, executorResult.err); + if (isError(executorResult)) { + 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,16 +89,16 @@ export class MatrixInterfaceAdaptor { + private async reportValidationError(client: MatrixSendClient, roomID: StringRoomID, event: RoomMessage, 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)); + await tickCrossRenderer.call(this, client, roomID, event, ResultError(validationError)); } - public async promptForAccept(parameter: ParameterDescription, invocationRecord: CommandInvocationRecord): Promise> { + 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."); } diff --git a/src/commands/interface-manager/MatrixPresentations.tsx b/src/commands/interface-manager/MatrixPresentations.tsx index 1a3bb109..1fdb1325 100644 --- a/src/commands/interface-manager/MatrixPresentations.tsx +++ b/src/commands/interface-manager/MatrixPresentations.tsx @@ -6,10 +6,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 { MatrixRoomAlias, MatrixRoomID } from "matrix-protection-suite"; makePresentationType({ @@ -19,7 +19,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({ diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index cbc444ad..5bf889c8 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -3,6 +3,7 @@ * All rights reserved. */ +import { ActionResult, StringUserID } from "matrix-protection-suite"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; import { JSXFactory } from "./JSXFactory"; @@ -10,7 +11,6 @@ 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( @@ -18,7 +18,7 @@ async function promptDefault(this: MatrixContext, parameter: P 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 ) } @@ -38,22 +38,23 @@ async function promptSuggestions( })} , - this.roomId, this.event, this.client + 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); +): Promise> { + // FIXME: is there a better way to get the clinet ID? why isn't Draupnir in the command context? + const promptHelper = new PromptResponseListener(this.emitter, await this.client.getUserId() as StringUserID, 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, + this.roomID, promptSuggestions.call(this, parameter, command, promptOptions.suggestions) ); } diff --git a/src/commands/interface-manager/MatrixPromptUX.ts b/src/commands/interface-manager/MatrixPromptUX.ts index 8886932d..13434f7b 100644 --- a/src/commands/interface-manager/MatrixPromptUX.ts +++ b/src/commands/interface-manager/MatrixPromptUX.ts @@ -3,9 +3,9 @@ * All rights reserved. */ -import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; -import { CommandError, CommandResult } from "./Validation"; import { LogService } from "matrix-bot-sdk"; +import { ActionError, ActionResult, Ok, StringUserID } from "matrix-protection-suite"; +import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; type PresentationByReactionKey = Map; @@ -29,8 +29,8 @@ class ReactionHandler { private readonly promptRecordByEvent: Map> = new Map(); constructor( - matrixEmitter: MatrixEmitter, - private readonly userId: string, + matrixEmitter: SafeMatrixEmitter, + private readonly userID: StringUserID, private readonly client: MatrixSendClient, ) { matrixEmitter.on('room.event', this.handleEvent.bind(this)) @@ -83,7 +83,7 @@ class ReactionHandler { if (!(typeof relatedEventId === 'string' && typeof reactionKey === 'string')) { return; } - if (event['sender'] === this.userId) { + if (event['sender'] === this.userID) { return; } const entry = this.promptRecordByEvent.get(relatedEventId); @@ -104,7 +104,7 @@ class ReactionHandler { public async waitForReactionToPrompt( roomId: string, eventId: string, presentationByReaction: PresentationByReactionKey, timeout = 600_000 // ten minutes - ): Promise> { + ): Promise> { let record; let timeoutId; const presentationOrTimeout = await Promise.race([ @@ -120,9 +120,9 @@ class ReactionHandler { if (record !== undefined) { this.removePromptRecordForEvent(eventId, record); } - return CommandError.Result(`Timed out while waiting for a response to the prompt`); + return ActionError.Result(`Timed out while waiting for a response to the prompt`); } else { - return CommandResult.Ok(presentationOrTimeout as T); + return Ok(presentationOrTimeout as T); } } } @@ -154,11 +154,11 @@ export class PromptResponseListener { private readonly reactionHandler: ReactionHandler; constructor( - matrixEmitter: MatrixEmitter, - userId: string, + matrixEmitter: SafeMatrixEmitter, + userID: StringUserID, client: MatrixSendClient, ) { - this.reactionHandler = new ReactionHandler(matrixEmitter, userId, client); + this.reactionHandler = new ReactionHandler(matrixEmitter, userID, client); } private indexToReactionKey(index: number): string { @@ -168,7 +168,7 @@ export class PromptResponseListener { // 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> { + 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); diff --git a/src/commands/interface-manager/MatrixReactionHandler.ts b/src/commands/interface-manager/MatrixReactionHandler.ts index d6ad9ae8..f4739bd4 100644 --- a/src/commands/interface-manager/MatrixReactionHandler.ts +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -4,8 +4,8 @@ */ import { EventEmitter } from "stream"; -import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; import { LogService } from "matrix-bot-sdk"; +import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; const REACTION_ANNOTATION_KEY = 'ge.applied-langua.ge.draupnir.reaction_handler'; @@ -92,14 +92,14 @@ export class MatrixReactionHandler extends EventEmitter { * Start listening for reactions to events. * Called normally by an associated mjolnir instance when it is started. */ - public start(emitter: MatrixEmitter): void { + public start(emitter: SafeMatrixEmitter): void { emitter.on('room.event', this.listener); } /** * Stop listening for reactions to events. */ - public stop(emitter: MatrixEmitter): void { + public stop(emitter: SafeMatrixEmitter): void { emitter.off('room.event', this.listener); } diff --git a/src/commands/interface-manager/ParameterParsing.ts b/src/commands/interface-manager/ParameterParsing.ts index fa7c3250..5ec5258b 100644 --- a/src/commands/interface-manager/ParameterParsing.ts +++ b/src/commands/interface-manager/ParameterParsing.ts @@ -24,15 +24,15 @@ 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"; export interface IArgumentStream extends ISuperCoolStream { rest(): 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 { @@ -44,7 +44,7 @@ export class ArgumentStream extends SuperCoolStream implements IArgu 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 +52,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 +86,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 +98,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 +154,34 @@ 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; + if (isError(result)) { + return result; } } 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 +263,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 +298,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 +306,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 +347,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,21 +377,21 @@ 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; + if (isError(promptResult)) { + return promptResult; } hasPrompted = true; } else { @@ -402,20 +402,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 +423,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 +443,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 +464,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/PromptForAccept.ts b/src/commands/interface-manager/PromptForAccept.ts index c87e0374..6fafbca9 100644 --- a/src/commands/interface-manager/PromptForAccept.ts +++ b/src/commands/interface-manager/PromptForAccept.ts @@ -3,10 +3,10 @@ * All rights reserved. */ +import { ActionResult } from "matrix-protection-suite"; import { ReadItem } from "./CommandReader"; import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; import { ArgumentStream, ParameterDescription } from "./ParameterParsing"; -import { CommandResult } from "./Validation"; export interface PromptOptions { readonly suggestions: PresentationType[] @@ -19,7 +19,7 @@ export interface PromptOptions { */ export interface InterfaceAcceptor { readonly isPromptable: boolean - promptForAccept(parameter: ParameterDescription, invocationRecord: CommandInvocationRecord): Promise> + promptForAccept(parameter: ParameterDescription, invocationRecord: CommandInvocationRecord): Promise> } export interface CommandInvocationRecord { @@ -43,12 +43,12 @@ export class PromptableArgumentStream extends ArgumentStream { return this.interfaceAcceptor.isPromptable } - public async prompt(parameterDescription: ParameterDescription): Promise> { + public async prompt(parameterDescription: ParameterDescription): Promise> { const result = await this.interfaceAcceptor.promptForAccept( parameterDescription, this.invocationRecord ); - if (result.isOk()) { + if (result.isOkay) { this.source.push(result.ok); } return result; From 844751a5b3b859b4b8c62045026bd439ce3acf8e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Dec 2023 16:43:12 +0000 Subject: [PATCH 018/160] Add SynapseAdminClient from MPS4BotSDK to Draupnir. --- src/Draupnir.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 9036caa8..b48e2036 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -33,7 +33,7 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter, SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; import { makeProtectedRoomsSet } from "./DraupnirBotMode"; @@ -80,7 +80,8 @@ export class Draupnir { public readonly managementRoom: MatrixRoomID, public readonly config: IConfig, public readonly protectedRoomsSet: ProtectedRoomsSet, - public readonly managerManager: ManagerManager + public readonly managerManager: ManagerManager, + public readonly synapseAdminClient?: SynapseAdminClient ) { this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID); @@ -117,7 +118,11 @@ export class Draupnir { managementRoom, config, protectedRoomsSet, - managerManager + managerManager, + new SynapseAdminClient( + client, + clientUserID + ) ); const loadResult = await protectedRoomsSet.protections.loadProtections( makeStandardConsequenceProvider(client, draupnir.managementRoomID), From 76a58b6f00d360b78849c5a9b32930ee89f932ec Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Dec 2023 16:44:38 +0000 Subject: [PATCH 019/160] Delete directory management commands. This should be managed from the client, and the idea that being a Synapse admin makes a difference is unfounded. It wasn't even connected to the command handler anyway. --- .../AddRemoveRoomFromDirectoryCommand.ts | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 src/commands/AddRemoveRoomFromDirectoryCommand.ts 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, -}) From f6771b06cf15535aeed253a4063b3806d13ff9af Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 2 Dec 2023 18:05:27 +0000 Subject: [PATCH 020/160] Add MatrixEventReference to presentation types. --- src/commands/interface-manager/CommandReader.ts | 12 ++++++++---- .../interface-manager/MatrixPresentations.tsx | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index ac0014bb..283834ec 100644 --- a/src/commands/interface-manager/CommandReader.ts +++ b/src/commands/interface-manager/CommandReader.ts @@ -3,7 +3,7 @@ * All rights reserved. */ -import { MatrixRoomReference, Permalinks, UserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; +import { MatrixEventReference, MatrixRoomReference, Permalinks, UserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; export interface ISuperCoolStream { readonly source: T @@ -136,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. @@ -276,8 +276,12 @@ definePostReadReplace(/^https:\/\/matrix\.to/, input => { } const url = parseResult.ok; if (url.eventID !== undefined) { - // don't know what to turn event references into yet. - return input; + 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 { diff --git a/src/commands/interface-manager/MatrixPresentations.tsx b/src/commands/interface-manager/MatrixPresentations.tsx index 1fdb1325..7b0f5b0d 100644 --- a/src/commands/interface-manager/MatrixPresentations.tsx +++ b/src/commands/interface-manager/MatrixPresentations.tsx @@ -9,7 +9,7 @@ import { UserID } from "matrix-bot-sdk"; import { definePresentationRenderer } from "./DeadDocumentPresentation"; import { JSXFactory } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; -import { MatrixRoomAlias, MatrixRoomID } from "matrix-protection-suite"; +import { MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomAlias, MatrixRoomID } from "matrix-protection-suite"; makePresentationType({ @@ -38,3 +38,8 @@ definePresentationRenderer(findPresentationType('UserID'), function (presentatio {presentation.toString()} }) + +makePresentationType({ + name: 'MatrixEventReference', + validator: simpleTypeValidator('MatrixEventReference', (item) => item instanceof MatrixEventViaAlias || item instanceof MatrixEventViaRoomID) +}) From 8fbec62df7aa1830f0ddb6baff3199b083f9a854 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:00:23 +0000 Subject: [PATCH 021/160] Command Context should have MatrixSendClient. --- src/commands/interface-manager/InterfaceCommand.ts | 2 +- src/commands/interface-manager/MatrixHelpRenderer.tsx | 6 +++--- .../interface-manager/MatrixInterfaceAdaptor.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/commands/interface-manager/InterfaceCommand.ts b/src/commands/interface-manager/InterfaceCommand.ts index 40466dcf..18323420 100644 --- a/src/commands/interface-manager/InterfaceCommand.ts +++ b/src/commands/interface-manager/InterfaceCommand.ts @@ -38,7 +38,7 @@ import { ParameterParser, IArgumentStream, IArgumentListParser, ParsedKeywords, * Probably am "doing something wrong", and no, trying to make this protocol isn't it. */ -export type BaseFunction = (keywords: ParsedKeywords, ...args: any) => Promise>; +export type BaseFunction = (keywords: ParsedKeywords, ...args: unknown[]) => Promise>; type CommandLookupEntry = { next?: Map>, diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 882454cc..98a0dbf3 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -3,14 +3,14 @@ */ import { BaseFunction, CommandTable, InterfaceCommand } from "./InterfaceCommand"; -import { MatrixContext, MatrixInterfaceAdaptor } from "./MatrixInterfaceAdaptor"; +import { MatrixContext, MatrixInterfaceAdaptor, RendererSignature } from "./MatrixInterfaceAdaptor"; import { ArgumentParseError, ParameterDescription, RestDescription } from "./ParameterParsing"; import { JSXFactory } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { LogService } from "matrix-bot-sdk"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionException, ActionResult, StringRoomID, isError } from "matrix-protection-suite"; +import { ActionException, ActionResult, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; function requiredArgument(argumentName: string): string { return `<${argumentName}>`; @@ -88,7 +88,7 @@ export async function renderHelp(client: MatrixSendClient, commandRoomID: String ); } -export async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: any, result: ActionResult): 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); diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index d3b0d132..443f0481 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -46,19 +46,19 @@ export interface MatrixContext { event: RoomMessage, } -type RendererSignature = ( +export type RendererSignature = ( this: MatrixInterfaceAdaptor, - client: MatrixClient, - commandRoomId: string, + client: MatrixSendClient, + commandRoomID: StringRoomID, event: RoomEvent, - result: Awaited>) => Promise; + 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: StringRoomID, event: RoomEvent, validationError: ActionError) => Promise + private readonly validationErrorHandler?: (client: MatrixSendClient, roomID: StringRoomID, event: RoomEvent, validationError: ActionError) => Promise ) { } From 4d054f43451b86c06b0f0831b6a9e0110fdec923 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:01:20 +0000 Subject: [PATCH 022/160] Begin moving all commands over to interface-manager and MPS. --- src/commands/CommandHandler.ts | 73 ++++++++++++---------------------- 1 file changed, 26 insertions(+), 47 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 4e180882..f8bec264 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -25,38 +25,22 @@ 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>; defineCommandTable("synapse admin"); import "./HijackRoomCommand"; @@ -73,26 +57,22 @@ import "./Rules"; import "./WatchUnwatchCommand"; import "./Help"; import "./SetDisplayNameCommand"; -import { RoomMessage, StringRoomID } from "matrix-protection-suite"; -export const COMMAND_PREFIX = "!mjolnir"; +export const COMMAND_PREFIX = "!draupnir"; export async function handleCommand( roomID: StringRoomID, event: RoomMessage, normalisedCommand: string, - mjolnir: Mjolnir, + draupnir: Draupnir, commandTable: CommandTable ) { - const parts = normalisedCommand.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(normalisedCommand.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); - try { + /** + * TODO Delete these: + if (parts[1] === 'joins') { - return await showJoinsStatus(roomID, event, mjolnir, parts.slice(/* ["joins"] */ 2)); + return await showJoinsStatus(roomID, event, mjolnir, parts.slice(2)); // joins } else if (parts[1] === 'sync') { return await execSyncCommand(roomID, event, mjolnir); } else if (parts[1] === 'verify') { @@ -127,28 +107,27 @@ export async function handleCommand( 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(normalisedCommand).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: 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).slice(1); // remove "!mjolnir" + const stream = new ArgumentStream(readItems); + const command = commandTable.findAMatchingCommand(stream) + ?? findTableCommand("mjolnir", "help"); + const adaptor = findMatrixInterfaceAdaptor(command); + const mjolnirContext: DraupnirContext = { + draupnir, roomID: roomID, event, client: draupnir.client, emitter: draupnir.matrixEmitter, + }; + try { + return await adaptor.invoke(mjolnirContext, mjolnirContext, ...stream.rest()); + } catch (e) { + const commandError = new ActionException(ActionExceptionKind.Unknown, e, 'Unknown Unexpected Error'); + await tickCrossRenderer.call(mjolnirContext, draupnir.client, roomID, event, ResultError(commandError)); } } catch (e) { LogService.error("CommandHandler", e); const text = "There was an error processing your command - see console/log for details"; const reply = RichReply.createFor(roomID, event, text, text); reply["msgtype"] = "m.notice"; - return await mjolnir.client.sendMessage(roomID, reply); + return await draupnir.client.sendMessage(roomID, reply); } } From 6fb05842606dc767971811ae8fc18d7ba9c777db Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:02:11 +0000 Subject: [PATCH 023/160] Move kick command over to interface-manager and MPS. --- src/commands/KickCommand.ts | 89 -------------------- src/commands/KickCommand.tsx | 153 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 89 deletions(-) delete mode 100644 src/commands/KickCommand.ts create mode 100644 src/commands/KickCommand.tsx 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..4572a95b --- /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 + ); + } +}) From 67b4c31cce0b14e8f511f9357d4a94f070e0aa7c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:03:02 +0000 Subject: [PATCH 024/160] Move resolve command other to interface-manager and MPS. --- src/commands/ResolveAlias.ts | 43 --------------------- src/commands/ResolveAlias.tsx | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 43 deletions(-) delete mode 100644 src/commands/ResolveAlias.ts create mode 100644 src/commands/ResolveAlias.tsx 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 + ) + } +}) From 89bede86c30042dcadfdea2925e255b83da3bfc0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:05:17 +0000 Subject: [PATCH 025/160] Begin moving most commands to interface-manager and MPS. --- src/commands/AliasCommands.ts | 54 +++++++----- src/commands/Ban.tsx | 110 +++++++++-------------- src/commands/CreateBanListCommand.ts | 72 ++++++++++----- src/commands/DeactivateCommand.ts | 20 +++-- src/commands/HijackRoomCommand.ts | 30 ++++--- src/commands/ImportCommand.ts | 91 ++++++++++++------- src/commands/RedactCommand.ts | 121 +++++++++++++++++++------- src/commands/Rooms.tsx | 8 +- src/commands/Rules.tsx | 6 +- src/commands/SetDisplayNameCommand.ts | 12 +-- src/commands/SetPowerLevelCommand.ts | 62 +++++++++---- src/commands/ShutdownRoomCommand.ts | 35 +++++--- src/commands/StatusCommand.tsx | 109 +++++++++++------------ src/commands/Unban.ts | 6 +- src/commands/WatchUnwatchCommand.ts | 6 +- 15 files changed, 430 insertions(+), 312 deletions(-) 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..6bb06384 100644 --- a/src/commands/Ban.tsx +++ b/src/commands/Ban.tsx @@ -25,88 +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 } 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.managerManager.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; + ): Promise> { + const policyListEditorResult = await findPolicyRoomEditorFromRoomReference( + this.draupnir, + policyRoomReference + ); + if (isError(policyListEditorResult)) { + return policyListEditorResult; } - const policyList = policyListResult.ok; - + const policyListEditor = policyListEditorResult.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); + return await policyListEditor.banEntity(PolicyRuleType.User, entity.toString(), reason); + } else if (typeof entity === 'string') { + return await policyListEditor.banEntity(PolicyRuleType.Server,entity, reason); } else { - await policyList.banEntity(RULE_SERVER, entity, reason); + const resolvedRoomReference = await resolveRoomReferenceSafe( + this.draupnir.client, + entity + ); + if (isError(resolvedRoomReference)) { + return resolvedRoomReference; + } + return await policyListEditor.banEntity(PolicyRuleType.Server, resolvedRoomReference.ok.toRoomIDOrAlias(), reason); } - return CommandResult.Ok(undefined); } defineInterfaceCommand({ @@ -124,23 +93,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.managerManager.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/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 78723f30..15e5ce59 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -25,31 +25,55 @@ 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, MatrixRoomAlias, 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, + alias: MatrixRoomAlias, + ...reasonParts: string[] +): Promise> { + const newList = await this.draupnir.managerManager.policyRoomManager.createPolicyRoom( shortcode, - [event['sender']], - { room_alias_name: aliasLocalpart } + [this.event.sender], + { + room_alias_name: alias.toRoomIDOrAlias() + } ); + 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", + acceptor: findPresentationType("MatrixRoomAlias"), + }, + ]), + 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/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..b03d6ae5 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.managerManager.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/RedactCommand.ts b/src/commands/RedactCommand.ts index d1c185f5..31c286c7 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/Rooms.tsx b/src/commands/Rooms.tsx index eaf80613..170181f1 100644 --- a/src/commands/Rooms.tsx +++ b/src/commands/Rooms.tsx @@ -27,7 +27,7 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; +import { DraupnirContext } from "./CommandHandler"; import { MatrixRoomID, MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; import { CommandResult } from "./interface-manager/Validation"; import { CommandException, CommandExceptionKind } from "./interface-manager/CommandException"; @@ -43,7 +43,7 @@ defineInterfaceCommand({ designator: ["rooms"], summary: "List all of the protected rooms.", parameters: parameters([]), - command: async function (this: MjolnirContext, _keywrods): Promise> { + command: async function (this: DraupnirContext, _keywrods): Promise> { return CommandResult.Ok(this.mjolnir.protectedRoomsTracker.getProtectedRooms()); } }) @@ -84,7 +84,7 @@ defineInterfaceCommand({ description: 'The room to protect.' } ]), - command: async function (this: MjolnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { + command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { const roomIDOrError = await (async () => { try { return CommandResult.Ok(await roomRef.joinClient(this.mjolnir.client)); @@ -115,7 +115,7 @@ defineInterfaceCommand({ description: 'The room to stop protecting.' } ]), - command: async function (this: MjolnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { + command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { const roomID = await roomRef.resolve(this.mjolnir.client); await this.mjolnir.removeProtectedRoom(roomID.toRoomIdOrAlias()); try { diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index f1e3cb3d..8f2916b1 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MjolnirContext } from "./CommandHandler"; +import { DraupnirContext } from "./CommandHandler"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { JSXFactory } from "./interface-manager/JSXFactory"; @@ -87,7 +87,7 @@ defineInterfaceCommand({ designator: ["rules"], table: "mjolnir", parameters: parameters([]), - command: async function (this: MjolnirContext) { + command: async function (this: DraupnirContext) { return CommandResult.Ok( this.mjolnir.policyListManager.lists .map(list => { @@ -122,7 +122,7 @@ defineInterfaceCommand({ } ]), command: async function ( - this: MjolnirContext, _keywords, entity: string|UserID|MatrixRoomReference + this: DraupnirContext, _keywords, entity: string|UserID|MatrixRoomReference ): Promise> { return CommandResult.Ok( this.mjolnir.policyListManager.lists 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..d5e6b87a 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: "", + designator: [], + 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/StatusCommand.tsx b/src/commands/StatusCommand.tsx index a8399db3..74fc9eb7 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -25,40 +25,34 @@ 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 { DraupnirContext } from "./CommandHandler"; import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; -import { MatrixSendClient } from "../MatrixEmitter"; 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) { + return Ok(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 +60,36 @@ export interface StatusInfo { repository: string } - -function mjolnirStatusInfo(this: MjolnirContext): StatusInfo { - const listInfo = (list: PolicyList): ListInfo => { +async function listInfo(draupnir: Draupnir): Promise { + const watchedListProfiles = draupnir.protectedRoomsSet.issuerManager.allWatchedLists; + const issuerResults = await Promise.all(watchedListProfiles.map((profile) => + draupnir.managerManager.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,49 +97,35 @@ 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)} 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); } @@ -151,12 +140,12 @@ defineInterfaceCommand({ acceptor: findPresentationType("string") }, ], - new RestDescription( + new RestDescription( "subcommand", findPresentationType("any") )), command: async function ( - this: MjolnirContext, _keywords, protectionName: string, ...subcommands: string[] + this: DraupnirContext, _keywords, protectionName: string, ...subcommands: string[] ): Promise>>> { const protection = this.mjolnir.protectionManager.getProtection(protectionName); if (!protection) { diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index e9f8fc21..fb7ce2fc 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MjolnirContext } from "./CommandHandler"; +import { DraupnirContext } from "./CommandHandler"; import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; import { findPresentationType, KeywordsDescription, parameters, ParsedKeywords, union } from "./interface-manager/ParameterParsing"; import { UserID, MatrixGlob, LogLevel } from "matrix-bot-sdk"; @@ -68,7 +68,7 @@ async function unbanUserFromRooms(mjolnir: Mjolnir, rule: MatrixGlob) { } async function unban( - this: MjolnirContext, + this: DraupnirContext, keywords: ParsedKeywords, entity: UserID|MatrixRoomReference|string, policyListReference: MatrixRoomReference|string, @@ -126,7 +126,7 @@ defineInterfaceCommand({ findPresentationType("string"), findPresentationType("PolicyList"), ), - prompt: async function (this: MjolnirContext) { + prompt: async function (this: DraupnirContext) { return { suggestions: this.mjolnir.policyListManager.lists }; diff --git a/src/commands/WatchUnwatchCommand.ts b/src/commands/WatchUnwatchCommand.ts index 9d51b8b1..145f90ea 100644 --- a/src/commands/WatchUnwatchCommand.ts +++ b/src/commands/WatchUnwatchCommand.ts @@ -27,7 +27,7 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; -import { MjolnirContext } from "./CommandHandler"; +import { DraupnirContext } from "./CommandHandler"; import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; import { CommandError, CommandResult } from "./interface-manager/Validation"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; @@ -43,7 +43,7 @@ defineInterfaceCommand({ acceptor: findPresentationType("MatrixRoomReference"), } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, list: MatrixRoomReference): Promise> { + command: async function (this: DraupnirContext, _keywords: ParsedKeywords, list: MatrixRoomReference): Promise> { await this.mjolnir.policyListManager.watchList(list); return CommandResult.Ok(undefined); }, @@ -64,7 +64,7 @@ defineInterfaceCommand({ acceptor: findPresentationType("MatrixRoomReference"), } ]), - command: async function (this: MjolnirContext, _keywords: ParsedKeywords, list: MatrixRoomReference): Promise> { + command: async function (this: DraupnirContext, _keywords: ParsedKeywords, list: MatrixRoomReference): Promise> { await this.mjolnir.policyListManager.unwatchList(list); return CommandResult.Ok(undefined); }, From a370f0f4b4ca55d5361e038320d1164604ea5fc5 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:07:38 +0000 Subject: [PATCH 026/160] Begin moving protections over to MPS protections. --- src/protections/BanPropagation.tsx | 5 +- src/protections/BasicFlooding.ts | 114 ++++++++++----- src/protections/FirstMessageIsImage.ts | 112 +++++++++------ src/protections/JoinWaveShortCircuit.ts | 147 ------------------- src/protections/JoinWaveShortCircuit.tsx | 171 +++++++++++++++++++++++ src/protections/MessageIsMedia.ts | 64 ++++++--- src/protections/MessageIsVoice.ts | 65 ++++++--- src/protections/Protection.ts | 53 +------ src/protections/WordList.ts | 120 +++++++++------- 9 files changed, 476 insertions(+), 375 deletions(-) delete mode 100644 src/protections/JoinWaveShortCircuit.ts create mode 100644 src/protections/JoinWaveShortCircuit.tsx diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 0a560a5e..451c780b 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -34,9 +34,10 @@ import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRende import { UserID } from "matrix-bot-sdk"; import { renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; -import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, Protection, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DraupnirProtection } from "./Protection"; const log = new Logger('BanPropagationProtection'); @@ -133,7 +134,7 @@ async function promptUnbanPropagation( await draupnir.reactionHandler.addReactionsToEvent(draupnir.client, draupnir.managementRoomID, promptEventId, reactionMap); } -export class BanPropagationProtection extends AbstractProtection implements Protection { +export class BanPropagationProtection extends AbstractProtection implements DraupnirProtection { constructor( description: ProtectionDescription, diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 89cc7a4c..587e383b 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -25,45 +25,86 @@ 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, ConsequenceProvider, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringRoomID, StringUserID, 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 }[] } } = {}; +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.", + factory: (description, consequenceProvider, protectedRoomsSet, draupnir, rawSettings) => { + const parsedSettings = description.protectionSettings.parseSettings(rawSettings); + if (isError(parsedSettings)) { + return parsedSettings; + } + return Ok( + new BasicFloodingProtection( + description, + consequenceProvider, + 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."; + public constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + private readonly settings: BasicFloodingProtectionSettings, + ) { + super( + description, + consequenceProvider, + protectedRoomsSet, + [], + [] + ) } - 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 +113,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.consequenceProvider.consequenceForUserInRoom(this.description, 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.consequenceProvider.consequenceForEvent(this.description, 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 +141,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/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 915f6b88..c1f1cb96 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -25,72 +25,94 @@ 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, ConsequenceProvider, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; + +type FirstMessageIsImageProtectionSettings = {} + +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.", + factory: function (description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + return Ok( + new FirstMessageIsImageProtection( + description, + consequenceProvider, + 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[] = []; + + constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + ) { + super( + description, + consequenceProvider, + protectedRoomsSet, + [], + [] + ); } - 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)) { + 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..2eb1eeb8 --- /dev/null +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -0,0 +1,171 @@ +/** + * 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, ConsequenceProvider, Logger, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, 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"; +import { DocumentNode } from "../commands/interface-manager/DeadDocument"; + +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, +} + +describeProtection({ + name: 'JoinWaveShortCircuitProtection', + description: "If X amount of users join in Y time, set the room to invite-only.", + factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, settings) { + const parsedSettings = description.protectionSettings.parseSettings(settings); + if (isError(parsedSettings)) { + return parsedSettings + } + return Ok( + new JoinWaveShortCircuitProtection( + description, + consequenceProvider, + 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: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + public readonly settings: JoinWaveShortCircuitProtectionSettings + ) { + super( + description, + consequenceProvider, + 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) + } + + 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..7ba5e9b4 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -25,41 +25,59 @@ 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, ConsequenceProvider, 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 = {}; - - constructor() { - super(); +describeProtection({ + name: 'MessageIsMediaProtection', + description: "If a user posts an image or video, that message will be redacted. No bans are issued.", + factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + return Ok( + new MessageIsMediaProtection( + description, + consequenceProvider, + protectedRoomsSet, + draupnir + ) + ) } +}) - public get name(): string { - return 'MessageIsMediaProtection'; - } - 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 { + constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir + ) { + super( + description, + consequenceProvider, + protectedRoomsSet, + [], + [] + ); } - 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)) { + 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('({ + name: 'MessageIsVoiceProtection', + description: 'If a user posts a voice message, that message will be redacted', + factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + return Ok( + new MessageIsVoiceProtection( + description, + consequenceProvider, + 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 { + constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + ) { + super( + description, + consequenceProvider, + 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 (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.consequenceProvider.consequenceForEvent(this.description, 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/Protection.ts b/src/protections/Protection.ts index e33281b3..9d46f8bf 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/WordList.ts b/src/protections/WordList.ts index 80e26cb4..ca9f3bc5 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -25,77 +25,92 @@ 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, ConsequenceProvider, Logger, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; + +const log = new Logger('WordList'); + +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.", + factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + return Ok( + new WordListProtection( + description, + consequenceProvider, + 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(); - } - - 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."; + constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + ) { + super( + description, + consequenceProvider, + protectedRoomsSet, + [], + [] + ); } - - 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)) { + 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 +120,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.consequenceProvider.consequenceForUserInRoom(this.description, roomID, event.sender, reason); + await this.consequenceProvider.consequenceForEvent(this.description, roomID, event.event_id, reason); } } + return Ok(undefined); } } From cb942365b3f8645ef24a8e0f68e529ed9c09e443 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:08:34 +0000 Subject: [PATCH 027/160] Remove JoinsCommand. There isn't the time to make this work at the moment. It should be brought back later and made to use the `SetMembership` component from MPS. --- src/commands/JoinsCommand.ts | 105 ----------------------------------- 1 file changed, 105 deletions(-) delete mode 100644 src/commands/JoinsCommand.ts 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); -} From a9bde39a0ae9be3db28c47a9243f815ae7c5a0ec Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:09:50 +0000 Subject: [PATCH 028/160] Remove default banlist command. In reality this sets a default shortcode, and for the moment we aren't able to work with shortcodes. I don't know if it makes sense to bring this command back in future because we would like to instead have the ban propagation protection have a setting for a list to fill client bans directly into. --- src/commands/SetDefaultBanListCommand.ts | 47 ------------------------ 1 file changed, 47 deletions(-) delete mode 100644 src/commands/SetDefaultBanListCommand.ts 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'], '✅'); -} From 34133f6e8c93f5395cf681fca2869082b0f001b1 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:11:07 +0000 Subject: [PATCH 029/160] Remove since command Much like the joins command, this needs rewriting to use `SetMembership` from MPS and there isn't the time to do that right now. These commands didn't seem to work as intended anyway. --- src/commands/SinceCommand.ts | 343 ----------------------------------- 1 file changed, 343 deletions(-) delete mode 100644 src/commands/SinceCommand.ts 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")}` - } -} From 11dc0506f204b4c71345d71923b3519963f90d97 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:11:49 +0000 Subject: [PATCH 030/160] Remove sync command. It no longer makes sense to have a sync command since all synchornisation is done by protections. So it might come back just not in its current form. --- src/commands/SyncCommand.ts | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 src/commands/SyncCommand.ts 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(); -} From 4b415410a989483762b4798a34200db8da135e6b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:12:29 +0000 Subject: [PATCH 031/160] Remove detect federation lag command. This is a pretty bold piece of work and it's a shame to remove it for now. We would like to bring it back, possibly as a protection. However, there isn't the time while we move over to the new MPS backend. --- src/protections/DetectFederationLag.ts | 762 ------------------------- 1 file changed, 762 deletions(-) delete mode 100644 src/protections/DetectFederationLag.ts 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 - } - } -} From 342ef16be67837d8f7ce6e8cbefda07665f9b5dc Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 17:13:25 +0000 Subject: [PATCH 032/160] Consequences are now replaced by MPS consequence providers. --- src/protections/consequence.ts | 44 ---------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 src/protections/consequence.ts 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); - } -} From 03ed39dd699e31f306a6324ce31631ca273974bc Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 6 Dec 2023 20:00:45 +0000 Subject: [PATCH 033/160] Refactor TrustedReporters for MPS. --- src/protections/ProtectionSettings.ts | 197 -------------------------- src/protections/TrustedReporters.ts | 114 ++++++++++----- 2 files changed, 76 insertions(+), 235 deletions(-) delete mode 100644 src/protections/ProtectionSettings.ts 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/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 01b4bd15..816a459f 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -25,49 +25,87 @@ 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, ConsequenceProvider, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringUserID, StringUserIDSetProtectionSettings, 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, +} + +describeProtection({ + name: 'TrustedReporters', + description: "Count reports from trusted reporters and take a configured action", + factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, rawSettings) { + const parsedSettings = description.protectionSettings.parseSettings(rawSettings); + if (isError(parsedSettings)) { + return parsedSettings; + } + return Ok( + new TrustedReporters( + description, + consequenceProvider, + 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>(); + + public constructor( + description: ProtectionDescription, + consequenceProvider: ConsequenceProvider, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + public readonly settings: TrustedReportersProtectionSettings + ) { + super( + description, + consequenceProvider, + protectedRoomsSet, + [], + [] + ); } - 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 +113,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.consequenceProvider.consequenceForEvent(this.description, 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.consequenceProvider.consequenceForEvent(this.description, report.room_id, report.event_id, "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) } } From e710f9928637db8d1933732e3227c539752e3c73 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 7 Dec 2023 00:29:42 +0000 Subject: [PATCH 034/160] Update BasicFlooding protection for protection settings changes. --- src/protections/BasicFlooding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 587e383b..a08a5003 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -62,7 +62,7 @@ describeProtection({ ) }, protectionSettings: new StandardProtectionSettings({ - maxPerMinute: new SafeIntegerProtectionSetting( + maxPerMinute: new SafeIntegerProtectionSetting( 'maxPerMinute' )}, { From fc163edb3ee4b8fe7627f5eb11927d35d5936adf Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 7 Dec 2023 16:25:53 +0000 Subject: [PATCH 035/160] Remove status command from protections for now. We will replace them with protection specific command tables later. --- src/protections/JoinWaveShortCircuit.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx index 2eb1eeb8..114dc010 100644 --- a/src/protections/JoinWaveShortCircuit.tsx +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -25,11 +25,10 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomMembershipRevision, SafeIntegerProtectionSetting, StandardProtectionSettings, StringRoomID, describeProtection, isError } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, ConsequenceProvider, 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"; -import { DocumentNode } from "../commands/interface-manager/DeadDocument"; const log = new Logger('JoinWaveShortCircuitProtection'); @@ -139,6 +138,11 @@ export class JoinWaveShortCircuitProtection extends AbstractProtection implement 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"); @@ -168,4 +172,5 @@ export class JoinWaveShortCircuitProtection extends AbstractProtection implement text, } } + */ } From f1d98b579ab46fd590ed3fa1c6bb263557222f83 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 8 Dec 2023 13:49:06 +0000 Subject: [PATCH 036/160] Port joinOnInvite listener from Mjolnir to Draupnir. --- src/Draupnir.ts | 73 ++++++++++++++++++++++++++++++++++++++++-- src/DraupnirBotMode.ts | 17 +++++++--- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index b48e2036..6f23a5ad 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { DefaultEventDecoder, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, serverName, userLocalpart } from "matrix-protection-suite"; +import { DefaultEventDecoder, Logger, MatrixRoomID, MatrixRoomReference, Membership, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; @@ -33,11 +33,13 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter, SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter, SynapseAdminClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; import { makeProtectedRoomsSet } from "./DraupnirBotMode"; import { makeStandardConsequenceProvider, renderProtectionFailedToStart } from "./StandardConsequenceProvider"; +import { htmlEscape } from "./utils"; +import { LogLevel } from "matrix-bot-sdk"; const log = new Logger('Draupnir'); @@ -174,6 +176,73 @@ export class Draupnir { Task(handleCommand(roomID, event, commandBeingRun, this, this.commandTable).then((_) => Ok(undefined))); } }); + this.matrixEmitter.on("room.event", this.handleEvent.bind(this)) + this.addJoinOnInviteListener(); + } + + /** + * 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 + * @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 addJoinOnInviteListener() { + this.matrixEmitter.on("room.invite", async (roomID, inviteEvent) => { + 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 + } + } + return this.client.joinRoom(roomID); + }); } public async start(): Promise { diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 1d462e7e..0ae82911 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -47,6 +47,8 @@ import { ProtectedRoomsSet, MatrixRoomReference, isStringUserID, + isStringRoomAlias, + isStringRoomID, } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, @@ -55,9 +57,9 @@ import { BotSDKMjolnirWatchedPolicyRoomsStore, ManagerManager, MatrixSendClient, - SafeMatrixEmitter + SafeMatrixEmitter, + resolveRoomReferenceSafe } from 'matrix-protection-suite-for-matrix-bot-sdk'; -import { makeStandardConsequenceProvider, renderProtectionFailedToStart } from "./StandardConsequenceProvider"; import { IConfig } from "./config"; import { Draupnir } from "./Draupnir"; @@ -173,12 +175,19 @@ export async function makeDraupnirBotModeFromConfig( if (!isStringUserID(clientUserId)) { throw new TypeError(`${clientUserId} is not a valid mxid`); } - const managementRoom = await MatrixRoomReference.fromRoomIdOrAlias(config.managementRoom).resolve(client as unknown as { resolveRoom: ResolveRoom }); + 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; + } return await Draupnir.makeDraupnirBot( client, matrixEmitter, clientUserId, - managementRoom, + managementRoom.ok, config ); } From 87c09d8719dbff1ffb3573afd96c3c615c3ab140 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 14:25:57 +0000 Subject: [PATCH 037/160] Correctly configure package.json for MPS. This has taken forever to get working, but now it is. index.ts is changed because that's how we know we've overriden the bot-sdk dep correctly. --- package.json | 9 +- src/Mjolnir.ts | 501 ------------------------------------------------- src/index.ts | 9 +- yarn.lock | 26 ++- 4 files changed, 29 insertions(+), 516 deletions(-) delete mode 100644 src/Mjolnir.ts diff --git a/package.json b/package.json index a2a36cfd..eba1fe3a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dependencies": { "@sentry/node": "^7.17.2", "@sentry/tracing": "^7.17.2", + "@sinclair/typebox": "~0.31.15", "await-lock": "^2.2.2", "body-parser": "^1.20.2", "config": "^3.3.9", @@ -59,14 +60,18 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "link:/home/user/experiments/matrix-protection-suite", - "matrix-protection-suite-for-matrix-bot-sdk": "link:/home/user/experiments/matrix-protection-suite-for-matrix-bot-sdk", + "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git#0.8.0", + "matrix-protection-suite-for-matrix-bot-sdk": "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#2df8b462442a42c975f7932d17a08c3aea23604b", "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/Mjolnir.ts b/src/Mjolnir.ts deleted file mode 100644 index 4ad94e4e..00000000 --- a/src/Mjolnir.ts +++ /dev/null @@ -1,501 +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 { 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 { ThrottlingQueue } from "./queues/ThrottlingQueue"; -import { getDefaultConfig, IConfig } from "./config"; -import ManagementRoomOutput from "./ManagementRoomOutput"; -import { ProtectionManager } from "./protections/ProtectionManager"; -import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; -import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { ProtectedRoomsSet } from "matrix-protection-suite"; - -export const STATE_NOT_STARTED = "not_started"; -export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; -export const STATE_SYNCING = "syncing"; -export const STATE_RUNNING = "running"; - -export class Mjolnir { - private displayName: string; - private localpart: string; - private currentState: string = STATE_NOT_STARTED; - /** - * 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 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 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 mjolnir = new Mjolnir(client, await client.getUserId(), matrixEmitter, managementRoomId, config); - 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, - private readonly protectedRoomsSet: ProtectedRoomsSet, - ) { - 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); - 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/index.ts b/src/index.ts index 607b8221..090c0c82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,8 +40,11 @@ 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 { makeDraupnirBotModeFromConfig } from "./DraupnirBotMode"; +import { Draupnir } from "./Draupnir"; +import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEventDecoder } from "matrix-protection-suite"; (async function () { @@ -64,7 +67,7 @@ import { initializeSentry, patchMatrixClient } from "./utils"; healthz.listen(); } - let bot: Mjolnir | null = null; + let bot: Draupnir | 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")); @@ -86,7 +89,7 @@ import { initializeSentry, patchMatrixClient } from "./utils"; patchMatrixClient(); config.RUNTIME.client = client; - bot = await Mjolnir.setupMjolnirFromConfig(client, client, config); + bot = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); } catch (err) { console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`); throw err; diff --git a/yarn.lock b/yarn.lock index 221585e7..2eebca44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -211,10 +211,10 @@ 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.21" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.31.21.tgz#d52d8e35f71e5651042aa0237e918e4b21fbbbf8" - integrity sha512-Wtq/K44EMkREaXytK+2c5DrygtYsH7ZxT0StQL8HMJz2BoOM7NZ/xfrUFBVuZxDrhJCoXf5Im282P2CCz5DHwQ== +"@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/body-parser@*": version "1.19.2" @@ -2319,13 +2319,19 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@link:/home/user/experiments/matrix-protection-suite-for-matrix-bot-sdk": - version "0.0.0" - uid "" +"matrix-protection-suite-for-matrix-bot-sdk@git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#2df8b462442a42c975f7932d17a08c3aea23604b": + version "0.8.0" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#2df8b462442a42c975f7932d17a08c3aea23604b" -"matrix-protection-suite@link:../../experiments/matrix-protection-suite": - version "0.0.0" - uid "" +"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git#0.8.0": + version "0.8.0" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#e67a5fcbba9565acad7da43fb84c4a4c0f321466" + 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" From 2fd2745dc35ea586943beddfdcc80fe63ed145be Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 14:43:54 +0000 Subject: [PATCH 038/160] Update ManagementRoomOutput for MPS. --- src/Draupnir.ts | 3 +++ src/ManagementRoomOutput.ts | 17 +++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 6f23a5ad..eacd0110 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -86,6 +86,9 @@ export class Draupnir { 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.setupMatrixEmitterListeners(); this.reportManager = new ReportManager(this); 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); From 0d39ab3b991a9581bc931fab5fb52565e0c39e14 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 17:31:44 +0000 Subject: [PATCH 039/160] Update rooms commands to MPS. --- src/commands/Rooms.tsx | 65 +++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/commands/Rooms.tsx b/src/commands/Rooms.tsx index 170181f1..2e4ed6af 100644 --- a/src/commands/Rooms.tsx +++ b/src/commands/Rooms.tsx @@ -28,32 +28,30 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters } from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; -import { MatrixRoomID, MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { CommandResult } from "./interface-manager/Validation"; -import { CommandException, CommandExceptionKind } from "./interface-manager/CommandException"; 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: DraupnirContext, _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,15 @@ defineInterfaceCommand({ description: 'The room to protect.' } ]), - command: async function (this: DraupnirContext, _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 room = await resolveRoomReferenceSafe(this.client, roomRef); + if (isError(room)) { + return room.addContext( + `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 +105,27 @@ defineInterfaceCommand({ description: 'The room to stop protecting.' } ]), - command: async function (this: DraupnirContext, _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.addContext( + `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); }, }) From 072ee49e31f2f1191f1fe7aa98681cdaf2ec3ea7 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 17:52:54 +0000 Subject: [PATCH 040/160] Update Rules command for MPS. --- src/commands/Rules.tsx | 66 +++++++++++++++++----------------- src/commands/StatusCommand.tsx | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index 8f2916b1..64822a8d 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -25,31 +25,29 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirContext } from "./CommandHandler"; import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { 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 { PolicyRule } from "matrix-protection-suite"; +import { ActionResult, MatrixRoomID, MatrixRoomReference, Ok, PolicyRoomWatchProfile, PolicyRule, StringRoomID, isError } 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( @@ -57,29 +55,30 @@ 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.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 { - roomRef: string, - roomId: string, + room: MatrixRoomID, + roomID: StringRoomID, + profile: PolicyRoomWatchProfile, matches: PolicyRule[] } @@ -87,17 +86,17 @@ defineInterfaceCommand({ designator: ["rules"], table: "mjolnir", parameters: parameters([]), - command: async function (this: DraupnirContext) { - 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,15 +122,16 @@ defineInterfaceCommand({ ]), command: async function ( this: DraupnirContext, _keywords, entity: string|UserID|MatrixRoomReference - ): Promise> { - return CommandResult.Ok( - this.mjolnir.policyListManager.lists - .map(list => { + ): 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/StatusCommand.tsx b/src/commands/StatusCommand.tsx index 74fc9eb7..fcb7002e 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -60,7 +60,7 @@ export interface StatusInfo { repository: string } -async function listInfo(draupnir: Draupnir): Promise { +export async function listInfo(draupnir: Draupnir): Promise { const watchedListProfiles = draupnir.protectedRoomsSet.issuerManager.allWatchedLists; const issuerResults = await Promise.all(watchedListProfiles.map((profile) => draupnir.managerManager.policyRoomManager.getPolicyRoomRevisionIssuer(profile.room) From d175f1d7f4f1ba503a66d4ba5fa7e6e78c82d73c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 18:13:59 +0000 Subject: [PATCH 041/160] Update Unban command for MPS. --- src/commands/Unban.ts | 101 ++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 54 deletions(-) diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index fb7ce2fc..61b8ebce 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -26,85 +26,77 @@ limitations under the License. */ import { DraupnirContext } from "./CommandHandler"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; 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, 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: 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.managerManager.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()); + this.draupnir.unlistedUserRedactionQueue.removeUser(entity.toString()); 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 +115,13 @@ defineInterfaceCommand({ name: "list", acceptor: union( findPresentationType("MatrixRoomReference"), - findPresentationType("string"), - findPresentationType("PolicyList"), ), prompt: async function (this: DraupnirContext) { return { - suggestions: this.mjolnir.policyListManager.lists + suggestions: this.draupnir.managerManager.policyRoomManager.getEditablePolicyRoomIDs( + this.draupnir.clientUserID, + PolicyRuleType.User + ) }; } }, From 48351fc3fe6c185bdc868d620abe97c0978a8c80 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 18:18:17 +0000 Subject: [PATCH 042/160] Update watch/unwatch commands for MPS. --- src/commands/WatchUnwatchCommand.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/commands/WatchUnwatchCommand.ts b/src/commands/WatchUnwatchCommand.ts index 145f90ea..a4affbc5 100644 --- a/src/commands/WatchUnwatchCommand.ts +++ b/src/commands/WatchUnwatchCommand.ts @@ -28,10 +28,10 @@ limitations under the License. import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, parameters, ParsedKeywords } from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; -import { MatrixRoomReference } from "./interface-manager/MatrixRoomReference"; -import { CommandError, CommandResult } from "./interface-manager/Validation"; 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: DraupnirContext, _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: DraupnirContext, _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); }, }) From 4110b62a8a39c19a7375b4f7b9328cd739730508 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 9 Dec 2023 18:20:01 +0000 Subject: [PATCH 043/160] Decide we're not going to do status protection command yet. --- src/commands/StatusCommand.tsx | 49 ++-------------------------------- 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index fcb7002e..57156bae 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -27,11 +27,10 @@ limitations under the License. import { PACKAGE_JSON, SOFTWARE_VERSION } from "../config"; import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; -import { findPresentationType, parameters, RestDescription } from "./interface-manager/ParameterParsing"; +import { parameters } from "./interface-manager/ParameterParsing"; import { DraupnirContext } from "./CommandHandler"; -import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; +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"; @@ -130,47 +129,3 @@ defineMatrixInterfaceAdaptor({ client); } }); - -defineInterfaceCommand({ - designator: ["status", "protection"], - table: "mjolnir", - parameters: parameters([ - { - name: "protection name", - acceptor: findPresentationType("string") - }, - ], - new RestDescription( - "subcommand", - findPresentationType("any") - )), - command: async function ( - this: DraupnirContext, _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); - } -}) From b06dcdc8bd0b6f38169b7df42bba01663f77685d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 10 Dec 2023 09:59:42 +0000 Subject: [PATCH 044/160] Update protections for new consequence provider descriptions --- src/Draupnir.ts | 3 +-- src/StandardConsequenceProvider.tsx | 34 ++++++++++++++++-------- src/protections/BanPropagation.tsx | 4 +-- src/protections/BasicFlooding.ts | 4 +-- src/protections/FirstMessageIsImage.ts | 4 +-- src/protections/JoinWaveShortCircuit.tsx | 4 +-- src/protections/MessageIsMedia.ts | 4 +-- src/protections/MessageIsVoice.ts | 4 +-- src/protections/TrustedReporters.ts | 4 +-- src/protections/WordList.ts | 4 +-- 10 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index eacd0110..cd41f042 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -37,7 +37,7 @@ import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitte import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; import { makeProtectedRoomsSet } from "./DraupnirBotMode"; -import { makeStandardConsequenceProvider, renderProtectionFailedToStart } from "./StandardConsequenceProvider"; +import { renderProtectionFailedToStart } from "./StandardConsequenceProvider"; import { htmlEscape } from "./utils"; import { LogLevel } from "matrix-bot-sdk"; @@ -130,7 +130,6 @@ export class Draupnir { ) ); const loadResult = await protectedRoomsSet.protections.loadProtections( - makeStandardConsequenceProvider(client, draupnir.managementRoomID), protectedRoomsSet, draupnir, (error, description) => renderProtectionFailedToStart( diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx index 18ef0f89..c81be7df 100644 --- a/src/StandardConsequenceProvider.tsx +++ b/src/StandardConsequenceProvider.tsx @@ -25,12 +25,13 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionException, ActionExceptionKind, ActionResult, ConsequenceProvider, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, RoomUpdateError, RoomUpdateException, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership, isError } from "matrix-protection-suite"; +import { ActionError, ActionException, ActionExceptionKind, ActionResult, BasicConsequenceProvider, DEFAULT_CONSEQUENCE_PROVIDER, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, RoomUpdateError, RoomUpdateException, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership, describeConsequenceProvider, isError } 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"; import { DocumentNode } from "./commands/interface-manager/DeadDocument"; import { printActionResult } from "./models/RoomUpdateError"; +import { Draupnir } from "./Draupnir"; interface ProviderContext { client: MatrixSendClient; @@ -49,7 +50,7 @@ async function renderConsequenceForEvent(client: MatrixSendClient, managementRoo return Ok(undefined); } -const consequenceForEvent: ConsequenceProvider['consequenceForEvent'] = async function( +const consequenceForEvent: BasicConsequenceProvider['consequenceForEvent'] = async function( this: ProviderContext, protection, roomID, eventID, reason ): Promise> { Task(renderConsequenceForEvent(this.client, this.managementRoomID, protection, roomID, eventID, reason)) @@ -86,7 +87,7 @@ function banUser(client: MatrixSendClient, protection: ProtectionDescriptionInfo ) } -const consequenceForUserInRoom: ConsequenceProvider['consequenceForUserInRoom'] = async function( +const consequenceForUserInRoom: BasicConsequenceProvider['consequenceForUserInRoom'] = async function( this: ProviderContext, protection, roomID, userID, reason ): Promise> { Task(renderConsequenceForUserInRoom(this.client, this.managementRoomID, protection, roomID, userID, reason)); @@ -109,7 +110,7 @@ function renderSetMembershipBans(title: DocumentNode, map: SetMemberBanResultMap
    } -const consequenceForUsersInRevision: ConsequenceProvider['consequenceForUsersInRevision'] = async function( +const consequenceForUsersInRevision: BasicConsequenceProvider['consequenceForUsersInRevision'] = async function( this: ProviderContext, description, setMembership, revision ) { const results = await applyPolicyRevisionToSetMembership( @@ -130,14 +131,14 @@ const consequenceForUsersInRevision: ConsequenceProvider['consequenceForUsersInR return Ok(undefined); } -const consequenceForServerACL: ConsequenceProvider['consequenceForServerACL'] = async function( +const consequenceForServerACL: BasicConsequenceProvider['consequenceForServerACL'] = async function( this: ProviderContext, aclContent ): Promise> { // nothing to do return Ok(undefined) } -const consequenceForServerACLInRoom: ConsequenceProvider['consequenceForServerACLInRoom'] = async function( +const consequenceForServerACLInRoom: BasicConsequenceProvider['consequenceForServerACLInRoom'] = async function( this: ProviderContext, _protection, roomID, aclContent ): Promise> { return this.client.sendStateEvent(roomID, 'm.room.server_acl', '', aclContent).then( @@ -149,12 +150,12 @@ const consequenceForServerACLInRoom: ConsequenceProvider['consequenceForServerAC ) } -const consequenceForServerInRoom: ConsequenceProvider['consequenceForServerInRoom'] = async function( +const consequenceForServerInRoom: BasicConsequenceProvider['consequenceForServerInRoom'] = async function( ) { return Ok(undefined); } -const unbanUserFromRoomsInSet: ConsequenceProvider['unbanUserFromRoomsInSet'] = async function( +const unbanUserFromRoomsInSet: BasicConsequenceProvider['unbanUserFromRoomsInSet'] = async function( this: ProviderContext, _protection, userID, protectedRoomsSet ): Promise> { const errors: RoomUpdateError[] = []; @@ -181,10 +182,10 @@ const unbanUserFromRoomsInSet: ConsequenceProvider['unbanUserFromRoomsInSet'] = return Ok(undefined); } -export function makeStandardConsequenceProvider( +export function makeStandardBasicConsequenceProvider( client: MatrixSendClient, managementRoomID: StringRoomID -): ConsequenceProvider { +): BasicConsequenceProvider { return { consequenceForEvent, consequenceForServerACL, @@ -195,9 +196,20 @@ export function makeStandardConsequenceProvider( unbanUserFromRoomsInSet, client, managementRoomID - } as unknown as ConsequenceProvider; + } as unknown as BasicConsequenceProvider; } +describeConsequenceProvider({ + name: DEFAULT_CONSEQUENCE_PROVIDER, + description: 'Does what it says on the tin', + factory: function(draupnir) { + return makeStandardBasicConsequenceProvider( + draupnir.client, + draupnir.managementRoomID + ) + } +}) + export async function renderProtectionFailedToStart( client: MatrixSendClient, managementRoomID: StringRoomID, diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 451c780b..2fc0a130 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -34,7 +34,7 @@ import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRende import { UserID } from "matrix-bot-sdk"; import { renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; -import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, ConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirProtection } from "./Protection"; @@ -138,7 +138,7 @@ export class BanPropagationProtection extends AbstractProtection implements Drau constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index a08a5003..e74ec6a9 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -28,7 +28,7 @@ limitations under the License. import { Draupnir } from "../Draupnir"; import { DraupnirProtection } from "./Protection"; import { LogLevel } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringRoomID, StringUserID, describeProtection, isError } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringRoomID, StringUserID, describeProtection, isError } from "matrix-protection-suite"; const log = new Logger('BasicFloodingProtection'); @@ -78,7 +78,7 @@ export class BasicFloodingProtection extends AbstractProtection implements Draup public constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, private readonly settings: BasicFloodingProtectionSettings, diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index c1f1cb96..65922dde 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -26,7 +26,7 @@ limitations under the License. */ import { LogLevel, LogService } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, ConsequenceProvider, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; type FirstMessageIsImageProtectionSettings = {} @@ -54,7 +54,7 @@ export class FirstMessageIsImageProtection extends AbstractProtection implements constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx index 114dc010..15110dae 100644 --- a/src/protections/JoinWaveShortCircuit.tsx +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, ProtectionDescription, RoomMembershipRevision, SafeIntegerProtectionSetting, StandardProtectionSettings, StringRoomID, describeProtection, isError } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, 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"; @@ -85,7 +85,7 @@ export class JoinWaveShortCircuitProtection extends AbstractProtection implement constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, public readonly settings: JoinWaveShortCircuitProtectionSettings diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 7ba5e9b4..d7627d95 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -26,7 +26,7 @@ limitations under the License. */ import { LogLevel } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, ConsequenceProvider, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; type MessageIsMediaProtectionSettings = {}; @@ -49,7 +49,7 @@ describeProtection({ export class MessageIsMediaProtection extends AbstractProtection implements Protection { constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir ) { diff --git a/src/protections/MessageIsVoice.ts b/src/protections/MessageIsVoice.ts index e8ae1dc8..30abdfee 100644 --- a/src/protections/MessageIsVoice.ts +++ b/src/protections/MessageIsVoice.ts @@ -26,7 +26,7 @@ limitations under the License. */ import { LogLevel} from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, ConsequenceProvider, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; describeProtection({ @@ -47,7 +47,7 @@ describeProtection({ export class MessageIsVoiceProtection extends AbstractProtection implements Protection { constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 816a459f..73a16a44 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, ConsequenceProvider, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringUserID, StringUserIDSetProtectionSettings, describeProtection, isError } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringUserID, StringUserIDSetProtectionSettings, describeProtection, isError } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; const MAX_REPORTED_EVENT_BACKLOG = 20; @@ -81,7 +81,7 @@ export class TrustedReporters extends AbstractProtection implements Protection public constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, public readonly settings: TrustedReportersProtectionSettings diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index ca9f3bc5..f3dbc778 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, ConsequenceProvider, Logger, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; const log = new Logger('WordList'); @@ -52,7 +52,7 @@ export class WordListProtection extends AbstractProtection implements Protection constructor( description: ProtectionDescription, - consequenceProvider: ConsequenceProvider, + consequenceProvider: BasicConsequenceProvider, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { From 3798883466908fdbc25929ce06f691580b6e5e1d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 10 Dec 2023 10:23:19 +0000 Subject: [PATCH 045/160] Update BanPropagationProtection for updated Rules command. --- src/commands/Rules.tsx | 2 +- src/protections/BanPropagation.tsx | 37 +++++++++++++++++++----------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index 64822a8d..dc0ee527 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -75,7 +75,7 @@ export function renderListRules(list: ListMatches) { } -interface ListMatches { +export interface ListMatches { room: MatrixRoomID, roomID: StringRoomID, profile: PolicyRoomWatchProfile, diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 2fc0a130..b61080dc 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -32,12 +32,13 @@ import { JSXFactory } from "../commands/interface-manager/JSXFactory"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; import { UserID } from "matrix-bot-sdk"; -import { renderListRules } from "../commands/Rules"; +import { ListMatches, renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, ConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } 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"; const log = new Logger('BanPropagationProtection'); @@ -98,7 +99,7 @@ async function promptUnbanPropagation( draupnir: Draupnir, event: any, roomId: string, - rulesMatchingUser: Map + 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? @@ -109,12 +110,7 @@ async function promptUnbanPropagation( However there are rules in Draupnir's watched lists matching this user:
      { - [...rulesMatchingUser.entries()] - .map(([list, rules]) =>
    • {renderListRules({ - roomRef: draupnir.createRoomReference(list).toPermalink(), - roomId: list, - matches: rules - })}
    • ) + rulesMatchingUser.map(match =>
    • {renderListRules(match)}
    • ) }
    Would you like to remove these rules and unban the user from all protected rooms? @@ -156,7 +152,7 @@ export class BanPropagationProtection extends AbstractProtection implements Drau this.handleBan(ban); } for (const unban of unbans) { - this.handleUnban(unban); + Task(this.handleUnban(unban, this.draupnir)); } return Ok(undefined); } @@ -170,9 +166,10 @@ export class BanPropagationProtection extends AbstractProtection implements Drau Task(promptBanPropagation(this.draupnir, change)); } - private handleUnban(change: MembershipChange): void { + 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. } @@ -182,12 +179,24 @@ export class BanPropagationProtection extends AbstractProtection implements Drau entry.push(rule); return map; } - Task(promptUnbanPropagation( + const rulesByPolicyRoom = rulesMatchingUser.reduce((map, rule) => addRule(map, rule), new Map()); + await promptUnbanPropagation( this.draupnir, change, change.roomID, - rulesMatchingUser.reduce((map, rule) => addRule(map, rule), new Map()) - )); + [...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 + } + }) + ); } private async banReactionListener(key: string, item: unknown, context: BanPropagationMessageContext) { From a6b2179b010965af61b285009c7cc4c6fc4ef344 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 14:42:25 +0000 Subject: [PATCH 046/160] Update Protections Commands for MPS. Values will not be parsed as JSON yet, needs fixing. --- src/commands/ProtectionsCommands.ts | 232 ---------------- src/commands/ProtectionsCommands.tsx | 391 +++++++++++++++++++++++++++ 2 files changed, 391 insertions(+), 232 deletions(-) delete mode 100644 src/commands/ProtectionsCommands.ts create mode 100644 src/commands/ProtectionsCommands.tsx 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..25eb837c --- /dev/null +++ b/src/commands/ProtectionsCommands.tsx @@ -0,0 +1,391 @@ +/** + * 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, findConsequenceProvider, 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 consequenceProviderName = keywords.getKeyword("consequence-provider"); + const consequenceProviderDescription = consequenceProviderName !== undefined + ? Ok(findConsequenceProvider(consequenceProviderName)) + : await this.draupnir.protectedRoomsSet.protections.getConsequenceProviderDescriptionForProtection(protectionDescription); + if (isError(consequenceProviderDescription) || consequenceProviderDescription.ok === undefined) { + return ActionError.Result(`Couldn't find a consequence provider named ${consequenceProviderName}`); + } + return await this.draupnir.protectedRoomsSet.protections.addProtection( + protectionDescription, + consequenceProviderDescription.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", 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} +
    • ) + )} +
    +
    +} From 9b48a48c448b286ff8252336ba55e504c4cf6899 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 15:35:17 +0000 Subject: [PATCH 047/160] UNFINISHED: start moving permission check command to MPS. --- src/commands/PermissionCheckCommand.ts | 33 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/commands/PermissionCheckCommand.ts b/src/commands/PermissionCheckCommand.ts index 765c2d85..98ec99dd 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -25,9 +25,32 @@ 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? + return ActionError.Result(`Unimplemented`); + }, + summary: "Verify the permissions that draupnir has." +}) + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "verify"), + renderer: tickCrossRenderer +}) From a17625ac8f4ae9a8e7d482c6cd97d1792c17c2ae Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 15:39:37 +0000 Subject: [PATCH 048/160] Update CommandHandler and Help for migrated commands. --- src/commands/CommandHandler.ts | 62 ++++++++-------------------------- src/commands/Help.tsx | 38 ++++----------------- 2 files changed, 21 insertions(+), 79 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f8bec264..9b54c10f 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -26,7 +26,6 @@ limitations under the License. */ import { LogService, RichReply } from "matrix-bot-sdk"; -import { parse as tokenize } from "shell-quote"; import { readCommand } from "./interface-manager/CommandReader"; import { BaseFunction, CommandTable, defineCommandTable, findCommandTable, findTableCommand } from "./interface-manager/InterfaceCommand"; import { findMatrixInterfaceAdaptor, MatrixContext } from "./interface-manager/MatrixInterfaceAdaptor"; @@ -42,21 +41,30 @@ export interface DraupnirContext extends MatrixContext { 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 = "!draupnir"; @@ -68,46 +76,6 @@ export async function handleCommand( commandTable: CommandTable ) { try { - /** - * TODO Delete these: - - if (parts[1] === 'joins') { - return await showJoinsStatus(roomID, event, mjolnir, parts.slice(2)); // joins - } 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); - */ const readItems = readCommand(normalisedCommand).slice(1); // remove "!mjolnir" const stream = new ArgumentStream(readItems); const command = commandTable.findAMatchingCommand(stream) 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( From 11839e346b21cdbc49427b9bd4e2c151ac6027fb Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 15:40:42 +0000 Subject: [PATCH 049/160] Remove Mjolnir's ProtectionManager (MPS has made it redundant). --- src/protections/ProtectionManager.ts | 456 --------------------------- 1 file changed, 456 deletions(-) delete mode 100644 src/protections/ProtectionManager.ts 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); - } - } -} From 6885b7bbaf1df18b4bcf6c9325030dc71a5c98bf Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 15:43:18 +0000 Subject: [PATCH 050/160] cleanup unused imports in MatrixInterfaceAdaptar. --- src/commands/interface-manager/MatrixInterfaceAdaptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 443f0481..7931ab76 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -29,7 +29,7 @@ limitations under the License. * I'd like to remove the dependency on matrix-bot-sdk. */ -import { LogService, MatrixClient } from "matrix-bot-sdk"; +import { LogService } from "matrix-bot-sdk"; import { ReadItem } from "./CommandReader"; import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; import { tickCrossRenderer } from "./MatrixHelpRenderer"; From d3618ea814841b50e48fdf6b5c752c24c4895db4 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 15:47:27 +0000 Subject: [PATCH 051/160] fix utils.ts for MPS. --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 5ec7c71a..3f83a884 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. From 2225a9580fa6806fcb67843d3e7d69b363aababb Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 16:10:05 +0000 Subject: [PATCH 052/160] Update Queues for MPS. It's unclear if these are fit for purpose, plugged into the new Draupnir class or working. Needs investigating in a follow up. --- src/commands/Unban.ts | 6 ++- src/queues/EventRedactionQueue.ts | 47 +++++++++++------------- src/queues/ThrottlingQueue.ts | 6 +-- src/queues/UnlistedUserRedactionQueue.ts | 30 +++++++-------- 4 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index 61b8ebce..01d67a4f 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -32,7 +32,7 @@ import { defineInterfaceCommand, findTableCommand } from "./interface-manager/In import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { Draupnir } from "../Draupnir"; -import { ActionResult, isError, MatrixRoomReference, Ok, PolicyRuleType } from "matrix-protection-suite"; +import { ActionResult, isError, isStringUserID, MatrixRoomReference, Ok, PolicyRuleType } from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; async function unbanUserFromRooms(draupnir: Draupnir, rule: MatrixGlob) { @@ -88,7 +88,9 @@ async function unban( const rawEnttiy = typeof entity === 'string' ? entity : entity.toString(); const isGlob = (string: string) => string.includes('*') ? true : string.includes('?'); const rule = new MatrixGlob(entity.toString()) - this.draupnir.unlistedUserRedactionQueue.removeUser(entity.toString()); + if (isStringUserID(rawEnttiy)) { + this.draupnir.unlistedUserRedactionQueue.removeUser(rawEnttiy); + } if (!isGlob(rawEnttiy) || keywords.getKeyword("true", "false") === "true") { await unbanUserFromRooms(this.draupnir, rule); } else { 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/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); } } From 32778d91b3a57afec982bc583a1e2cf9840b9702 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 16:57:04 +0000 Subject: [PATCH 053/160] AppService: Update AccessControl for MPS. --- src/appservice/AccessControl.ts | 64 +++++++++++++++++---------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/appservice/AccessControl.ts b/src/appservice/AccessControl.ts index 4156a703..6f407457 100644 --- a/src/appservice/AccessControl.ts +++ b/src/appservice/AccessControl.ts @@ -26,10 +26,7 @@ limitations under the 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 } from "matrix-protection-suite"; /** * Utility to manage which users have access to the application service, @@ -39,9 +36,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 +48,43 @@ 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, + accessControlRoom: MatrixRoomID, + policyRoomManager: PolicyRoomManager, 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); + ): Promise> { + await bridge.getBot().getClient().joinRoom(accessControlRoom.toRoomIDOrAlias()); + 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); + } } } From 0526093ce3ad5329b43093cd86dcf8ebc1e9e28c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 17:03:49 +0000 Subject: [PATCH 054/160] AppService: Update AccessControl commands for MPS. And the updated AccessControl class which now also uses MPS. --- src/appservice/bot/AccessCommands.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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." }) From 9d4f140295b4b40d6854d01aa9e94ffebbe5ce8d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 17:05:49 +0000 Subject: [PATCH 055/160] Update AppserviceBotEmitter for MPS. --- src/appservice/bot/AppserviceBotEmitter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appservice/bot/AppserviceBotEmitter.ts b/src/appservice/bot/AppserviceBotEmitter.ts index f24070f8..cb83409f 100644 --- a/src/appservice/bot/AppserviceBotEmitter.ts +++ b/src/appservice/bot/AppserviceBotEmitter.ts @@ -4,7 +4,7 @@ */ import EventEmitter from "events"; -import { MatrixEmitter } from "../../MatrixEmitter"; +import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; // See https://github.com/Gnuxie/Draupnir/issues/13. From 719bde9e9479f23018cad8e59875838470725adf Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 17:11:25 +0000 Subject: [PATCH 056/160] AppService: update AppserviceCommandHandler for MPS. --- .../bot/AppserviceCommandHandler.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 887c53fd..5003b09f 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -9,8 +9,9 @@ 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 { AppserviceBotEmitter } from './AppserviceBotEmitter'; +import { ActionResult, Ok, RoomMessage, Value, isError } from 'matrix-protection-suite'; defineCommandTable("appservice bot"); @@ -18,18 +19,20 @@ 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" }) @@ -49,9 +52,14 @@ export class AppserviceCommandHandler { } public handleEvent(mxEvent: WeakEvent): void { - if (mxEvent.type !== 'm.room.message' && mxEvent.room_id !== this.appservice.config.adminRoom) { + if (mxEvent.room_id !== this.appservice.config.adminRoom) { + 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" @@ -62,7 +70,7 @@ export class AppserviceCommandHandler { const context: AppserviceContext = { appservice: this.appservice, roomId: mxEvent.room_id, - event: mxEvent, + event: parsedEvent, client: this.appservice.bridge.getBot().getClient(), emitter: new AppserviceBotEmitter(), }; From ab25f080487b86a74421bfb2c9f91f2594685dfb Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 17:15:29 +0000 Subject: [PATCH 057/160] AppService: update list command for MPS. --- src/appservice/bot/ListCommand.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/appservice/bot/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index deae22d0..2cc46067 100644 --- a/src/appservice/bot/ListCommand.tsx +++ b/src/appservice/bot/ListCommand.tsx @@ -4,16 +4,16 @@ */ 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 { AppserviceBaseExecutor } from './AppserviceCommandHandler'; import { UserID } from 'matrix-bot-sdk'; -import { CommandError, CommandResult } from '../../commands/interface-manager/Validation'; 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 } from 'matrix-protection-suite'; +import { MatrixSendClient } from 'matrix-protection-suite-for-matrix-bot-sdk'; /** * There is ovbiously something we're doing very wrong here, @@ -29,7 +29,7 @@ const listUnstarted = defineInterfaceCommand({ table: "appservice bot", parameters: parameters([]), command: async function () { - return CommandResult.Ok(this.appservice.mjolnirManager.getUnstartedMjolnirs()); + return Ok(this.appservice.mjolnirManager.getUnstartedMjolnirs()); }, summary: "List any Mjolnir that failed to start." }); @@ -38,9 +38,9 @@ const listUnstarted = defineInterfaceCommand({ // 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; @@ -80,14 +80,14 @@ const restart = defineInterfaceCommand({ description: 'The userid of the mjolnir to restart' } ]), - command: async function (this: AppserviceContext, _keywords, mjolnirId: UserID): Promise> { + command: async function (this, _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?`); + return ActionError.Result(`We can't find the unstarted mjolnir ${mjolnirId}, is it running?`); } await mjolnirManager.startMjolnir(mjolnir?.mjolnirRecord); - return CommandResult.Ok(true); + return Ok(true); }, summary: "Attempt to restart a Mjolnir." }) From 7ccfbadd5e36504308977233720bb98ec0aa5855 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 11 Dec 2023 18:06:12 +0000 Subject: [PATCH 058/160] Update DraupnirBotMode for new MPS SetRoomState. --- src/DraupnirBotMode.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 0ae82911..1ccd83e5 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -49,6 +49,8 @@ import { isStringUserID, isStringRoomAlias, isStringRoomID, + SetRoomState, + StandardSetRoomState, } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, @@ -116,6 +118,20 @@ async function makeSetMembership( return membershipSet.ok; } +async function makeSetRoomState( + roomStateManager: RoomStateManager, + protectedRoomsConfig: ProtectedRoomsConfig +): Promise { + const setRoomState = await StandardSetRoomState.create( + roomStateManager, + protectedRoomsConfig + ); + if (isError(setRoomState)) { + throw setRoomState.error; + } + return setRoomState.ok; +} + async function makeProtectionConfig( client: MatrixSendClient, roomStateManager: RoomStateManager, @@ -148,6 +164,10 @@ export async function makeProtectedRoomsSet( userID: StringUserID ): Promise { const protectedRoomsConfig = await makeProtectedRoomsConfig(client) + const setRoomState = await makeSetRoomState( + managerManager.roomStateManager, + protectedRoomsConfig + ); const membershipSet = await makeSetMembership( managerManager.roomMembershipManager, protectedRoomsConfig @@ -161,6 +181,7 @@ export async function makeProtectedRoomsSet( managementRoom ), membershipSet, + setRoomState, userID, ); return protectedRoomsSet; From 0a4b50634b238dacba376eb22d59adcd33c14572 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 30 Dec 2023 21:51:11 +0000 Subject: [PATCH 059/160] Attempting to create DraupnirFactory. --- src/Draupnir.ts | 111 ++++++------- src/DraupnirBotMode.ts | 114 ------------- src/draupnirfactory/DraupnirClientRooms.ts | 67 ++++++++ src/draupnirfactory/DraupnirFactory.ts | 89 ++++++++++ .../DraupnirProtectedRoomsSet.ts | 155 ++++++++++++++++++ 5 files changed, 363 insertions(+), 173 deletions(-) create mode 100644 src/draupnirfactory/DraupnirClientRooms.ts create mode 100644 src/draupnirfactory/DraupnirFactory.ts create mode 100644 src/draupnirfactory/DraupnirProtectedRoomsSet.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts index cd41f042..8ba7bb49 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { DefaultEventDecoder, Logger, MatrixRoomID, MatrixRoomReference, Membership, Ok, ProtectedRoomsSet, RoomEvent, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite"; +import { Client, 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"; @@ -33,10 +33,9 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { DefaultStateTrackingMeta, ManagerManager, ManagerManagerForMatrixEmitter, MatrixSendClient, SafeMatrixEmitter, SynapseAdminClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MatrixSendClient, SynapseAdminClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; -import { makeProtectedRoomsSet } from "./DraupnirBotMode"; import { renderProtectionFailedToStart } from "./StandardConsequenceProvider"; import { htmlEscape } from "./utils"; import { LogLevel } from "matrix-bot-sdk"; @@ -49,7 +48,7 @@ const log = new Logger('Draupnir'); // 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 { +export class Draupnir implements Client { private readonly displayName: string; /** * This is for users who are not listed on a watchlist, @@ -78,11 +77,12 @@ export class Draupnir { private constructor( public readonly client: MatrixSendClient, public readonly clientUserID: StringUserID, - public readonly matrixEmitter: SafeMatrixEmitter, public readonly managementRoom: MatrixRoomID, public readonly config: IConfig, public readonly protectedRoomsSet: ProtectedRoomsSet, - public readonly managerManager: ManagerManager, + public readonly roomStateManager: RoomStateManager, + public readonly policyRoomManager: PolicyRoomManager, + public readonly roomMembershipManager: RoomMembershipManager, public readonly synapseAdminClient?: SynapseAdminClient ) { this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); @@ -90,7 +90,6 @@ export class Draupnir { this.managementRoomID, this.clientUserID, this.client, this.config ); this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID); - this.setupMatrixEmitterListeners(); this.reportManager = new ReportManager(this); if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); @@ -99,31 +98,23 @@ export class Draupnir { public static async makeDraupnirBot( client: MatrixSendClient, - matrixEmitter: SafeMatrixEmitter, clientUserID: StringUserID, managementRoom: MatrixRoomID, + protectedRoomsSet: ProtectedRoomsSet, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager, config: IConfig ): Promise { - const managerManager = new ManagerManagerForMatrixEmitter( - matrixEmitter, - DefaultStateTrackingMeta, - DefaultEventDecoder, - client - ); - const protectedRoomsSet = await makeProtectedRoomsSet( - managementRoom, - managerManager, - client, - clientUserID - ) const draupnir = new Draupnir( client, clientUserID, - matrixEmitter, managementRoom, config, protectedRoomsSet, - managerManager, + roomStateManager, + policyRoomManager, + roomMembershipManager, new SynapseAdminClient( client, clientUserID @@ -142,49 +133,47 @@ export class Draupnir { return draupnir; } - private handleEvent(roomID: StringRoomID, event: RoomEvent): void { - this.protectedRoomsSet.handleTimelineEvent(roomID, event); + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + Task(this.joinOnInviteListener(roomID, event)); + this.managementRoomMessageListener(roomID, event); } - private setupMatrixEmitterListeners(): void { - this.matrixEmitter.on("room.message", (roomID, event) => { - if (roomID !== this.managementRoom.toRoomIDOrAlias()) { + 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; } - if (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), - displayName: this.displayName, - userId: this.clientUserID, - additionalPrefixes: this.config.commands.additionalPrefixes, - allowNoPrefix: this.config.commands.allowNoPrefix, - } - ); - if (commandBeingRun === undefined) { - return; + const commandBeingRun = extractCommandFromMessageBody( + event.content.body, + { + prefix: COMMAND_PREFIX, + localpart: userLocalpart(this.clientUserID), + displayName: this.displayName, + userId: this.clientUserID, + additionalPrefixes: this.config.commands.additionalPrefixes, + allowNoPrefix: this.config.commands.allowNoPrefix, } - 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))); + ); + if (commandBeingRun === undefined) { + return; } - }); - this.matrixEmitter.on("room.event", this.handleEvent.bind(this)) - this.addJoinOnInviteListener(); + 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))); + } } /** * 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: 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. @@ -192,8 +181,9 @@ export class Draupnir { * @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 addJoinOnInviteListener() { - this.matrixEmitter.on("room.invite", async (roomID, inviteEvent) => { + 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 @@ -243,8 +233,8 @@ export class Draupnir { return reportInvite(); // ignore invite } } - return this.client.joinRoom(roomID); - }); + await this.client.joinRoom(roomID); + } } public async start(): Promise { @@ -263,4 +253,7 @@ export class Draupnir { [serverName(this.clientUserID)] ); } + public handleEventReport(report: EventReport): void { + this.protectedRoomsSet.handleEventReport(report); + } } diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 1ccd83e5..21ae357d 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -73,120 +73,6 @@ import { Draupnir } from "./Draupnir"; * almost completely modular and customizable. */ -async function makePolicyListConfig( - client: MatrixSendClient, - policyRoomManager: PolicyRoomManager -): Promise { - const result = await MjolnirPolicyRoomsConfig.createFromStore( - new BotSDKMjolnirWatchedPolicyRoomsStore( - client - ), - policyRoomManager, - client as unknown as { resolveRoom: ResolveRoom } - ); - if (isError(result)) { - throw result.error; - } - return result.ok; -} - -async function makeProtectedRoomsConfig( - client: MatrixSendClient, -): Promise { - const result = await MjolnirProtectedRoomsConfig.createFromStore( - new BotSDKMjolnirProtectedRoomsStore( - client - ) - ); - if (isError(result)) { - throw result.error; - } - return result.ok; -} - -async function makeSetMembership( - roomMembershipManager: RoomMembershipManager, - protectedRoomsConfig: ProtectedRoomsConfig -): Promise { - const membershipSet = await StandardSetMembership.create( - roomMembershipManager, - protectedRoomsConfig - ); - if (isError(membershipSet)) { - throw membershipSet.error; - } - return membershipSet.ok; -} - -async function makeSetRoomState( - roomStateManager: RoomStateManager, - protectedRoomsConfig: ProtectedRoomsConfig -): Promise { - const setRoomState = await StandardSetRoomState.create( - roomStateManager, - protectedRoomsConfig - ); - if (isError(setRoomState)) { - throw setRoomState.error; - } - return setRoomState.ok; -} - -async function makeProtectionConfig( - client: MatrixSendClient, - roomStateManager: RoomStateManager, - managementRoom: MatrixRoomID -) { - const result = await roomStateManager.getRoomStateRevisionIssuer( - managementRoom - ); - if (isError(result)) { - throw result.error; - } - return new MjolnirProtectionsConfig( - new BotSDKMatrixAccountData( - MjolnirEnabledProtectionsEventType, - MjolnirEnabledProtectionsEvent, - client - ), - new BotSDKMatrixStateData( - MjolnirProtectionSettingsEventType, - result.ok, - client - ) - ) -} - -export async function makeProtectedRoomsSet( - managementRoom: MatrixRoomID, - managerManager: ManagerManager, - client: MatrixSendClient, - userID: StringUserID -): Promise { - const protectedRoomsConfig = await makeProtectedRoomsConfig(client) - const setRoomState = await makeSetRoomState( - managerManager.roomStateManager, - protectedRoomsConfig - ); - const membershipSet = await makeSetMembership( - managerManager.roomMembershipManager, - protectedRoomsConfig - ); - const protectedRoomsSet = new StandardProtectedRoomsSet( - await makePolicyListConfig(client, managerManager.policyRoomManager), - protectedRoomsConfig, - await makeProtectionConfig( - client, - managerManager.roomStateManager, - managementRoom - ), - membershipSet, - setRoomState, - userID, - ); - return protectedRoomsSet; -} - export async function makeDraupnirBotModeFromConfig( client: MatrixSendClient, matrixEmitter: SafeMatrixEmitter, diff --git a/src/draupnirfactory/DraupnirClientRooms.ts b/src/draupnirfactory/DraupnirClientRooms.ts new file mode 100644 index 00000000..15c9c46f --- /dev/null +++ b/src/draupnirfactory/DraupnirClientRooms.ts @@ -0,0 +1,67 @@ +/** + * Copyright (C) 2023 Gnuxie + * All rights reserved. + */ + +import { ActionException, ActionExceptionKind, ActionResult, JoinedRoomsRevision, JoinedRoomsSafe, MatrixRoomID, Ok, PolicyRoomManager, RoomMembershipManager, RoomStateManager, StandardClientRooms, StandardJoinedRoomsRevision, StringUserID, isError } from "matrix-protection-suite"; +import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet"; +import { Draupnir } from "../Draupnir"; +import { IConfig } from "../config"; + +export class DraupnirClientRooms extends StandardClientRooms { + private constructor( + client: Draupnir, + joinedRoomsThunk: JoinedRoomsSafe, + clientUserID: StringUserID, + joinedRoomsRevision: JoinedRoomsRevision + ) { + super( + client, + joinedRoomsThunk, + clientUserID, + joinedRoomsRevision + ); + } + + public static async makeClientRooms( + client: Draupnir, + managementRoom: MatrixRoomID, + joinedRoomsThunk: JoinedRoomsSafe, + clientUserID: StringUserID, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager + ): Promise> { + try { + const joinedRooms = await joinedRoomsThunk(); + if (isError(joinedRooms)) { + return joinedRooms; + } + const revision = StandardJoinedRoomsRevision.blankRevision( + clientUserID + ).reviseFromJoinedRooms(joinedRooms.ok); + const protectedRoomsSet = await makeProtectedRoomsSet( + managementRoom, + roomStateManager, + policyRoomManager, + roomMembershipManager, + client.client, + clientUserID + ); + return Ok(new DraupnirClientRooms( + client, + joinedRoomsThunk, + clientUserID, + revision + )) + } catch (exception) { + return ActionException.Result( + `Couldn't create client rooms`, + { + exception, + exceptionKind: ActionExceptionKind.Unknown + } + ) + } + } +} diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts new file mode 100644 index 00000000..04c7b7e7 --- /dev/null +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -0,0 +1,89 @@ +/** + * Copyright (C) 2023 Gnuxie + * All rights reserved. + */ + +import { ActionResult, ClientRooms, ClientsInRoomMap, InternedInstanceFactory, MatrixRoomID, Ok, PolicyRoomManager, RoomEvent, RoomMembershipManager, RoomStateManager, StandardClientsInRoomMap, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +import { ClientForUserID, MatrixSendClient, RoomStateManagerFactory, joinedRoomsSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DraupnirClientRooms } from "./DraupnirClientRooms"; +import { IConfig } from "../config"; + +interface DraupnirFactory { + clientsInRoomMap: ClientsInRoomMap; + getDraupnir( + clientUserID: StringUserID, + managementRoom: MatrixRoomID, + config: IConfig + ): Promise>; +} + +export class StandardDraupnirFactory implements DraupnirFactory { + public readonly clientsInRoomMap = new StandardClientsInRoomMap(); + private readonly draupnirs: InternedInstanceFactory = new InternedInstanceFactory( + async (clientUserID, managementRoom, config) => { + 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.makeClientRooms(clientUserID, client, managementRoom, roomStateManager, policyRoomManager, roomMembershipManager); + if (isError(clientRooms)) { + return clientRooms; + } + this.clientsInRoomMap.addClientRooms(clientRooms.ok); + return Ok(await Draupnir.makeDraupnirBot( + client, + clientUserID, + clientRooms.ok, + managementRoom, + clientRooms.ok.protectedRoomsSets[0], + roomStateManager, + policyRoomManager, + roomMembershipManager, + config + )) + } + ) + + public constructor( + private readonly clientProvider: ClientForUserID, + private readonly roomStateManagerFactory: RoomStateManagerFactory + ) { + // nothing to do. + } + + public async getDraupnir(clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig): Promise> { + return await this.draupnirs.getInstance(clientUserID, managementRoom, config); + } + + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + this.roomStateManagerFactory.handleTimelineEvent(roomID, event); + for (const draupnir of this.draupnirs.allInstances()) { + if (this.clientsInRoomMap.isClientInRoom(draupnir.clientUserID, roomID) || ('state_key' in event && draupnir.clientUserID === event.state_key)) { + // TODO: it would be nicer if somehow clientRooms can handle this, and finding which clients to inform. + // and also how to inform Draupnir. + draupnir.handleTimelineEvent(roomID, event); + draupnir.clientRooms.handleTimelineEvent(roomID, event); + } + } + } + + private async makeClientRooms( + clientUserID: StringUserID, + client: MatrixSendClient, + managementRoom: MatrixRoomID, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager + ): Promise> { + return await DraupnirClientRooms.makeClientRooms( + client, + managementRoom, + async () => joinedRoomsSafe(client), + clientUserID, + roomStateManager, + policyRoomManager, + roomMembershipManager + ) + } +} diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts new file mode 100644 index 00000000..ddf0e13e --- /dev/null +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -0,0 +1,155 @@ +/** + * 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 { MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { BotSDKMatrixAccountData, BotSDKMatrixStateData, BotSDKMjolnirProtectedRoomsStore, BotSDKMjolnirWatchedPolicyRoomsStore, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; + +async function makePolicyListConfig( + client: MatrixSendClient, + policyRoomManager: PolicyRoomManager +): Promise { + const result = await MjolnirPolicyRoomsConfig.createFromStore( + new BotSDKMjolnirWatchedPolicyRoomsStore( + client + ), + policyRoomManager, + { resolveRoom: async (stringReference: StringRoomID | StringRoomAlias) => { + const reference = MatrixRoomReference.fromRoomIDOrAlias(stringReference); + const resolvedReference = await resolveRoomReferenceSafe(client, reference); + if (isError(resolvedReference)) { + return resolvedReference; + } else { + return Ok(resolvedReference.ok.toRoomIDOrAlias()) + } + } + } + ); + if (isError(result)) { + throw result.error; + } + return result.ok; +} + +async function makeProtectedRoomsConfig( + client: MatrixSendClient, +): Promise { + const result = await MjolnirProtectedRoomsConfig.createFromStore( + new BotSDKMjolnirProtectedRoomsStore( + client + ) + ); + if (isError(result)) { + throw result.error; + } + return result.ok; +} + +async function makeSetMembership( + roomMembershipManager: RoomMembershipManager, + protectedRoomsConfig: ProtectedRoomsConfig +): Promise { + const membershipSet = await StandardSetMembership.create( + roomMembershipManager, + protectedRoomsConfig + ); + if (isError(membershipSet)) { + throw membershipSet.error; + } + return membershipSet.ok; +} + +async function makeSetRoomState( + roomStateManager: RoomStateManager, + protectedRoomsConfig: ProtectedRoomsConfig +): Promise { + const setRoomState = await StandardSetRoomState.create( + roomStateManager, + protectedRoomsConfig + ); + if (isError(setRoomState)) { + throw setRoomState.error; + } + return setRoomState.ok; +} + +async function makeProtectionConfig( + client: MatrixSendClient, + roomStateManager: RoomStateManager, + managementRoom: MatrixRoomID +) { + const result = await roomStateManager.getRoomStateRevisionIssuer( + managementRoom + ); + if (isError(result)) { + throw result.error; + } + return new MjolnirProtectionsConfig( + new BotSDKMatrixAccountData( + MjolnirEnabledProtectionsEventType, + MjolnirEnabledProtectionsEvent, + client + ), + new BotSDKMatrixStateData( + MjolnirProtectionSettingsEventType, + result.ok, + client + ) + ) +} + + +export async function makeProtectedRoomsSet( + managementRoom: MatrixRoomID, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager, + client: MatrixSendClient, + userID: StringUserID +): Promise { + const protectedRoomsConfig = await makeProtectedRoomsConfig(client) + const setRoomState = await makeSetRoomState( + roomStateManager, + protectedRoomsConfig + ); + const membershipSet = await makeSetMembership( + roomMembershipManager, + protectedRoomsConfig + ); + const protectedRoomsSet = new StandardProtectedRoomsSet( + await makePolicyListConfig(client, policyRoomManager), + protectedRoomsConfig, + await makeProtectionConfig( + client, + roomStateManager, + managementRoom + ), + membershipSet, + setRoomState, + userID, + ); + return protectedRoomsSet; +} From e62735be0c554bb628c1a8d81aff21b802a757f8 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 31 Dec 2023 13:27:09 +0000 Subject: [PATCH 060/160] Still messing with ClientRooms. --- src/Draupnir.ts | 1 + src/DraupnirBotMode.ts | 32 ++++- src/appservice/Api.ts | 2 +- src/appservice/AppService.ts | 10 +- src/appservice/MjolnirManager.ts | 115 +++++++++--------- src/appservice/datastore.ts | 6 +- src/commands/PermissionCheckCommand.ts | 2 + src/draupnirfactory/DraupnirClientRooms.ts | 18 +-- src/draupnirfactory/DraupnirFactory.ts | 106 +++++++--------- .../StandardDraupnirManager.ts | 110 +++++++++++++++++ 10 files changed, 257 insertions(+), 145 deletions(-) create mode 100644 src/draupnirfactory/StandardDraupnirManager.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 8ba7bb49..a0ffe59c 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -238,6 +238,7 @@ export class Draupnir implements Client { } public async start(): Promise { + // FIXME: This method needs to be removed it won't be called at all. if (this.reportPoller) { const reportPollSetting = await ReportPoller.getReportPollSetting( this.client, diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 21ae357d..cb54a6c5 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -51,19 +51,26 @@ import { isStringRoomID, SetRoomState, StandardSetRoomState, + StandardClientRooms, + StandardClientsInRoomMap, + StandardEventDecoder, + DefaultEventDecoder, } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, BotSDKMatrixStateData, BotSDKMjolnirProtectedRoomsStore, BotSDKMjolnirWatchedPolicyRoomsStore, + DefaultStateTrackingMeta, ManagerManager, 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"; /** * This is a file for providing default concrete implementations @@ -90,11 +97,30 @@ export async function makeDraupnirBotModeFromConfig( if (isError(managementRoom)) { throw managementRoom.error; } - return await Draupnir.makeDraupnirBot( - client, - matrixEmitter, + 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, + DefaultStateTrackingMeta + ); + const draupnirFactory = new DraupnirFactory( + clientProvider, + roomStateManagerFactory + ); + const clientRooms = await draupnirFactory.makeDraupnirClientRooms( clientUserId, managementRoom.ok, config ); + if (isError(clientRooms)) { + throw clientRooms.error; + } + } diff --git a/src/appservice/Api.ts b/src/appservice/Api.ts index bdb64c20..25f97884 100644 --- a/src/appservice/Api.ts +++ b/src/appservice/Api.ts @@ -103,7 +103,7 @@ export class Api { return; } - response.status(200).json({ managementRoom: mjolnir.managementRoomId }); + response.status(200).json({ managementRoom: mjolnir.managementRoomID }); } /** diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index f01b176f..7598484f 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -35,6 +35,8 @@ import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; +import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { isError } from "matrix-protection-suite"; const log = new Logger("AppService"); /** @@ -85,8 +87,11 @@ export class MjolnirAppService { disableStores: true, }); await bridge.initialise(); - const accessControlListId = await bridge.getBot().getClient().resolveRoom(config.adminRoom); - const accessControl = await AccessControl.setupAccessControl(accessControlListId, bridge); + const accessControlRoom = await resolveRoomReferenceSafe(bridge.getBot().getClient(), config.adminRoom); + if (isError(accessControlRoom)) { + throw accessControlRoom.error; + } + const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom, bridge); // Activate /metrics endpoint for Prometheus // This should happen automatically but in testing this didn't happen in the docker image @@ -164,7 +169,6 @@ export class MjolnirAppService { } } } - this.accessControl.handleEvent(mxEvent['room_id'], mxEvent); this.mjolnirManager.onEvent(request); this.commands.handleEvent(mxEvent); } diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts index 741fe95e..85bd420a 100644 --- a/src/appservice/MjolnirManager.ts +++ b/src/appservice/MjolnirManager.ts @@ -1,18 +1,16 @@ -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"; +import { makeDraupnirBotModeFromConfig } from "../DraupnirBotMode"; +import { Access, MatrixRoomID, PropagationType, StringRoomID, StringUserID, isError, isStringRoomID } from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; const log = new Logger('MjolnirManager'); @@ -26,7 +24,7 @@ const log = new Logger('MjolnirManager'); * * Informing mjolnirs about new events. */ export class MjolnirManager { - private readonly mjolnirs: Map = new Map(); + private readonly mjolnirs: Map = new Map(); private readonly unstartedMjolnirs: Map = new Map(); private constructor( @@ -53,20 +51,20 @@ export class MjolnirManager { /** * Creates a new mjolnir for a user. - * @param requestingUserId The user that is requesting this mjolnir and who will own it. + * @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 { + public async makeInstance(localPart: string, requestingUserID: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise { const mxid = await client.getUserId(); const intentListener = new MatrixIntentListener(mxid); - const managedMjolnir = new ManagedMjolnir( - requestingUserId, - await Mjolnir.setupMjolnirFromConfig( + const managedMjolnir = new ManagedDraupnir( + requestingUserID, + await makeDraupnirBotModeFromConfig( client, intentListener, - getProvisionedMjolnirConfig(managementRoomId) + getProvisionedMjolnirConfig(managementRoomID) ), intentListener, ); @@ -81,15 +79,15 @@ export class MjolnirManager { /** * 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. + * @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); + public getMjolnir(mjolnirID: StringUserID, ownerID: StringUserID): ManagedDraupnir | 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}`); + if (mjolnir.ownerID !== ownerID) { + throw new Error(`${mjolnirID} is owned by a different user to ${ownerID}`); } else { return mjolnir; } @@ -100,14 +98,14 @@ export class MjolnirManager { /** * Find all of the mjolnirs that are owned by this specific user. - * @param ownerId An owner of multiple mjolnirs. + * @param ownerID An owner of multiple mjolnirs. * @returns Any mjolnirs that they own. */ - public getOwnedMjolnirs(ownerId: string): ManagedMjolnir[] { + public getOwnedMjolnirs(ownerID: StringUserID): ManagedDraupnir[] { // 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); + return [...this.mjolnirs.values()].filter(mjolnir => mjolnir.ownerID !== ownerID); } /** @@ -116,49 +114,52 @@ export class MjolnirManager { 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)); + [...this.mjolnirs.values()].forEach((mj: ManagedDraupnir) => mj.onEvent(request)); } /** * provision a new mjolnir for a matrix user. - * @param requestingUserId The mxid of the user we are creating a mjolnir for. + * @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); + public async provisionNewMjolnir(requestingUserID: StringUserID): Promise<[StringUserID, StringRoomID]> { + 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'}`); + 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); + 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({ + const managementRoomID = await mjIntent.matrixClient.createRoom({ preset: 'private_chat', - invite: [requestingUserId], - name: `${requestingUserId}'s Draupnir`, + invite: [requestingUserID], + name: `${requestingUserID}'s Draupnir`, power_level_content_override: { users: { - [requestingUserId]: 100, + [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 mjolnir = await this.makeInstance(mjolnirLocalPart, requestingUserId, managementRoomId, mjIntent.matrixClient); - await mjolnir.createFirstList(requestingUserId, "list"); + 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, + owner: requestingUserID, + management_room: managementRoomID, }); - return [mjIntent.userId, managementRoomId]; + return [mjIntent.userId as StringUserID, managementRoomID as StringRoomID]; } else { - throw new Error(`User: ${requestingUserId} has already provisioned ${provisionedMjolnirs.length} Draupnirs.`); + throw new Error(`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} Draupnirs.`); } } @@ -232,10 +233,13 @@ export class MjolnirManager { } } -export class ManagedMjolnir { +// FIXME: This isn't acceptable really, we need a managerManager that +// shares the same caches internally but uses different clients to do things. +// (Within MPS). +export class ManagedDraupnir { public constructor( - public readonly ownerId: string, - private readonly mjolnir: Mjolnir, + public readonly ownerID: StringUserID, + private readonly draupnir: Draupnir, private readonly matrixEmitter: MatrixIntentListener, ) { } @@ -244,26 +248,27 @@ export class ManagedMjolnir { } public async joinRoom(roomId: string) { - await this.mjolnir.client.joinRoom(roomId); + await this.draupnir.client.joinRoom(roomId); } - public async addProtectedRoom(roomId: string) { - await this.mjolnir.addProtectedRoom(roomId); + public async addProtectedRoom(room: MatrixRoomID) { + await this.draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(room); } - public async createFirstList(mjolnirOwnerId: string, shortcode: string) { - const listRoomId = await PolicyList.createList( - this.mjolnir.client, + public async createFirstList(draupnirOwnerID: StringUserID, shortcode: string) { + const policyRoom = await this.draupnir.managerManager.policyRoomManager.createPolicyRoom( shortcode, - [mjolnirOwnerId], - { name: `${mjolnirOwnerId}'s policy room` } + [draupnirOwnerID], + { name: `${draupnirOwnerID}'s policy room` } ); - const roomRef = MatrixRoomReference.fromPermalink(Permalinks.forRoom(listRoomId)); - await this.mjolnir.addProtectedRoom(listRoomId); - return await this.mjolnir.policyListManager.watchList(roomRef); + if (isError(policyRoom)) { + throw policyRoom.error; + } + await this.addProtectedRoom(policyRoom.ok); + return await this.draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); } - public get managementRoomId(): string { - return this.mjolnir.managementRoomId; + public get managementRoomID(): StringRoomID { + return this.draupnir.managementRoomID; } /** @@ -271,7 +276,7 @@ export class ManagedMjolnir { * This managed mjolnir should not be informed of any events via `onEvent` until `start` is called. */ public async start(): Promise { - await this.mjolnir.start(); + await this.draupnir.start(); } } 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/commands/PermissionCheckCommand.ts b/src/commands/PermissionCheckCommand.ts index 98ec99dd..b9173604 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -45,6 +45,8 @@ defineInterfaceCommand({ 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." diff --git a/src/draupnirfactory/DraupnirClientRooms.ts b/src/draupnirfactory/DraupnirClientRooms.ts index 15c9c46f..afa7d1fc 100644 --- a/src/draupnirfactory/DraupnirClientRooms.ts +++ b/src/draupnirfactory/DraupnirClientRooms.ts @@ -3,12 +3,10 @@ * All rights reserved. */ -import { ActionException, ActionExceptionKind, ActionResult, JoinedRoomsRevision, JoinedRoomsSafe, MatrixRoomID, Ok, PolicyRoomManager, RoomMembershipManager, RoomStateManager, StandardClientRooms, StandardJoinedRoomsRevision, StringUserID, isError } from "matrix-protection-suite"; -import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet"; +import { ActionException, ActionExceptionKind, ActionResult, ClientRooms, JoinedRoomsRevision, JoinedRoomsSafe, Ok, StandardClientRooms, StandardJoinedRoomsRevision, StringUserID, isError } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -import { IConfig } from "../config"; -export class DraupnirClientRooms extends StandardClientRooms { +export class DraupnirClientRooms extends StandardClientRooms implements ClientRooms { private constructor( client: Draupnir, joinedRoomsThunk: JoinedRoomsSafe, @@ -25,12 +23,8 @@ export class DraupnirClientRooms extends StandardClientRooms { public static async makeClientRooms( client: Draupnir, - managementRoom: MatrixRoomID, joinedRoomsThunk: JoinedRoomsSafe, clientUserID: StringUserID, - roomStateManager: RoomStateManager, - policyRoomManager: PolicyRoomManager, - roomMembershipManager: RoomMembershipManager ): Promise> { try { const joinedRooms = await joinedRoomsThunk(); @@ -40,14 +34,6 @@ export class DraupnirClientRooms extends StandardClientRooms { const revision = StandardJoinedRoomsRevision.blankRevision( clientUserID ).reviseFromJoinedRooms(joinedRooms.ok); - const protectedRoomsSet = await makeProtectedRoomsSet( - managementRoom, - roomStateManager, - policyRoomManager, - roomMembershipManager, - client.client, - clientUserID - ); return Ok(new DraupnirClientRooms( client, joinedRoomsThunk, diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index 04c7b7e7..ac860f28 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -3,48 +3,14 @@ * All rights reserved. */ -import { ActionResult, ClientRooms, ClientsInRoomMap, InternedInstanceFactory, MatrixRoomID, Ok, PolicyRoomManager, RoomEvent, RoomMembershipManager, RoomStateManager, StandardClientsInRoomMap, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { ActionResult, MatrixRoomID, Ok, StringUserID, isError } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -import { ClientForUserID, MatrixSendClient, RoomStateManagerFactory, joinedRoomsSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ClientForUserID, RoomStateManagerFactory, joinedRoomsSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirClientRooms } from "./DraupnirClientRooms"; import { IConfig } from "../config"; +import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet"; -interface DraupnirFactory { - clientsInRoomMap: ClientsInRoomMap; - getDraupnir( - clientUserID: StringUserID, - managementRoom: MatrixRoomID, - config: IConfig - ): Promise>; -} - -export class StandardDraupnirFactory implements DraupnirFactory { - public readonly clientsInRoomMap = new StandardClientsInRoomMap(); - private readonly draupnirs: InternedInstanceFactory = new InternedInstanceFactory( - async (clientUserID, managementRoom, config) => { - 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.makeClientRooms(clientUserID, client, managementRoom, roomStateManager, policyRoomManager, roomMembershipManager); - if (isError(clientRooms)) { - return clientRooms; - } - this.clientsInRoomMap.addClientRooms(clientRooms.ok); - return Ok(await Draupnir.makeDraupnirBot( - client, - clientUserID, - clientRooms.ok, - managementRoom, - clientRooms.ok.protectedRoomsSets[0], - roomStateManager, - policyRoomManager, - roomMembershipManager, - config - )) - } - ) - +export class DraupnirFactory { public constructor( private readonly clientProvider: ClientForUserID, private readonly roomStateManagerFactory: RoomStateManagerFactory @@ -52,38 +18,48 @@ export class StandardDraupnirFactory implements DraupnirFactory { // nothing to do. } - public async getDraupnir(clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig): Promise> { - return await this.draupnirs.getInstance(clientUserID, managementRoom, config); - } - - public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { - this.roomStateManagerFactory.handleTimelineEvent(roomID, event); - for (const draupnir of this.draupnirs.allInstances()) { - if (this.clientsInRoomMap.isClientInRoom(draupnir.clientUserID, roomID) || ('state_key' in event && draupnir.clientUserID === event.state_key)) { - // TODO: it would be nicer if somehow clientRooms can handle this, and finding which clients to inform. - // and also how to inform Draupnir. - draupnir.handleTimelineEvent(roomID, event); - draupnir.clientRooms.handleTimelineEvent(roomID, event); - } - } + private 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 protectedRoomsSet = await makeProtectedRoomsSet( + managementRoom, + roomStateManager, + policyRoomManager, + roomMembershipManager, + client, + clientUserID + ); + return Ok(await Draupnir.makeDraupnirBot( + client, + clientUserID, + managementRoom, + protectedRoomsSet, + roomStateManager, + policyRoomManager, + roomMembershipManager, + config + )) } - private async makeClientRooms( + public async makeDraupnirClientRooms( clientUserID: StringUserID, - client: MatrixSendClient, managementRoom: MatrixRoomID, - roomStateManager: RoomStateManager, - policyRoomManager: PolicyRoomManager, - roomMembershipManager: RoomMembershipManager - ): Promise> { - return await DraupnirClientRooms.makeClientRooms( - client, - managementRoom, - async () => joinedRoomsSafe(client), + config: IConfig, + ): Promise> { + const draupnir = await this.makeDraupnir( clientUserID, - roomStateManager, - policyRoomManager, - roomMembershipManager + managementRoom, + config + ); + if (isError(draupnir)) { + return draupnir; + } + return await DraupnirClientRooms.makeClientRooms( + draupnir.ok, + async () => joinedRoomsSafe(draupnir.ok.client), + clientUserID ) } } diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts new file mode 100644 index 00000000..f6fba706 --- /dev/null +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -0,0 +1,110 @@ +/** + * 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 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, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { DraupnirClientRooms } from "./DraupnirClientRooms"; +import { IConfig } from "../config"; +import { DraupnirFactory } from "./DraupnirFactory"; + +export abstract class StandardDraupnirManager { + private readonly readyDraupnirs = new Map(); + private readonly listeningDraupnirs = new StandardClientsInRoomMap(); + 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 draupnirRooms = await this.draupnirFactory.makeDraupnirClientRooms( + clientUserID, + managementRoom, + config + ); + if (this.readyDraupnirs.has(clientUserID)) { + return ActionError.Result(`There is a draupnir for ${clientUserID} already waiting to be started`); + } else if (this.listeningDraupnirs.getClientRooms(clientUserID) !== undefined) { + return ActionError.Result(`There is a draupnir for ${clientUserID} already running`); + } + if (isError(draupnirRooms)) { + this.failedDraupnirs.set(clientUserID, new UnstartedDraupnir( + clientUserID, + DraupnirFailType.InitializationError, + draupnirRooms.error + )) + return draupnirRooms; + } + this.readyDraupnirs.set(clientUserID, draupnirRooms.ok); + this.failedDraupnirs.delete(clientUserID); + return draupnirRooms; + } + + 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}`); + } + this.listeningDraupnirs.addClientRooms(draupnir); + this.readyDraupnirs.delete(clientUserID); + } + + public stopDraupnir( + clientUserID: StringUserID + ): void { + const draupnir = this.listeningDraupnirs.getClientRooms(clientUserID); + if (draupnir === undefined) { + return; + } else { + this.listeningDraupnirs.removeClientRooms(draupnir); + this.readyDraupnirs.set(clientUserID, draupnir as DraupnirClientRooms); + } + } +} + +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", +} From aeb0aedc023185258ed28b0c0be660455176329a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 4 Jan 2024 20:09:06 +0000 Subject: [PATCH 061/160] Draupnir has complete `ClientRooms` before instantiation. --- src/Draupnir.ts | 11 +++++-- src/draupnirfactory/DraupnirClientRooms.ts | 13 ++++---- src/draupnirfactory/DraupnirFactory.ts | 33 +++++++------------ .../StandardDraupnirManager.ts | 33 ++++++++++--------- 4 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index a0ffe59c..48b759c0 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Client, 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 { Client, 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"; @@ -74,10 +74,14 @@ export class Draupnir implements Client { public readonly reportManager: ReportManager; public readonly reactionHandler: MatrixReactionHandler; + + private readonly timelineEventListener = this.handleTimelineEvent.bind(this); + private constructor( public readonly client: MatrixSendClient, public readonly clientUserID: StringUserID, public readonly managementRoom: MatrixRoomID, + public readonly clientRooms: ClientRooms, public readonly config: IConfig, public readonly protectedRoomsSet: ProtectedRoomsSet, public readonly roomStateManager: RoomStateManager, @@ -94,12 +98,14 @@ export class Draupnir implements Client { if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); } + this.clientRooms.on('timeline', this.timelineEventListener); } public static async makeDraupnirBot( client: MatrixSendClient, clientUserID: StringUserID, managementRoom: MatrixRoomID, + clientRooms: ClientRooms, protectedRoomsSet: ProtectedRoomsSet, roomStateManager: RoomStateManager, policyRoomManager: PolicyRoomManager, @@ -110,6 +116,7 @@ export class Draupnir implements Client { client, clientUserID, managementRoom, + clientRooms, config, protectedRoomsSet, roomStateManager, @@ -238,7 +245,7 @@ export class Draupnir implements Client { } public async start(): Promise { - // FIXME: This method needs to be removed it won't be called at all. + // FIXME: This method needs to be removed it probably won't be called at all. if (this.reportPoller) { const reportPollSetting = await ReportPoller.getReportPollSetting( this.client, diff --git a/src/draupnirfactory/DraupnirClientRooms.ts b/src/draupnirfactory/DraupnirClientRooms.ts index afa7d1fc..f7013141 100644 --- a/src/draupnirfactory/DraupnirClientRooms.ts +++ b/src/draupnirfactory/DraupnirClientRooms.ts @@ -1,20 +1,19 @@ /** - * Copyright (C) 2023 Gnuxie + * Copyright (C) 2023-2024 Gnuxie * All rights reserved. */ -import { ActionException, ActionExceptionKind, ActionResult, ClientRooms, JoinedRoomsRevision, JoinedRoomsSafe, Ok, StandardClientRooms, StandardJoinedRoomsRevision, StringUserID, isError } from "matrix-protection-suite"; -import { Draupnir } from "../Draupnir"; +import { ActionException, ActionExceptionKind, ActionResult, ClientRooms, JoinedRoomsRevision, JoinedRoomsSafe, Ok, RoomStateManager, StandardClientRooms, StandardJoinedRoomsRevision, StringUserID, isError } from "matrix-protection-suite"; export class DraupnirClientRooms extends StandardClientRooms implements ClientRooms { private constructor( - client: Draupnir, + roomStateManager: RoomStateManager, joinedRoomsThunk: JoinedRoomsSafe, clientUserID: StringUserID, joinedRoomsRevision: JoinedRoomsRevision ) { super( - client, + roomStateManager, joinedRoomsThunk, clientUserID, joinedRoomsRevision @@ -22,7 +21,7 @@ export class DraupnirClientRooms extends StandardClientRooms implements ClientRo } public static async makeClientRooms( - client: Draupnir, + roomStateManager: RoomStateManager, joinedRoomsThunk: JoinedRoomsSafe, clientUserID: StringUserID, ): Promise> { @@ -35,7 +34,7 @@ export class DraupnirClientRooms extends StandardClientRooms implements ClientRo clientUserID ).reviseFromJoinedRooms(joinedRooms.ok); return Ok(new DraupnirClientRooms( - client, + roomStateManager, joinedRoomsThunk, clientUserID, revision diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index ac860f28..e30aef50 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -1,5 +1,5 @@ /** - * Copyright (C) 2023 Gnuxie + * Copyright (C) 2023-2024 Gnuxie * All rights reserved. */ @@ -18,11 +18,19 @@ export class DraupnirFactory { // nothing to do. } - private async makeDraupnir(clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig): Promise> { + 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 DraupnirClientRooms.makeClientRooms( + roomStateManager, + async () => joinedRoomsSafe(client), + clientUserID + ); + if (isError(clientRooms)) { + return clientRooms; + } const protectedRoomsSet = await makeProtectedRoomsSet( managementRoom, roomStateManager, @@ -35,6 +43,7 @@ export class DraupnirFactory { client, clientUserID, managementRoom, + clientRooms.ok, protectedRoomsSet, roomStateManager, policyRoomManager, @@ -42,24 +51,4 @@ export class DraupnirFactory { config )) } - - public async makeDraupnirClientRooms( - clientUserID: StringUserID, - managementRoom: MatrixRoomID, - config: IConfig, - ): Promise> { - const draupnir = await this.makeDraupnir( - clientUserID, - managementRoom, - config - ); - if (isError(draupnir)) { - return draupnir; - } - return await DraupnirClientRooms.makeClientRooms( - draupnir.ok, - async () => joinedRoomsSafe(draupnir.ok.client), - clientUserID - ) - } } diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index f6fba706..cb510b72 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -26,13 +26,14 @@ limitations under the License. */ import { ActionError, ActionResult, MatrixRoomID, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; -import { DraupnirClientRooms } from "./DraupnirClientRooms"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; +import { Draupnir } from "../Draupnir"; export abstract class StandardDraupnirManager { - private readonly readyDraupnirs = new Map(); - private readonly listeningDraupnirs = new StandardClientsInRoomMap(); + private readonly readyDraupnirs = new Map(); + private readonly listeningDraupnirs = new Map(); + private readonly clientsInRooms = new StandardClientsInRoomMap(); private readonly failedDraupnirs = new Map(); public constructor( @@ -45,28 +46,28 @@ export abstract class StandardDraupnirManager { clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig - ): Promise> { - const draupnirRooms = await this.draupnirFactory.makeDraupnirClientRooms( + ): Promise> { + const draupnir = await this.draupnirFactory.makeDraupnir( clientUserID, managementRoom, config ); if (this.readyDraupnirs.has(clientUserID)) { return ActionError.Result(`There is a draupnir for ${clientUserID} already waiting to be started`); - } else if (this.listeningDraupnirs.getClientRooms(clientUserID) !== undefined) { + } else if (this.clientsInRooms.getClientRooms(clientUserID) !== undefined) { return ActionError.Result(`There is a draupnir for ${clientUserID} already running`); } - if (isError(draupnirRooms)) { + if (isError(draupnir)) { this.failedDraupnirs.set(clientUserID, new UnstartedDraupnir( clientUserID, DraupnirFailType.InitializationError, - draupnirRooms.error + draupnir.error )) - return draupnirRooms; + return draupnir; } - this.readyDraupnirs.set(clientUserID, draupnirRooms.ok); + this.readyDraupnirs.set(clientUserID, draupnir.ok); this.failedDraupnirs.delete(clientUserID); - return draupnirRooms; + return draupnir; } public startDraupnir( @@ -76,19 +77,21 @@ export abstract class StandardDraupnirManager { if (draupnir === undefined) { throw new TypeError(`Trying to start a draupnir that hasn't been created ${clientUserID}`); } - this.listeningDraupnirs.addClientRooms(draupnir); + this.clientsInRooms.addClientRooms(draupnir.clientRooms); + this.listeningDraupnirs.set(clientUserID, draupnir); this.readyDraupnirs.delete(clientUserID); } public stopDraupnir( clientUserID: StringUserID ): void { - const draupnir = this.listeningDraupnirs.getClientRooms(clientUserID); + const draupnir = this.listeningDraupnirs.get(clientUserID); if (draupnir === undefined) { return; } else { - this.listeningDraupnirs.removeClientRooms(draupnir); - this.readyDraupnirs.set(clientUserID, draupnir as DraupnirClientRooms); + this.clientsInRooms.removeClientRooms(draupnir.clientRooms); + this.listeningDraupnirs.delete(clientUserID); + this.readyDraupnirs.set(clientUserID, draupnir); } } } From e46e35505081eb2b2ec976dd10f5723b53d2ad17 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 9 Jan 2024 11:42:16 +0000 Subject: [PATCH 062/160] Update to MPS 0.9.0. --- package.json | 4 ++-- yarn.lock | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index eba1fe3a..c0cdd85c 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git#0.8.0", - "matrix-protection-suite-for-matrix-bot-sdk": "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#2df8b462442a42c975f7932d17a08c3aea23604b", + "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git#0.9.0", + "matrix-protection-suite-for-matrix-bot-sdk": "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#0.9.0", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/yarn.lock b/yarn.lock index 2eebca44..5e33b406 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2319,13 +2319,13 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#2df8b462442a42c975f7932d17a08c3aea23604b": - version "0.8.0" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#2df8b462442a42c975f7932d17a08c3aea23604b" +"matrix-protection-suite-for-matrix-bot-sdk@git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#0.9.0": + version "0.9.0" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#ce998f2c56cdeed74229c45977fd836464894854" -"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git#0.8.0": - version "0.8.0" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#e67a5fcbba9565acad7da43fb84c4a4c0f321466" +"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git#0.9.0": + version "0.9.0" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#fa2944d9ef325d88d0113e0d50936d50da2180d8" dependencies: await-lock "^2.2.2" crypto-js "^4.1.1" From eecb074ac8c012f8c0fe3e7c8dbf90818a69766a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 9 Jan 2024 11:48:34 +0000 Subject: [PATCH 063/160] introduce draupnir factory and manager. These are used to create draupnirs and then inform draupnirs of new events. The draupnir manager is also used to start/stop draupnirs for the appservice --- src/DraupnirBotMode.ts | 34 +- src/appservice/Api.ts | 51 ++- src/appservice/AppService.ts | 33 +- src/appservice/AppServiceDraupnirManager.ts | 293 +++++++++++++++ src/appservice/MjolnirManager.ts | 350 ------------------ src/appservice/bot/ListCommand.tsx | 4 +- .../StandardDraupnirManager.ts | 50 ++- 7 files changed, 398 insertions(+), 417 deletions(-) create mode 100644 src/appservice/AppServiceDraupnirManager.ts delete mode 100644 src/appservice/MjolnirManager.ts diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index cb54a6c5..08dd4a44 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -26,43 +26,17 @@ limitations under the License. */ import { - MjolnirPolicyRoomsConfig, - PolicyListConfig, - PolicyRoomManager, - ProtectedRoomsConfig, - ResolveRoom, - MjolnirProtectedRoomsConfig, - StandardProtectedRoomsSet, isError, - RoomStateManager, - MjolnirProtectionsConfig, - MjolnirEnabledProtectionsEvent, - MjolnirEnabledProtectionsEventType, - MatrixRoomID, - MjolnirProtectionSettingsEventType, - StandardSetMembership, - RoomMembershipManager, - SetMembership, StringUserID, - ProtectedRoomsSet, MatrixRoomReference, isStringUserID, isStringRoomAlias, isStringRoomID, - SetRoomState, - StandardSetRoomState, - StandardClientRooms, StandardClientsInRoomMap, - StandardEventDecoder, DefaultEventDecoder, } from "matrix-protection-suite"; import { - BotSDKMatrixAccountData, - BotSDKMatrixStateData, - BotSDKMjolnirProtectedRoomsStore, - BotSDKMjolnirWatchedPolicyRoomsStore, DefaultStateTrackingMeta, - ManagerManager, MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, @@ -114,13 +88,13 @@ export async function makeDraupnirBotModeFromConfig( clientProvider, roomStateManagerFactory ); - const clientRooms = await draupnirFactory.makeDraupnirClientRooms( + const draupnir = await draupnirFactory.makeDraupnir( clientUserId, managementRoom.ok, config ); - if (isError(clientRooms)) { - throw clientRooms.error; + if (isError(draupnir)) { + throw draupnir.error; } - + return draupnir.ok; } diff --git a/src/appservice/Api.ts b/src/appservice/Api.ts index 25f97884..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,16 +89,18 @@ 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; @@ -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 7598484f..cccb4d11 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,8 +34,9 @@ import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; -import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { isError } from "matrix-protection-suite"; +import { DefaultStateTrackingMeta, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEventDecoder, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); /** @@ -55,12 +55,12 @@ 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 ) { - this.api = new Api(config.homeserver.url, mjolnirManager); + this.api = new Api(config.homeserver.url, draupnirManager); this.commands = new AppserviceCommandHandler(this); } @@ -91,7 +91,19 @@ export class MjolnirAppService { if (isError(accessControlRoom)) { throw accessControlRoom.error; } - const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom, bridge); + const clientsInRoomMap = new StandardClientsInRoomMap(); + const clientProvider = async (clientUserID: StringUserID) => bridge.getIntent(clientUserID).matrixClient; + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + clientProvider, + DefaultEventDecoder, + DefaultStateTrackingMeta + ); + const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(bridge.getBot().getUserId() as StringUserID); + const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, bridge); + 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 @@ -105,12 +117,13 @@ 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, instanceCountGauge); const appService = new MjolnirAppService( config, bridge, mjolnirManager, - accessControl, + accessControl.ok, dataStore, prometheus ); @@ -156,7 +169,7 @@ 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) + await this.draupnirManager.provisionNewDraupnir(mxEvent.sender as StringUserID) } 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. @@ -169,7 +182,7 @@ export class MjolnirAppService { } } } - this.mjolnirManager.onEvent(request); + this.draupnirManager.onEvent(request); this.commands.handleEvent(mxEvent); } diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts new file mode 100644 index 00000000..1ac6e354 --- /dev/null +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -0,0 +1,293 @@ +/** + * 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 { 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 instanceCountGauge: Gauge<"status" | "uuid"> + ) { + const clientProvider = this.bridge.getIntent.bind(this.bridge); + const draupnirFactory = new DraupnirFactory( + clientProvider, + this.roomStateManagerFactory + ); + this.baseManager = new StandardDraupnirManager( + draupnirFactory, + roomStateManagerFactory.clientsInRoomMap + ); + } + + 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, + instanceCountGauge: Gauge<"status" | "uuid"> + ): Promise { + const draupnirManager = new AppServiceDraupnirManager(serverName, dataStore, bridge, accessControl, roomStateManagerFactory, 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 findUnstartedMjolnir(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; + } + + /** + * 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 startDraupnir(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.startDraupnir(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 85bd420a..00000000 --- a/src/appservice/MjolnirManager.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { Request, WeakEvent, Bridge, Intent, Logger } from "matrix-appservice-bridge"; -import { getProvisionedMjolnirConfig } from "../config"; -import { MatrixClient, UserID } from "matrix-bot-sdk"; -import { DataStore, MjolnirRecord } from "./datastore"; -import { AccessControl } from "./AccessControl"; -import { randomUUID } from "crypto"; -import EventEmitter from "events"; -import { Gauge } from "prom-client"; -import { decrementGaugeValue, incrementGaugeValue } from "../utils"; -import { makeDraupnirBotModeFromConfig } from "../DraupnirBotMode"; -import { Access, MatrixRoomID, PropagationType, StringRoomID, StringUserID, isError, isStringRoomID } from "matrix-protection-suite"; -import { Draupnir } from "../Draupnir"; -import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; - -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: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise { - const mxid = await client.getUserId(); - const intentListener = new MatrixIntentListener(mxid); - const managedMjolnir = new ManagedDraupnir( - requestingUserID, - await makeDraupnirBotModeFromConfig( - 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: StringUserID, ownerID: StringUserID): ManagedDraupnir | 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: StringUserID): ManagedDraupnir[] { - // 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: ManagedDraupnir) => 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: StringUserID): Promise<[StringUserID, StringRoomID]> { - 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 - } - } - }); - if (!isStringRoomID(managementRoomID)) { - throw new TypeError(`${managementRoomID} malformed managmentRoomID`); - } - - 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 as StringUserID, managementRoomID as StringRoomID]; - } 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); - } - } -} - -// FIXME: This isn't acceptable really, we need a managerManager that -// shares the same caches internally but uses different clients to do things. -// (Within MPS). -export class ManagedDraupnir { - public constructor( - public readonly ownerID: StringUserID, - private readonly draupnir: Draupnir, - private readonly matrixEmitter: MatrixIntentListener, - ) { } - - public async onEvent(request: Request) { - this.matrixEmitter.handleEvent(request.getData()); - } - - public async joinRoom(roomId: string) { - await this.draupnir.client.joinRoom(roomId); - } - public async addProtectedRoom(room: MatrixRoomID) { - await this.draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(room); - } - - public async createFirstList(draupnirOwnerID: StringUserID, shortcode: string) { - const policyRoom = await this.draupnir.managerManager.policyRoomManager.createPolicyRoom( - shortcode, - [draupnirOwnerID], - { name: `${draupnirOwnerID}'s policy room` } - ); - if (isError(policyRoom)) { - throw policyRoom.error; - } - await this.addProtectedRoom(policyRoom.ok); - return await this.draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {}); - } - - public get managementRoomID(): StringRoomID { - return this.draupnir.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.draupnir.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/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index 2cc46067..9a9112f0 100644 --- a/src/appservice/bot/ListCommand.tsx +++ b/src/appservice/bot/ListCommand.tsx @@ -29,7 +29,7 @@ const listUnstarted = defineInterfaceCommand({ table: "appservice bot", parameters: parameters([]), command: async function () { - return Ok(this.appservice.mjolnirManager.getUnstartedMjolnirs()); + return Ok(this.appservice.draupnirManager.getUnstartedMjolnirs()); }, summary: "List any Mjolnir that failed to start." }); @@ -81,7 +81,7 @@ const restart = defineInterfaceCommand({ } ]), command: async function (this, _keywords, mjolnirId: UserID): Promise> { - const mjolnirManager = this.appservice.mjolnirManager; + const mjolnirManager = this.appservice.draupnirManager; const mjolnir = mjolnirManager.findUnstartedMjolnir(mjolnirId.localpart); if (mjolnir?.mjolnirRecord === undefined) { return ActionError.Result(`We can't find the unstarted mjolnir ${mjolnirId}, is it running?`); diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index cb510b72..589379af 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -1,5 +1,5 @@ /** - * Copyright (C) 2022-2023 Gnuxie + * Copyright (C) 2022-2024 Gnuxie * All rights reserved. * * This file is modified and is NOT licensed under the Apache License. @@ -25,19 +25,19 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionResult, MatrixRoomID, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { ActionError, ActionResult, ClientsInRoomMap, MatrixRoomID, RoomEvent, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; -export abstract class StandardDraupnirManager { +export class StandardDraupnirManager { private readonly readyDraupnirs = new Map(); private readonly listeningDraupnirs = new Map(); - private readonly clientsInRooms = new StandardClientsInRoomMap(); private readonly failedDraupnirs = new Map(); public constructor( - protected readonly draupnirFactory: DraupnirFactory + protected readonly draupnirFactory: DraupnirFactory, + private readonly clientsInRooms: ClientsInRoomMap ) { // nothing to do. } @@ -58,11 +58,11 @@ export abstract class StandardDraupnirManager { return ActionError.Result(`There is a draupnir for ${clientUserID} already running`); } if (isError(draupnir)) { - this.failedDraupnirs.set(clientUserID, new UnstartedDraupnir( - clientUserID, + this.reportUnstartedDraupnir( DraupnirFailType.InitializationError, - draupnir.error - )) + draupnir.error, + clientUserID + ); return draupnir; } this.readyDraupnirs.set(clientUserID, draupnir.ok); @@ -70,6 +70,34 @@ export abstract class StandardDraupnirManager { 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 { @@ -94,6 +122,10 @@ export abstract class StandardDraupnirManager { this.readyDraupnirs.set(clientUserID, draupnir); } } + + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + this.clientsInRooms.handleTimelineEvent(roomID, event); + } } export class UnstartedDraupnir { From 468f1846b0fa43aade45e230b6282f1adb6c7a09 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 9 Jan 2024 12:01:49 +0000 Subject: [PATCH 064/160] bot mode: ClientsInRoomMap and RoomStateManager are given events. --- src/DraupnirBotMode.ts | 4 ++++ src/index.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 08dd4a44..95da128b 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -96,5 +96,9 @@ export async function makeDraupnirBotModeFromConfig( if (isError(draupnir)) { throw draupnir.error; } + matrixEmitter.on('room.event', (roomID, event) => { + roomStateManagerFactory.handleTimelineEvent(roomID, event); + clientsInRoomMap.handleTimelineEvent(roomID, event); + }) return draupnir.ok; } diff --git a/src/index.ts b/src/index.ts index 090c0c82..7e9411f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,7 @@ import { DefaultEventDecoder } from "matrix-protection-suite"; } try { await bot.start(); + await config.RUNTIME.client.start(); healthz.isHealthy = true; } catch (err) { console.error(`Mjolnir failed to start: ${err}`); From 207be66977a68e202604e2253992e45b453b61ab Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 9 Jan 2024 12:09:37 +0000 Subject: [PATCH 065/160] AppService: Inform ClientsInRoomMap and RoomStateManagerFactory of events. --- src/appservice/AppService.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index cccb4d11..5b861efb 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -35,7 +35,7 @@ import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; import { DefaultStateTrackingMeta, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { DefaultEventDecoder, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); @@ -58,6 +58,9 @@ export class MjolnirAppService { public readonly draupnirManager: AppServiceDraupnirManager, public readonly accessControl: AccessControl, private readonly dataStore: DataStore, + private readonly eventDecoder: EventDecoder, + private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly clientsInRoomMap: ClientsInRoomMap, private readonly prometheusMetrics: PrometheusMetrics ) { this.api = new Api(config.homeserver.url, draupnirManager); @@ -71,7 +74,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, @@ -96,7 +99,7 @@ export class MjolnirAppService { const roomStateManagerFactory = new RoomStateManagerFactory( clientsInRoomMap, clientProvider, - DefaultEventDecoder, + eventDecoder, DefaultStateTrackingMeta ); const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(bridge.getBot().getUserId() as StringUserID); @@ -125,6 +128,9 @@ export class MjolnirAppService { mjolnirManager, accessControl.ok, dataStore, + eventDecoder, + roomStateManagerFactory, + clientsInRoomMap, prometheus ); bridge.opts.controller = { @@ -144,7 +150,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; @@ -182,8 +188,19 @@ export class MjolnirAppService { } } } - this.draupnirManager.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); } /** From 495779377337caa482feb288bf26c0f6f342f5ef Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 9 Jan 2024 12:10:40 +0000 Subject: [PATCH 066/160] AppService: eslint. --- src/appservice/AppService.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 5b861efb..c0bc8ca7 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -83,8 +83,12 @@ 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, From 887e6f6772ba32c8bb3a2a4bd2e7db05c914f719 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 9 Jan 2024 12:24:48 +0000 Subject: [PATCH 067/160] AppService: resolve `config.adminRoom` correctly. There should really be a dedicated config parser for both appservice and bot config. --- src/appservice/AppService.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index c0bc8ca7..d604cbd0 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -35,7 +35,7 @@ import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; import { DefaultStateTrackingMeta, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite"; +import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringUserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); @@ -94,7 +94,20 @@ export class MjolnirAppService { disableStores: true, }); await bridge.initialise(); - const accessControlRoom = await resolveRoomReferenceSafe(bridge.getBot().getClient(), config.adminRoom); + 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; } From e72f3d152161d1404575c16fa0c73d417c6277a4 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 10 Jan 2024 18:13:26 +0000 Subject: [PATCH 068/160] Update AppService command handler. --- src/appservice/bot/AppserviceCommandHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 5003b09f..149c1f5c 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -11,7 +11,7 @@ import { ArgumentStream, RestDescription, findPresentationType, parameters } fro import { MjolnirAppService } from '../AppService'; import { renderHelp } from '../../commands/interface-manager/MatrixHelpRenderer'; import { AppserviceBotEmitter } from './AppserviceBotEmitter'; -import { ActionResult, Ok, RoomMessage, Value, isError } from 'matrix-protection-suite'; +import { ActionResult, Ok, RoomMessage, StringRoomID, Value, isError } from 'matrix-protection-suite'; defineCommandTable("appservice bot"); @@ -69,7 +69,7 @@ export class AppserviceCommandHandler { const adaptor = findMatrixInterfaceAdaptor(command); const context: AppserviceContext = { appservice: this.appservice, - roomId: mxEvent.room_id, + roomID: mxEvent.room_id as StringRoomID, event: parsedEvent, client: this.appservice.bridge.getBot().getClient(), emitter: new AppserviceBotEmitter(), From fa499a49c30c174cc4cf6d40eb42e42fcd633dc0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 10 Jan 2024 18:46:17 +0000 Subject: [PATCH 069/160] AppService commands should use draupnir manager. --- src/appservice/AppServiceDraupnirManager.ts | 15 +++++++-- src/appservice/bot/ListCommand.tsx | 37 ++++++++++----------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index 1ac6e354..879f988a 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -201,7 +201,7 @@ export class AppServiceDraupnirManager { return this.baseManager.getUnstartedDraupnirs(); } - public findUnstartedMjolnir(clientUserID: StringUserID): UnstartedDraupnir | undefined { + public findUnstartedDraupnir(clientUserID: StringUserID): UnstartedDraupnir | undefined { return this.baseManager.findUnstartedDraupnir(clientUserID); } @@ -216,12 +216,21 @@ export class AppServiceDraupnirManager { 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 startDraupnir(mjolnirRecord: MjolnirRecord): Promise> { + 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.`); @@ -271,7 +280,7 @@ export class AppServiceDraupnirManager { */ public async startDraupnirs(mjolnirRecords: MjolnirRecord[]): Promise { for (const mjolnirRecord of mjolnirRecords) { - await this.startDraupnir(mjolnirRecord); + await this.startDraupnirFromRecord(mjolnirRecord); } } } diff --git a/src/appservice/bot/ListCommand.tsx b/src/appservice/bot/ListCommand.tsx index 9a9112f0..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 { UnstartedMjolnir } from '../MjolnirManager'; import { BaseFunction, defineInterfaceCommand } from '../../commands/interface-manager/InterfaceCommand'; import { findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; import { AppserviceBaseExecutor } from './AppserviceCommandHandler'; -import { UserID } from 'matrix-bot-sdk'; 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 } from 'matrix-protection-suite'; +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,16 +28,16 @@ const listUnstarted = defineInterfaceCommand({ table: "appservice bot", parameters: parameters([]), command: async function () { - return Ok(this.appservice.draupnirManager.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: ActionResult) { + 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 (isError(result)) { return; // just let the default handler deal with it. @@ -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, _keywords, mjolnirId: UserID): Promise> { - const mjolnirManager = this.appservice.draupnirManager; - const mjolnir = mjolnirManager.findUnstartedMjolnir(mjolnirId.localpart); - if (mjolnir?.mjolnirRecord === undefined) { - return ActionError.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 Ok(true); + return await draupnirManager.startDraupnirFromMXID(draupnirUser.toString()); }, summary: "Attempt to restart a Mjolnir." }) From 8d8cb0fe40e08afd9c6bcca5e2f895d8e3be142e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 10 Jan 2024 19:03:12 +0000 Subject: [PATCH 070/160] Update commands for removal of MPS managerManager. --- src/commands/Ban.tsx | 4 ++-- src/commands/CreateBanListCommand.ts | 2 +- src/commands/ImportCommand.ts | 2 +- src/commands/StatusCommand.tsx | 2 +- src/commands/Unban.ts | 4 ++-- src/protections/BanPropagation.tsx | 8 ++++---- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/commands/Ban.tsx b/src/commands/Ban.tsx index 6bb06384..c0f4c1a2 100644 --- a/src/commands/Ban.tsx +++ b/src/commands/Ban.tsx @@ -43,7 +43,7 @@ export async function findPolicyRoomEditorFromRoomReference(draupnir: Draupnir, if (isError(policyRoomID)) { return policyRoomID; } - return await draupnir.managerManager.policyRoomManager.getPolicyRoomEditor(policyRoomID.ok); + return await draupnir.policyRoomManager.getPolicyRoomEditor(policyRoomID.ok); } async function ban( @@ -97,7 +97,7 @@ defineInterfaceCommand({ ), prompt: async function (this: DraupnirContext, _parameter: ParameterDescription): Promise { return { - suggestions: this.draupnir.managerManager.policyRoomManager.getEditablePolicyRoomIDs( + suggestions: this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( this.draupnir.clientUserID, PolicyRuleType.User ) diff --git a/src/commands/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 15e5ce59..297f33cf 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -39,7 +39,7 @@ export async function createList( alias: MatrixRoomAlias, ...reasonParts: string[] ): Promise> { - const newList = await this.draupnir.managerManager.policyRoomManager.createPolicyRoom( + const newList = await this.draupnir.policyRoomManager.createPolicyRoom( shortcode, [this.event.sender], { diff --git a/src/commands/ImportCommand.ts b/src/commands/ImportCommand.ts index b03d6ae5..5fe9fbb5 100644 --- a/src/commands/ImportCommand.ts +++ b/src/commands/ImportCommand.ts @@ -47,7 +47,7 @@ export async function importCommand( if (isError(policyRoom)) { return policyRoom; } - const policyRoomEditor = await this.draupnir.managerManager.policyRoomManager.getPolicyRoomEditor( + const policyRoomEditor = await this.draupnir.policyRoomManager.getPolicyRoomEditor( policyRoom.ok ); if (isError(policyRoomEditor)) { diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index 57156bae..a6186f9e 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -62,7 +62,7 @@ export interface StatusInfo { export async function listInfo(draupnir: Draupnir): Promise { const watchedListProfiles = draupnir.protectedRoomsSet.issuerManager.allWatchedLists; const issuerResults = await Promise.all(watchedListProfiles.map((profile) => - draupnir.managerManager.policyRoomManager.getPolicyRoomRevisionIssuer(profile.room) + draupnir.policyRoomManager.getPolicyRoomRevisionIssuer(profile.room) )); return issuerResults.map((result) => { if (isError(result)) { diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index 01d67a4f..2b932104 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -64,7 +64,7 @@ async function unban( if (isError(policyRoom)) { return policyRoom; } - const policyRoomEditor = await this.draupnir.managerManager.policyRoomManager.getPolicyRoomEditor( + const policyRoomEditor = await this.draupnir.policyRoomManager.getPolicyRoomEditor( policyRoom.ok ); if (isError(policyRoomEditor)) { @@ -120,7 +120,7 @@ defineInterfaceCommand({ ), prompt: async function (this: DraupnirContext) { return { - suggestions: this.draupnir.managerManager.policyRoomManager.getEditablePolicyRoomIDs( + suggestions: this.draupnir.policyRoomManager.getEditablePolicyRoomIDs( this.draupnir.clientUserID, PolicyRuleType.User ) diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index b61080dc..e4e16b94 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -67,7 +67,7 @@ async function promptBanPropagation( draupnir: Draupnir, change: MembershipChange, ): Promise { - const editablePolicyRoomIDs = draupnir.managerManager.policyRoomManager.getEditablePolicyRoomIDs( + const editablePolicyRoomIDs = draupnir.policyRoomManager.getEditablePolicyRoomIDs( draupnir.clientUserID, PolicyRuleType.User ); @@ -211,7 +211,7 @@ export class BanPropagationProtection extends AbstractProtection implements Drau 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.managerManager.policyRoomManager.getPolicyRoomEditor(roomID.ok) + 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; @@ -233,7 +233,7 @@ export class BanPropagationProtection extends AbstractProtection implements Drau 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.managerManager.policyRoomManager.getEditablePolicyRoomIDs(this.draupnir.clientUserID, PolicyRuleType.User); + 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) { @@ -241,7 +241,7 @@ export class BanPropagationProtection extends AbstractProtection implements Drau 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.managerManager.policyRoomManager.getPolicyRoomEditor(editablePolicyRoom); + const editorResult = await this.draupnir.policyRoomManager.getPolicyRoomEditor(editablePolicyRoom); if (isError(editorResult)) { errors.push(RoomActionError.fromActionError(editablePolicyRoom, editorResult.error)); continue; From 40f3ad4742376ab3e3fe8b7f0a7093e1634e27c9 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 11 Jan 2024 13:00:48 +0000 Subject: [PATCH 071/160] Try fixing prompt for accept TBC... We need to make commands that need a prompt to fail with an error and then we can create a prompt which basically invokes the command again + the new argument. --- src/Draupnir.ts | 1 + src/commands/CommandHandler.ts | 2 +- .../MatrixInterfaceAdaptor.ts | 5 +- .../MatrixPromptForAccept.tsx | 3 ++ .../interface-manager/MatrixPromptUX.ts | 8 +-- .../MatrixReactionHandler.ts | 50 ++++++------------- 6 files changed, 26 insertions(+), 43 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 48b759c0..7269dd41 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -143,6 +143,7 @@ export class Draupnir implements Client { public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { Task(this.joinOnInviteListener(roomID, event)); this.managementRoomMessageListener(roomID, event); + this.reactionHandler.handleEvent(roomID, event); } private managementRoomMessageListener(roomID: StringRoomID, event: RoomEvent): void { diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 9b54c10f..3a00ccbe 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -82,7 +82,7 @@ export async function handleCommand( ?? findTableCommand("mjolnir", "help"); const adaptor = findMatrixInterfaceAdaptor(command); const mjolnirContext: DraupnirContext = { - draupnir, roomID: roomID, event, client: draupnir.client, emitter: draupnir.matrixEmitter, + draupnir, roomID: roomID, event, client: draupnir.client, reactionHandler: draupnir.reactionHandler, }; try { return await adaptor.invoke(mjolnirContext, mjolnirContext, ...stream.rest()); diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 7931ab76..3728e3a8 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -36,12 +36,13 @@ import { tickCrossRenderer } from "./MatrixHelpRenderer"; import { CommandInvocationRecord, InterfaceAcceptor, PromptableArgumentStream, PromptOptions } from "./PromptForAccept"; import { ParameterDescription } from "./ParameterParsing"; import { matrixPromptForAccept } from "./MatrixPromptForAccept"; -import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { ActionError, ActionResult, ResultError, RoomEvent, RoomMessage, StringRoomID, isError } from "matrix-protection-suite"; +import { MatrixReactionHandler } from "./MatrixReactionHandler"; export interface MatrixContext { + reactionHandler: MatrixReactionHandler, client: MatrixSendClient, - emitter: SafeMatrixEmitter, roomID: StringRoomID, event: RoomMessage, } diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index 5bf889c8..8c80b95d 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -43,6 +43,9 @@ async function promptSuggestions( } +// TODO: this should be changed so that we don't keep the continuation waiting. +// instead, the original command should either be placed as context +// within the prompt or reparsed from the source event. export async function matrixPromptForAccept ( this: MatrixContext, parameter: ParameterDescription, command: InterfaceCommand, promptOptions: PromptOptions ): Promise> { diff --git a/src/commands/interface-manager/MatrixPromptUX.ts b/src/commands/interface-manager/MatrixPromptUX.ts index 13434f7b..6000534b 100644 --- a/src/commands/interface-manager/MatrixPromptUX.ts +++ b/src/commands/interface-manager/MatrixPromptUX.ts @@ -151,14 +151,10 @@ class ReactionHandler { // 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: SafeMatrixEmitter, - userID: StringUserID, - client: MatrixSendClient, + private readonly reactionHandler: ReactionHandler ) { - this.reactionHandler = new ReactionHandler(matrixEmitter, userID, client); + // nothing to do. } private indexToReactionKey(index: number): string { diff --git a/src/commands/interface-manager/MatrixReactionHandler.ts b/src/commands/interface-manager/MatrixReactionHandler.ts index f4739bd4..26225ca9 100644 --- a/src/commands/interface-manager/MatrixReactionHandler.ts +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -5,7 +5,8 @@ import { EventEmitter } from "stream"; import { LogService } from "matrix-bot-sdk"; -import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-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'; @@ -17,15 +18,12 @@ export type ReactionListener = (key: string, item: any, additionalContext: unkno * 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 +31,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,44 +62,29 @@ 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: SafeMatrixEmitter): void { - emitter.on('room.event', this.listener); - } - - /** - * Stop listening for reactions to events. - */ - public stop(emitter: SafeMatrixEmitter): void { - emitter.off('room.event', this.listener); - } - /** * Create the annotation required to setup a listener for when a reaction is encountered for the list. * @param listenerName The name of the event to emit when a reaction is encountered for a matrix event that matches a key in the `reactionMap`. From fb8f76906b6847fb2eed1c631b0ab8b1f39de39c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 12 Jan 2024 18:50:27 +0000 Subject: [PATCH 072/160] PromptForAccept refactor. We had to change the old prompt for accept since it depended on the matrix emitter. In order to remove the dependence on the event emitter, then we had to get rid of the entire continuation which waits for the prompt and make it entirely event driven instead. Changes made while here were also emoji for numbres and no longer having timeout for prompt arguments. --- src/Draupnir.ts | 23 +- src/commands/CommandHandler.ts | 9 +- .../interface-manager/InterfaceCommand.ts | 16 +- .../MatrixInterfaceAdaptor.ts | 61 +++-- .../MatrixPromptForAccept.tsx | 209 ++++++++++++++---- .../interface-manager/MatrixPromptUX.ts | 176 --------------- .../MatrixReactionHandler.ts | 38 +++- .../interface-manager/ParameterParsing.ts | 30 ++- .../interface-manager/PromptForAccept.ts | 21 +- .../interface-manager/PromptRequiredError.ts | 31 +++ 10 files changed, 322 insertions(+), 292 deletions(-) delete mode 100644 src/commands/interface-manager/MatrixPromptUX.ts create mode 100644 src/commands/interface-manager/PromptRequiredError.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 7269dd41..45d965c6 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -35,10 +35,11 @@ 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, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; +import { COMMAND_PREFIX, DraupnirContext, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; import { renderProtectionFailedToStart } from "./StandardConsequenceProvider"; 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"; const log = new Logger('Draupnir'); @@ -75,6 +76,8 @@ export class Draupnir implements Client { public readonly reactionHandler: MatrixReactionHandler; + public readonly commandContext: Omit; + private readonly timelineEventListener = this.handleTimelineEvent.bind(this); private constructor( @@ -99,6 +102,24 @@ export class Draupnir implements Client { this.reportPoller = new ReportPoller(this, this.reportManager); } this.clientRooms.on('timeline', this.timelineEventListener); + + this.commandContext = { + draupnir: this, roomID: this.managementRoomID, client: this.client, reactionHandler: this.reactionHandler, + }; + this.reactionHandler.on(ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt( + this.client, + this.managementRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + )); + this.reactionHandler.on(DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForPromptDefault( + this.client, + this.managementRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + )); } public static async makeDraupnirBot( diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 3a00ccbe..18c0da10 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -81,14 +81,15 @@ export async function handleCommand( const command = commandTable.findAMatchingCommand(stream) ?? findTableCommand("mjolnir", "help"); const adaptor = findMatrixInterfaceAdaptor(command); - const mjolnirContext: DraupnirContext = { - draupnir, roomID: roomID, event, client: draupnir.client, reactionHandler: draupnir.reactionHandler, + const draupnirContext: DraupnirContext = { + ...draupnir.commandContext, + event }; try { - return await adaptor.invoke(mjolnirContext, mjolnirContext, ...stream.rest()); + return await adaptor.invoke(draupnirContext, draupnirContext, ...stream.rest()); } catch (e) { const commandError = new ActionException(ActionExceptionKind.Unknown, e, 'Unknown Unexpected Error'); - await tickCrossRenderer.call(mjolnirContext, draupnir.client, roomID, event, ResultError(commandError)); + await tickCrossRenderer.call(draupnirContext, draupnir.client, roomID, event, ResultError(commandError)); } } catch (e) { LogService.error("CommandHandler", e); diff --git a/src/commands/interface-manager/InterfaceCommand.ts b/src/commands/interface-manager/InterfaceCommand.ts index 18323420..582365be 100644 --- a/src/commands/interface-manager/InterfaceCommand.ts +++ b/src/commands/interface-manager/InterfaceCommand.ts @@ -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 (isError(parameterDescription)) { + 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/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 3728e3a8..2ee18d31 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -33,18 +33,19 @@ import { LogService } from "matrix-bot-sdk"; import { ReadItem } from "./CommandReader"; 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, ResultError, RoomEvent, RoomMessage, StringRoomID, isError } from "matrix-protection-suite"; +import { ActionError, ActionResult, ResultError, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; import { MatrixReactionHandler } from "./MatrixReactionHandler"; +import { PromptRequiredError } from "./PromptRequiredError"; export interface MatrixContext { reactionHandler: MatrixReactionHandler, client: MatrixSendClient, roomID: StringRoomID, - event: RoomMessage, + event: RoomEvent, } export type RendererSignature = ( @@ -73,12 +74,28 @@ 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); + // 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)) { - this.reportValidationError(matrixContext.client, matrixContext.roomID, matrixContext.event, executorResult.error); - return; + 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! @@ -90,7 +107,7 @@ 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); @@ -98,34 +115,8 @@ export class MatrixInterfaceAdaptor(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; - } } -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 { diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index 8c80b95d..9cf0ae2b 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -3,61 +3,196 @@ * All rights reserved. */ -import { ActionResult, StringUserID } from "matrix-protection-suite"; +import { 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 { 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"; -async function promptDefault(this: MatrixContext, parameter: ParameterDescription, command: InterfaceCommand, defaultPrompt: PresentationType) { - await renderMatrixAndSend( +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, + 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, + 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, + 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, + commandRoomID, + reactionHandler, + annotatedEvent, + additionalCommandContext + ); + } +} + +export const ARGUMENT_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.argument_prompt'; +export function makeListenerForArgumentPrompt( + client: MatrixSendClient, + 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, + 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; - -} - -// TODO: this should be changed so that we don't keep the continuation waiting. -// instead, the original command should either be placed as context -// within the prompt or reparsed from the source event. -export async function matrixPromptForAccept ( - this: MatrixContext, parameter: ParameterDescription, command: InterfaceCommand, promptOptions: PromptOptions -): Promise> { - // FIXME: is there a better way to get the clinet ID? why isn't Draupnir in the command context? - const promptHelper = new PromptResponseListener(this.emitter, await this.client.getUserId() as StringUserID, 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, 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, - promptSuggestions.call(this, parameter, command, promptOptions.suggestions) + events[0], + reactionMap ); } diff --git a/src/commands/interface-manager/MatrixPromptUX.ts b/src/commands/interface-manager/MatrixPromptUX.ts deleted file mode 100644 index 6000534b..00000000 --- a/src/commands/interface-manager/MatrixPromptUX.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Copyright (C) 2023 Gnuxie - * All rights reserved. - */ - -import { LogService } from "matrix-bot-sdk"; -import { ActionError, ActionResult, Ok, StringUserID } from "matrix-protection-suite"; -import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-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: SafeMatrixEmitter, - private readonly userID: StringUserID, - 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 ActionError.Result(`Timed out while waiting for a response to the prompt`); - } else { - return 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 { - constructor( - private readonly reactionHandler: ReactionHandler - ) { - // nothing to do. - } - - 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 26225ca9..a204401d 100644 --- a/src/commands/interface-manager/MatrixReactionHandler.ts +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -11,7 +11,13 @@ import { ReactionEvent, RoomEvent, StringRoomID, StringUserID, Value } from "mat 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 @@ -82,7 +88,7 @@ export class MatrixReactionHandler extends EventEmitter { 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))); + this.emit(listenerName, reactionKey, association, annotation['additional_context'], new Map(Object.entries(reactionMap)), annotatedEvent); } /** @@ -115,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/ParameterParsing.ts b/src/commands/interface-manager/ParameterParsing.ts index 5ec5258b..8a3ee232 100644 --- a/src/commands/interface-manager/ParameterParsing.ts +++ b/src/commands/interface-manager/ParameterParsing.ts @@ -27,9 +27,12 @@ limitations under the License. import { ActionError, ActionResult, Ok, ResultError, isError } from "matrix-protection-suite"; import { ISuperCoolStream, Keyword, ReadItem, SuperCoolStream } from "./CommandReader"; import { PromptOptions } from "./PromptForAccept"; +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>, @@ -40,6 +43,10 @@ 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; } @@ -160,10 +167,13 @@ export class RestDescription implements ParameterDesc 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 (isError(result)) { - return result; - } + 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); @@ -389,11 +399,13 @@ class ArgumentListParser implements IArgumentListParser { } if (stream.peekItem() === undefined) { if (parameter.prompt && stream.isPromptable()) { - const promptResult = await stream.prompt(parameter); - if (isError(promptResult)) { - return promptResult; - } - 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.`, diff --git a/src/commands/interface-manager/PromptForAccept.ts b/src/commands/interface-manager/PromptForAccept.ts index 6fafbca9..b9bdcf7a 100644 --- a/src/commands/interface-manager/PromptForAccept.ts +++ b/src/commands/interface-manager/PromptForAccept.ts @@ -3,10 +3,8 @@ * All rights reserved. */ -import { ActionResult } from "matrix-protection-suite"; import { ReadItem } from "./CommandReader"; -import { BaseFunction, InterfaceCommand } from "./InterfaceCommand"; -import { ArgumentStream, ParameterDescription } from "./ParameterParsing"; +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.isOkay) { - 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())); + } +} From 1884e1b456028fb7febabd54d0054009822e885a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 12 Jan 2024 19:30:01 +0000 Subject: [PATCH 073/160] Hook the appservice bot into the reaction handler for prompts. --- src/appservice/AppService.ts | 14 ++++--- src/appservice/bot/AppserviceBotEmitter.ts | 19 ---------- .../bot/AppserviceCommandHandler.ts | 38 +++++++++++++++---- 3 files changed, 40 insertions(+), 31 deletions(-) delete mode 100644 src/appservice/bot/AppserviceBotEmitter.ts diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index d604cbd0..dbecdaab 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -35,7 +35,7 @@ import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; import { DefaultStateTrackingMeta, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringUserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; +import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringRoomID, StringUserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); @@ -61,10 +61,11 @@ export class MjolnirAppService { private readonly eventDecoder: EventDecoder, private readonly roomStateManagerFactory: RoomStateManagerFactory, private readonly clientsInRoomMap: ClientsInRoomMap, - private readonly prometheusMetrics: PrometheusMetrics + private readonly prometheusMetrics: PrometheusMetrics, + public readonly accessControlRoomID: StringRoomID, + public readonly botUserID: StringUserID, ) { this.api = new Api(config.homeserver.url, draupnirManager); - this.commands = new AppserviceCommandHandler(this); } /** @@ -119,7 +120,8 @@ export class MjolnirAppService { eventDecoder, DefaultStateTrackingMeta ); - const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(bridge.getBot().getUserId() as StringUserID); + const botUserID = bridge.getBot().getUserId() as StringUserID; + const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(botUserID); const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, bridge); if (isError(accessControl)) { throw accessControl.error; @@ -148,7 +150,9 @@ export class MjolnirAppService { eventDecoder, roomStateManagerFactory, clientsInRoomMap, - prometheus + prometheus, + accessControlRoom.ok.toRoomIDOrAlias(), + botUserID ); bridge.opts.controller = { onUserQuery: appService.onUserQuery.bind(appService), diff --git a/src/appservice/bot/AppserviceBotEmitter.ts b/src/appservice/bot/AppserviceBotEmitter.ts deleted file mode 100644 index cb83409f..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 "matrix-protection-suite-for-matrix-bot-sdk"; - - -// 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 149c1f5c..8b745648 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -10,8 +10,7 @@ import { defineMatrixInterfaceAdaptor, findMatrixInterfaceAdaptor, MatrixContext import { ArgumentStream, RestDescription, findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; import { MjolnirAppService } from '../AppService'; import { renderHelp } from '../../commands/interface-manager/MatrixHelpRenderer'; -import { AppserviceBotEmitter } from './AppserviceBotEmitter'; -import { ActionResult, Ok, RoomMessage, StringRoomID, Value, isError } from 'matrix-protection-suite'; +import { ActionResult, Ok, RoomMessage, Value, isError } from 'matrix-protection-suite'; defineCommandTable("appservice bot"); @@ -24,6 +23,8 @@ export type AppserviceBaseExecutor = (this: AppserviceContext, ...args: unknown[ import '../../commands/interface-manager/MatrixPresentations'; import './ListCommand'; import './AccessCommands'; +import { MatrixReactionHandler } from '../../commands/interface-manager/MatrixReactionHandler'; +import { ARGUMENT_PROMPT_LISTENER, DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt, makeListenerForPromptDefault } from '../../commands/interface-manager/MatrixPromptForAccept'; @@ -44,11 +45,37 @@ defineMatrixInterfaceAdaptor({ export class AppserviceCommandHandler { private readonly commandTable = findCommandTable("appservice bot"); + private commandContext: Omit; + private readonly reactionHandler: MatrixReactionHandler; constructor( 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.appservice.bridge.getBot().getClient(), + reactionHandler: this.reactionHandler, + roomID: this.appservice.accessControlRoomID + }; + this.reactionHandler.on(ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt( + this.commandContext.client, + this.appservice.accessControlRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + )); + this.reactionHandler.on(DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForPromptDefault( + this.commandContext.client, + this.appservice.accessControlRoomID, + this.reactionHandler, + this.commandTable, + this.commandContext + )); } public handleEvent(mxEvent: WeakEvent): void { @@ -68,11 +95,8 @@ export class AppserviceCommandHandler { if (command) { const adaptor = findMatrixInterfaceAdaptor(command); const context: AppserviceContext = { - appservice: this.appservice, - roomID: mxEvent.room_id as StringRoomID, + ...this.commandContext, event: parsedEvent, - client: this.appservice.bridge.getBot().getClient(), - emitter: new AppserviceBotEmitter(), }; adaptor.invoke(context, context, ...argumentStream.rest()); return; From 069dec2b9d0b5446dff27a91bbe3da2264e7343c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 12 Jan 2024 20:07:20 +0000 Subject: [PATCH 074/160] Update for MPS 0.9.1. --- src/protections/FirstMessageIsImage.ts | 5 ++++- src/protections/MessageIsMedia.ts | 3 +++ src/protections/MessageIsVoice.ts | 2 +- src/protections/WordList.ts | 3 +++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 65922dde..1b7ddd93 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -82,7 +82,10 @@ export class FirstMessageIsImageProtection extends AbstractProtection implements const roomID = room.toRoomIDOrAlias(); if (!this.justJoined[roomID]) this.justJoined[roomID] = []; if (Value.Check(RoomMessage, event)) { - const msgtype = event.content?.['msgtype'] || 'm.text'; + 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('> { 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('> { const roomID = room.toRoomIDOrAlias(); if (Value.Check(RoomMessage, event)) { - if (event.content?.msgtype !== 'm.audio') { + 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)])}`); diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index f3dbc778..3c20a0ce 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -88,6 +88,9 @@ export class WordListProtection extends AbstractProtection implements Protection 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); From 3050fc6f2260614dec24dd0f92ace6909abad68e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 12 Jan 2024 20:14:41 +0000 Subject: [PATCH 075/160] Remove event emitter from ReportManager. --- src/Draupnir.ts | 1 + src/report/ReportManager.ts | 21 +++++++-------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 45d965c6..9d0994ce 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -196,6 +196,7 @@ export class Draupnir implements Client { 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); } } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index ab55fd4d..cb84f11b 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -32,7 +32,7 @@ import { htmlEscape } from "../utils"; import { JSDOM } from 'jsdom'; import { EventEmitter } from 'events'; import { Draupnir } from "../Draupnir"; -import { ReactionContent, RoomEvent, StringEventID, StringRoomID, Value, isError } from "matrix-protection-suite"; +import { ReactionContent, RoomEvent, StringEventID, StringRoomID, 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]`. @@ -90,22 +90,15 @@ export class ReportManager extends EventEmitter { private displayManager: DisplayManager; constructor(public draupnir: Draupnir) { super(); - // Configure bot interactions. - draupnir.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); - } - }); this.displayManager = new DisplayManager(this); } + public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + if (event.type === 'm.reaction') { + Task(this.handleReaction({ roomID, event })); + } + } + /** * Display an incoming abuse report received, e.g. from the /report Matrix API. * From ea4cdd0ddafb7d9ce9107c8a208a3a477150a78a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 12 Jan 2024 20:27:01 +0000 Subject: [PATCH 076/160] Web API's use MPS event and room IDs. Still need to figure out the plumbing of starting this and the report poller in integration tests and from index without being started from Draupnir's class. --- src/webapis/WebAPIs.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 731f71fe..a1507236 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -30,6 +30,7 @@ import express from "express"; import { MatrixClient } from "matrix-bot-sdk"; import { ReportManager } from "../report/ReportManager"; import { IConfig } from "../config"; +import { StringEventID, StringRoomID } from "matrix-protection-suite"; /** @@ -74,7 +75,7 @@ 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`); } @@ -98,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; @@ -175,16 +176,16 @@ 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); } } From 46ae7c24c6291d272a27131d7779f19d3a6b5d57 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 24 Jan 2024 16:07:29 +0000 Subject: [PATCH 077/160] Update DefaultTrackingMeta switching packages in MPS. --- src/DraupnirBotMode.ts | 2 +- src/appservice/AppService.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 95da128b..d5657599 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -34,9 +34,9 @@ import { isStringRoomID, StandardClientsInRoomMap, DefaultEventDecoder, + DefaultStateTrackingMeta, } from "matrix-protection-suite"; import { - DefaultStateTrackingMeta, MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index dbecdaab..21ae76bf 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -34,8 +34,8 @@ import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; -import { DefaultStateTrackingMeta, RoomStateManagerFactory, 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 { RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ClientsInRoomMap, DefaultEventDecoder, DefaultStateTrackingMeta, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringRoomID, StringUserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); From a21f2682d3ae2bdbf253b907c4677f8c36420864 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 26 Jan 2024 13:55:38 +0000 Subject: [PATCH 078/160] Update for MPS's removal of `StateTrackingMeta`. --- src/DraupnirBotMode.ts | 6 ++---- src/appservice/AppService.ts | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index d5657599..c0df0ffc 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -33,8 +33,7 @@ import { isStringRoomAlias, isStringRoomID, StandardClientsInRoomMap, - DefaultEventDecoder, - DefaultStateTrackingMeta, + DefaultEventDecoder } from "matrix-protection-suite"; import { MatrixSendClient, @@ -81,8 +80,7 @@ export async function makeDraupnirBotModeFromConfig( const roomStateManagerFactory = new RoomStateManagerFactory( clientsInRoomMap, clientProvider, - DefaultEventDecoder, - DefaultStateTrackingMeta + DefaultEventDecoder ); const draupnirFactory = new DraupnirFactory( clientProvider, diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 21ae76bf..aa0a7f24 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -35,7 +35,7 @@ import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; import { RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ClientsInRoomMap, DefaultEventDecoder, DefaultStateTrackingMeta, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringRoomID, StringUserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; +import { ClientsInRoomMap, DefaultEventDecoder, EventDecoder, MatrixRoomReference, StandardClientsInRoomMap, StringRoomID, StringUserID, isError, isStringRoomAlias, isStringRoomID } from "matrix-protection-suite"; import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager"; const log = new Logger("AppService"); @@ -117,8 +117,7 @@ export class MjolnirAppService { const roomStateManagerFactory = new RoomStateManagerFactory( clientsInRoomMap, clientProvider, - eventDecoder, - DefaultStateTrackingMeta + eventDecoder ); const botUserID = bridge.getBot().getUserId() as StringUserID; const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(botUserID); From 1a371344598ae5cd5b5687869931bd7ff4351f14 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 3 Feb 2024 13:57:59 +0000 Subject: [PATCH 079/160] Ensure the enabled BanPropagationProtection by default (MPS). We needed to add support that enabled this into MPS. the `MatrixDataManager` has been removed because it is redundant with the `SchemedDataManager` from MPS. --- .../DraupnirProtectedRoomsSet.ts | 4 +- src/models/MatrixDataManager.ts | 55 ------------------- .../DefaultEnabledProtectionsMigration.ts | 22 ++++++++ 3 files changed, 25 insertions(+), 56 deletions(-) delete mode 100644 src/models/MatrixDataManager.ts create mode 100644 src/protections/DefaultEnabledProtectionsMigration.ts diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index ddf0e13e..1f3088f0 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -27,6 +27,7 @@ limitations under the License. import { MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, BotSDKMatrixStateData, BotSDKMjolnirProtectedRoomsStore, BotSDKMjolnirWatchedPolicyRoomsStore, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration"; async function makePolicyListConfig( client: MatrixSendClient, @@ -117,7 +118,8 @@ async function makeProtectionConfig( MjolnirProtectionSettingsEventType, result.ok, client - ) + ), + DefaultEnabledProtectionsMigration, ) } 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/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts new file mode 100644 index 00000000..b01f8c26 --- /dev/null +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -0,0 +1,22 @@ +/** + * Copyright (C) 2023-2024 Gnuxie + * All rights reserved. + */ + +import { ActionError,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value } from "matrix-protection-suite"; + +export const DefaultEnabledProtectionsMigration = new SchemedDataManager([ + async function enableBanPropagationByDefault(input) { + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const enabled = new Set(input.enabled); + enabled.add('BanPropagationProtection'); + return Ok({ + enabled: [...enabled], + [DRAUPNIR_SCHEMA_VERSION_KEY]: 1, + }); + }, +]); From c6f198303dbb0fc8da1d535607cc3da24c28c5fa Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 3 Feb 2024 15:25:53 +0000 Subject: [PATCH 080/160] Start fixes to dev environment --- test/integration/banListTest.ts | 8 ++++---- test/integration/mjolnirSetupUtils.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts index a0320720..2aa65be4 100644 --- a/test/integration/banListTest.ts +++ b/test/integration/banListTest.ts @@ -11,7 +11,7 @@ import AccessControlUnit, { Access, EntityAccess } from "../../src/models/Access import { randomUUID } from "crypto"; import { MatrixSendClient } from "../../src/MatrixEmitter"; import { MatrixRoomReference } from "../../src/commands/interface-manager/MatrixRoomReference"; -import { MjolnirTestContext } from "./mjolnirSetupUtils"; +import { DraupnirTestContext } from "./mjolnirSetupUtils"; /** * Create a policy rule in a policy room. @@ -598,11 +598,11 @@ describe('Test: Creating policy lists.', function() { }) 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) { + it('Failing to ban a moderator should not stop other members being banned.', async function(this: DraupnirTestContext) { + if (this.draupnir === undefined) { throw new TypeError("Mjolnir was never created.") } - const mjolnir: Mjolnir = this.mjolnir; + const mjolnir: Mjolnir = this.draupnir; const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "mx-moderator" } }); await moderator.joinRoom(mjolnir.managementRoomId); const mjolnirId = await mjolnir.client.getUserId(); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index d1a7947d..a1bd5761 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -21,15 +21,18 @@ 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 } from "matrix-protection-suite"; patchMatrixClient(); -export interface MjolnirTestContext extends Mocha.Context { - mjolnir?: Mjolnir +export interface DraupnirTestContext extends Mocha.Context { + draupnir?: Draupnir } /** @@ -68,19 +71,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): Promise { await configureMjolnir(config); LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); @@ -89,7 +92,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); globalClient = client; globalMjolnir = mj; return mj; From 8dadea75328164566c581e705e8140177f6ae91c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 3 Feb 2024 20:49:56 +0000 Subject: [PATCH 081/160] Fix inverted boolean expression for validating managementRoomID. --- src/DraupnirBotMode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index c0df0ffc..7e751b38 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -62,7 +62,7 @@ export async function makeDraupnirBotModeFromConfig( if (!isStringUserID(clientUserId)) { throw new TypeError(`${clientUserId} is not a valid mxid`); } - if (!isStringRoomAlias(config.managementRoom) || !isStringRoomID(config.managementRoom)) { + 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); From fdc396c3866c6c40a89b03d572f08973a9e5c46c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 3 Feb 2024 20:50:29 +0000 Subject: [PATCH 082/160] Fix missing designators in protections and power level commands. --- src/commands/ProtectionsCommands.tsx | 2 +- src/commands/SetPowerLevelCommand.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/ProtectionsCommands.tsx b/src/commands/ProtectionsCommands.tsx index 25eb837c..85b3c3d7 100644 --- a/src/commands/ProtectionsCommands.tsx +++ b/src/commands/ProtectionsCommands.tsx @@ -254,7 +254,7 @@ async function settingChangeSummaryRenderer(this: unknown, client: MatrixSendCli for (const designator of ["add", "set", "remove"]) { defineMatrixInterfaceAdaptor({ - interfaceCommand: findTableCommand("mjolnir", "protections", designator), + interfaceCommand: findTableCommand("mjolnir", "protections", "config", designator), renderer: settingChangeSummaryRenderer, }) } diff --git a/src/commands/SetPowerLevelCommand.ts b/src/commands/SetPowerLevelCommand.ts index d5e6b87a..369b862e 100644 --- a/src/commands/SetPowerLevelCommand.ts +++ b/src/commands/SetPowerLevelCommand.ts @@ -56,8 +56,8 @@ async function setPowerLevelCommand( } defineInterfaceCommand({ - table: "", - designator: [], + table: "mjolnir", + designator: ["powerlevel"], parameters: parameters([ { name: "user", From 7128e3f10cbd1b77263a191c239bd4f7c5da76f8 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 14:56:59 +0000 Subject: [PATCH 083/160] Update for changes to Mjolnir AccountData in MPS. --- src/draupnirfactory/DraupnirProtectedRoomsSet.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index 1f3088f0..364b346a 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -25,8 +25,8 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; -import { BotSDKMatrixAccountData, BotSDKMatrixStateData, BotSDKMjolnirProtectedRoomsStore, BotSDKMjolnirWatchedPolicyRoomsStore, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration"; async function makePolicyListConfig( @@ -34,7 +34,9 @@ async function makePolicyListConfig( policyRoomManager: PolicyRoomManager ): Promise { const result = await MjolnirPolicyRoomsConfig.createFromStore( - new BotSDKMjolnirWatchedPolicyRoomsStore( + new BotSDKMatrixAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MjolnirWatchedPolicyRoomsEvent, client ), policyRoomManager, @@ -59,7 +61,9 @@ async function makeProtectedRoomsConfig( client: MatrixSendClient, ): Promise { const result = await MjolnirProtectedRoomsConfig.createFromStore( - new BotSDKMjolnirProtectedRoomsStore( + new BotSDKMatrixAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MjolnirProtectedRoomsEvent, client ) ); From 1d94599c688542d93aebaf972512891cf2068823 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 14:57:20 +0000 Subject: [PATCH 084/160] Ensure sure we join the management room at startup. --- src/DraupnirBotMode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 7e751b38..66457786 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -70,6 +70,7 @@ export async function makeDraupnirBotModeFromConfig( 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) { From 5c61bc262683801a1377d3dda88d6a5b4ff8f5a3 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 17:03:49 +0000 Subject: [PATCH 085/160] Make errors more obvious at Draupnir MPS startup. --- src/DraupnirBotMode.ts | 10 ++- src/draupnirfactory/DraupnirFactory.ts | 9 +- .../DraupnirProtectedRoomsSet.ts | 84 ++++++++++--------- 3 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 66457786..41dface6 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -33,9 +33,11 @@ import { isStringRoomAlias, isStringRoomID, StandardClientsInRoomMap, - DefaultEventDecoder + DefaultEventDecoder, + setGlobalLoggerProvider } from "matrix-protection-suite"; import { + BotSDKLogServiceLogger, MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, @@ -45,6 +47,8 @@ import { IConfig } from "./config"; import { Draupnir } from "./Draupnir"; import { DraupnirFactory } from "./draupnirfactory/DraupnirFactory"; +setGlobalLoggerProvider(new BotSDKLogServiceLogger()); + /** * This is a file for providing default concrete implementations * for all things to bootstrap Draupnir in 'bot mode'. @@ -84,6 +88,7 @@ export async function makeDraupnirBotModeFromConfig( DefaultEventDecoder ); const draupnirFactory = new DraupnirFactory( + clientsInRoomMap, clientProvider, roomStateManagerFactory ); @@ -93,7 +98,8 @@ export async function makeDraupnirBotModeFromConfig( config ); if (isError(draupnir)) { - throw draupnir.error; + const error = draupnir.error; + throw new Error(`Unable to create Draupnir: ${error.message}`); } matrixEmitter.on('room.event', (roomID, event) => { roomStateManagerFactory.handleTimelineEvent(roomID, event); diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index e30aef50..9f40d816 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -3,7 +3,7 @@ * All rights reserved. */ -import { ActionResult, MatrixRoomID, Ok, StringUserID, isError } from "matrix-protection-suite"; +import { ActionResult, ClientsInRoomMap, MatrixRoomID, Ok, StringUserID, isError } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { ClientForUserID, RoomStateManagerFactory, joinedRoomsSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirClientRooms } from "./DraupnirClientRooms"; @@ -12,6 +12,7 @@ import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet"; export class DraupnirFactory { public constructor( + private readonly clientsInRoomMap: ClientsInRoomMap, private readonly clientProvider: ClientForUserID, private readonly roomStateManagerFactory: RoomStateManagerFactory ) { @@ -31,6 +32,7 @@ export class DraupnirFactory { if (isError(clientRooms)) { return clientRooms; } + this.clientsInRoomMap.addClientRooms(clientRooms.ok); const protectedRoomsSet = await makeProtectedRoomsSet( managementRoom, roomStateManager, @@ -39,12 +41,15 @@ export class DraupnirFactory { client, clientUserID ); + if (isError(protectedRoomsSet)) { + return protectedRoomsSet; + } return Ok(await Draupnir.makeDraupnirBot( client, clientUserID, managementRoom, clientRooms.ok, - protectedRoomsSet, + protectedRoomsSet.ok, roomStateManager, policyRoomManager, roomMembershipManager, diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index 364b346a..ba53cdd6 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -25,14 +25,14 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { ActionResult, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsConfig, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration"; async function makePolicyListConfig( client: MatrixSendClient, policyRoomManager: PolicyRoomManager -): Promise { +): Promise> { const result = await MjolnirPolicyRoomsConfig.createFromStore( new BotSDKMatrixAccountData( MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, @@ -51,68 +51,53 @@ async function makePolicyListConfig( } } ); - if (isError(result)) { - throw result.error; - } - return result.ok; + return result; } async function makeProtectedRoomsConfig( client: MatrixSendClient, -): Promise { - const result = await MjolnirProtectedRoomsConfig.createFromStore( +): Promise> { + return await MjolnirProtectedRoomsConfig.createFromStore( new BotSDKMatrixAccountData( MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MjolnirProtectedRoomsEvent, client ) ); - if (isError(result)) { - throw result.error; - } - return result.ok; } async function makeSetMembership( roomMembershipManager: RoomMembershipManager, protectedRoomsConfig: ProtectedRoomsConfig -): Promise { - const membershipSet = await StandardSetMembership.create( +): Promise> { + return await StandardSetMembership.create( roomMembershipManager, protectedRoomsConfig ); - if (isError(membershipSet)) { - throw membershipSet.error; - } - return membershipSet.ok; } async function makeSetRoomState( roomStateManager: RoomStateManager, protectedRoomsConfig: ProtectedRoomsConfig -): Promise { - const setRoomState = await StandardSetRoomState.create( +): Promise> { + return await StandardSetRoomState.create( roomStateManager, protectedRoomsConfig ); - if (isError(setRoomState)) { - throw setRoomState.error; - } - return setRoomState.ok; } async function makeProtectionConfig( client: MatrixSendClient, roomStateManager: RoomStateManager, managementRoom: MatrixRoomID -) { +): Promise> { const result = await roomStateManager.getRoomStateRevisionIssuer( managementRoom ); if (isError(result)) { - throw result.error; + return result; } - return new MjolnirProtectionsConfig( + return Ok(new MjolnirProtectionsConfig( new BotSDKMatrixAccountData( MjolnirEnabledProtectionsEventType, MjolnirEnabledProtectionsEvent, @@ -124,7 +109,7 @@ async function makeProtectionConfig( client ), DefaultEnabledProtectionsMigration, - ) + )); } @@ -135,27 +120,44 @@ export async function makeProtectedRoomsSet( roomMembershipManager: RoomMembershipManager, client: MatrixSendClient, userID: StringUserID -): Promise { +): Promise> { const protectedRoomsConfig = await makeProtectedRoomsConfig(client) + if (isError(protectedRoomsConfig)) { + return protectedRoomsConfig; + } const setRoomState = await makeSetRoomState( roomStateManager, - protectedRoomsConfig + protectedRoomsConfig.ok ); + if (isError(setRoomState)) { + return setRoomState; + } const membershipSet = await makeSetMembership( roomMembershipManager, - protectedRoomsConfig + protectedRoomsConfig.ok + ); + if (isError(membershipSet)) { + return membershipSet; + } + const policyListConfig = await makePolicyListConfig(client, policyRoomManager); + if (isError(policyListConfig)) { + return policyListConfig; + } + const protectionsConfig = await makeProtectionConfig( + client, + roomStateManager, + managementRoom ); + if (isError(protectionsConfig)) { + return protectionsConfig; + } const protectedRoomsSet = new StandardProtectedRoomsSet( - await makePolicyListConfig(client, policyRoomManager), - protectedRoomsConfig, - await makeProtectionConfig( - client, - roomStateManager, - managementRoom - ), - membershipSet, - setRoomState, + policyListConfig.ok, + protectedRoomsConfig.ok, + protectionsConfig.ok, + membershipSet.ok, + setRoomState.ok, userID, ); - return protectedRoomsSet; + return Ok(protectedRoomsSet); } From 34114353a8d47343e5b278f7357cf657892d9808 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 17:42:19 +0000 Subject: [PATCH 086/160] Update for MPS ProtectionFailedToStartCB changing. We added the protectionName to the callback. --- src/Draupnir.ts | 4 ++-- src/StandardConsequenceProvider.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 9d0994ce..4d4c8af7 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -151,8 +151,8 @@ export class Draupnir implements Client { const loadResult = await protectedRoomsSet.protections.loadProtections( protectedRoomsSet, draupnir, - (error, description) => renderProtectionFailedToStart( - client, managementRoom.toRoomIDOrAlias(), error, description + (error, protectionName, description) => renderProtectionFailedToStart( + client, managementRoom.toRoomIDOrAlias(), error, protectionName, description ) ); if (isError(loadResult)) { diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx index c81be7df..591f8d99 100644 --- a/src/StandardConsequenceProvider.tsx +++ b/src/StandardConsequenceProvider.tsx @@ -214,11 +214,12 @@ export async function renderProtectionFailedToStart( client: MatrixSendClient, managementRoomID: StringRoomID, error: ActionError, - protectionDescription?: ProtectionDescription + protectionName: string, + _protectionDescription?: ProtectionDescription ): Promise { await renderMatrixAndSend( - A protection {protectionDescription?.name} failed to start for the following reason: + A protection {protectionName} failed to start for the following reason: {error.message} , managementRoomID, From 9658f39769de49e578c63ffc5de0115787438939 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 17:54:10 +0000 Subject: [PATCH 087/160] Add `` to `DeadDocument`. --- src/commands/interface-manager/DeadDocument.ts | 1 + src/commands/interface-manager/DeadDocumentHtml.ts | 4 +++- src/commands/interface-manager/DeadDocumentMarkdown.ts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/interface-manager/DeadDocument.ts b/src/commands/interface-manager/DeadDocument.ts index 08a27f57..4e209290 100644 --- a/src/commands/interface-manager/DeadDocument.ts +++ b/src/commands/interface-manager/DeadDocument.ts @@ -67,6 +67,7 @@ export enum NodeTag { Details = 'details', Summary = 'summary', Font = 'font', + Span = 'span', } /** 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..5c84d88c 100644 --- a/src/commands/interface-manager/DeadDocumentMarkdown.ts +++ b/src/commands/interface-manager/DeadDocumentMarkdown.ts @@ -145,4 +145,7 @@ MARKDOWN_RENDERER.registerRenderer Date: Sun, 4 Feb 2024 17:54:26 +0000 Subject: [PATCH 088/160] Start client in manual launch script. Not doing this would just cause draupnir to exit. --- test/integration/manualLaunchScript.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index eee1f094..634184e7 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -2,11 +2,12 @@ * 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'; (async () => { const config = configRead(); let mjolnir = await makeMjolnir(config); await mjolnir.start(); + await draupnirClient()?.start(); })(); From 73f2910b8edf22168e4d6ac9727966037de13708 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 18:26:13 +0000 Subject: [PATCH 089/160] Stop stripping prefixes in CommandHandler, they're already stripped. --- src/commands/CommandHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 18c0da10..5dddb84e 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -76,7 +76,7 @@ export async function handleCommand( commandTable: CommandTable ) { try { - const readItems = readCommand(normalisedCommand).slice(1); // remove "!mjolnir" + const readItems = readCommand(normalisedCommand) const stream = new ArgumentStream(readItems); const command = commandTable.findAMatchingCommand(stream) ?? findTableCommand("mjolnir", "help"); From bf6f27e0799a93af7fd2f5f807a984c77ff1fc2c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 18:26:49 +0000 Subject: [PATCH 090/160] Fix StatusCommand accidentally returning a promise. --- src/commands/StatusCommand.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index a6186f9e..b0b5ab21 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -40,8 +40,8 @@ defineInterfaceCommand({ designator: ["status"], table: "mjolnir", parameters: parameters([]), - command: async function (this: DraupnirContext) { - return Ok(draupnirStatusInfo(this.draupnir)) + command: async function (this: DraupnirContext): Promise> { + return Ok(await draupnirStatusInfo(this.draupnir)) }, summary: "Show the status of the bot." }) From b8606ce07355742d1388719dc5b772c5e1d76518 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 4 Feb 2024 18:47:57 +0000 Subject: [PATCH 091/160] Make CreateBanListCommand consistent with Mjolnir again. This was changed during MPS work, it won't work like this anyways, since we need the alias name, not the fully qualified alias. --- src/commands/CreateBanListCommand.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/commands/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 297f33cf..48dd68f4 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, MatrixRoomAlias, MatrixRoomID, PropagationType, isError } from "matrix-protection-suite"; +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"; @@ -36,14 +36,13 @@ export async function createList( this: DraupnirContext, _keywords: ParsedKeywords, shortcode: string, - alias: MatrixRoomAlias, - ...reasonParts: string[] + aliasName: string, ): Promise> { const newList = await this.draupnir.policyRoomManager.createPolicyRoom( shortcode, [this.event.sender], { - room_alias_name: alias.toRoomIDOrAlias() + room_alias_name: aliasName } ); if (isError(newList)) { @@ -65,8 +64,8 @@ defineInterfaceCommand({ acceptor: findPresentationType("string"), }, { - name: "alias", - acceptor: findPresentationType("MatrixRoomAlias"), + name: "alias name", + acceptor: findPresentationType("string"), }, ]), command: createList, From 7aca44167f5d32f60e6e160a0294db47be6cb47b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 9 Feb 2024 16:16:10 +0000 Subject: [PATCH 092/160] Fix: wrong event type was being given for watched policy lists data. --- src/draupnirfactory/DraupnirProtectedRoomsSet.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index ba53cdd6..60998138 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsConfig, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { ActionResult, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsConfig, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration"; @@ -35,7 +35,7 @@ async function makePolicyListConfig( ): Promise> { const result = await MjolnirPolicyRoomsConfig.createFromStore( new BotSDKMatrixAccountData( - MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MjolnirWatchedPolicyRoomsEvent, client ), From 4366a6df4671636ddd15914ae0b9c8bc8f19d87e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 15 Feb 2024 18:14:28 +0000 Subject: [PATCH 093/160] MPS `ActionError['addContext']` got renamed to `elaborate`. --- src/commands/Rooms.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/Rooms.tsx b/src/commands/Rooms.tsx index 2e4ed6af..9ca427ef 100644 --- a/src/commands/Rooms.tsx +++ b/src/commands/Rooms.tsx @@ -85,7 +85,7 @@ defineInterfaceCommand({ command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { const room = await resolveRoomReferenceSafe(this.client, roomRef); if (isError(room)) { - return room.addContext( + 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.`, ); @@ -108,7 +108,7 @@ defineInterfaceCommand({ command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { const room = await resolveRoomReferenceSafe(this.client, roomRef); if (isError(room)) { - return room.addContext( + 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.`, ); From 426879fb68c417c7d8f0e3e8ec0093390506d452 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 16 Feb 2024 14:36:14 +0000 Subject: [PATCH 094/160] Give commands access to MPS `ClientPlatform`. --- src/Draupnir.ts | 9 ++++++-- src/DraupnirBotMode.ts | 3 +++ src/appservice/AppService.ts | 22 +++++++++++++++++-- src/appservice/AppServiceDraupnirManager.ts | 16 ++++++++++++-- .../bot/AppserviceCommandHandler.ts | 19 ++++++++++------ .../MatrixInterfaceAdaptor.ts | 8 ++++++- .../MatrixPromptForAccept.tsx | 8 ++++++- src/draupnirfactory/DraupnirFactory.ts | 5 ++++- 8 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 4d4c8af7..bd722795 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Client, 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 { 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"; @@ -83,6 +83,7 @@ export class Draupnir implements Client { 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, @@ -104,10 +105,11 @@ export class Draupnir implements Client { this.clientRooms.on('timeline', this.timelineEventListener); this.commandContext = { - draupnir: this, roomID: this.managementRoomID, client: this.client, reactionHandler: this.reactionHandler, + 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, @@ -115,6 +117,7 @@ export class Draupnir implements Client { )); this.reactionHandler.on(DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForPromptDefault( this.client, + this.clientPlatform, this.managementRoomID, this.reactionHandler, this.commandTable, @@ -125,6 +128,7 @@ export class Draupnir implements Client { public static async makeDraupnirBot( client: MatrixSendClient, clientUserID: StringUserID, + clientPlatform: ClientPlatform, managementRoom: MatrixRoomID, clientRooms: ClientRooms, protectedRoomsSet: ProtectedRoomsSet, @@ -136,6 +140,7 @@ export class Draupnir implements Client { const draupnir = new Draupnir( client, clientUserID, + clientPlatform, managementRoom, clientRooms, config, diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 41dface6..f34c558d 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -38,6 +38,7 @@ import { } from "matrix-protection-suite"; import { BotSDKLogServiceLogger, + ClientCapabilityFactory, MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, @@ -87,8 +88,10 @@ export async function makeDraupnirBotModeFromConfig( clientProvider, DefaultEventDecoder ); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); const draupnirFactory = new DraupnirFactory( clientsInRoomMap, + clientCapabilityFactory, clientProvider, roomStateManagerFactory ); diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index aa0a7f24..8430f402 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -34,7 +34,7 @@ import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; -import { RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ClientCapabilityFactory, RoomStateManagerFactory, 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"; @@ -60,12 +60,20 @@ export class MjolnirAppService { private readonly dataStore: DataStore, private readonly eventDecoder: EventDecoder, private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly clientCapabilityFactory: ClientCapabilityFactory, private readonly clientsInRoomMap: ClientsInRoomMap, private readonly prometheusMetrics: PrometheusMetrics, public readonly accessControlRoomID: StringRoomID, public readonly botUserID: StringUserID, ) { this.api = new Api(config.homeserver.url, draupnirManager); + const client = this.bridge.getBot().getClient(); + this.commands = new AppserviceCommandHandler( + botUserID, + client, + this.clientCapabilityFactory.makeClientPlatform(botUserID, client), + this + ); } /** @@ -119,6 +127,7 @@ export class MjolnirAppService { clientProvider, eventDecoder ); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); const botUserID = bridge.getBot().getUserId() as StringUserID; const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(botUserID); const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, bridge); @@ -139,7 +148,15 @@ export class MjolnirAppService { }); const serverName = config.homeserver.domain; - const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager(serverName, dataStore, bridge, accessControl.ok, roomStateManagerFactory, instanceCountGauge); + const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager( + serverName, + dataStore, + bridge, + accessControl.ok, + roomStateManagerFactory, + clientCapabilityFactory, + instanceCountGauge + ); const appService = new MjolnirAppService( config, bridge, @@ -148,6 +165,7 @@ export class MjolnirAppService { dataStore, eventDecoder, roomStateManagerFactory, + clientCapabilityFactory, clientsInRoomMap, prometheus, accessControlRoom.ok.toRoomIDOrAlias(), diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index 879f988a..3870c7ed 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -35,7 +35,7 @@ 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 { RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ClientCapabilityFactory, RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirFailType, StandardDraupnirManager, UnstartedDraupnir } from "../draupnirfactory/StandardDraupnirManager"; import { DraupnirFactory } from "../draupnirfactory/DraupnirFactory"; @@ -58,10 +58,13 @@ export class AppServiceDraupnirManager { private readonly bridge: Bridge, private readonly accessControl: AccessControl, private readonly roomStateManagerFactory: RoomStateManagerFactory, + private readonly clientCapabilityFactory: ClientCapabilityFactory, private readonly instanceCountGauge: Gauge<"status" | "uuid"> ) { const clientProvider = this.bridge.getIntent.bind(this.bridge); const draupnirFactory = new DraupnirFactory( + this.roomStateManagerFactory.clientsInRoomMap, + this.clientCapabilityFactory, clientProvider, this.roomStateManagerFactory ); @@ -88,9 +91,18 @@ export class AppServiceDraupnirManager { bridge: Bridge, accessControl: AccessControl, roomStateManagerFactory: RoomStateManagerFactory, + clientCapabilityFactory: ClientCapabilityFactory, instanceCountGauge: Gauge<"status" | "uuid"> ): Promise { - const draupnirManager = new AppServiceDraupnirManager(serverName, dataStore, bridge, accessControl, roomStateManagerFactory, instanceCountGauge); + const draupnirManager = new AppServiceDraupnirManager( + serverName, + dataStore, + bridge, + accessControl, + roomStateManagerFactory, + clientCapabilityFactory, + instanceCountGauge + ); await draupnirManager.startDraupnirs(await dataStore.list()); return draupnirManager; } diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 8b745648..ea230410 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -10,7 +10,10 @@ import { defineMatrixInterfaceAdaptor, findMatrixInterfaceAdaptor, MatrixContext import { ArgumentStream, RestDescription, findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; import { MjolnirAppService } from '../AppService'; import { renderHelp } from '../../commands/interface-manager/MatrixHelpRenderer'; -import { ActionResult, Ok, RoomMessage, Value, isError } from 'matrix-protection-suite'; +import { ActionResult, ClientPlatform, Ok, RoomMessage, 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"); @@ -23,10 +26,6 @@ export type AppserviceBaseExecutor = (this: AppserviceContext, ...args: unknown[ import '../../commands/interface-manager/MatrixPresentations'; import './ListCommand'; import './AccessCommands'; -import { MatrixReactionHandler } from '../../commands/interface-manager/MatrixReactionHandler'; -import { ARGUMENT_PROMPT_LISTENER, DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt, makeListenerForPromptDefault } from '../../commands/interface-manager/MatrixPromptForAccept'; - - defineInterfaceCommand({ parameters: parameters([], new RestDescription('command parts', findPresentationType("any"))), @@ -49,7 +48,10 @@ export class AppserviceCommandHandler { private readonly reactionHandler: MatrixReactionHandler; constructor( - private readonly appservice: MjolnirAppService + public readonly clientUserID: StringUserID, + private readonly client: MatrixSendClient, + private readonly clientPlatform: ClientPlatform, + private readonly appservice: MjolnirAppService, ) { this.reactionHandler = new MatrixReactionHandler( this.appservice.accessControlRoomID, @@ -58,12 +60,14 @@ export class AppserviceCommandHandler { ); this.commandContext = { appservice: this.appservice, - client: this.appservice.bridge.getBot().getClient(), + 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, @@ -71,6 +75,7 @@ export class AppserviceCommandHandler { )); this.reactionHandler.on(DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForPromptDefault( this.commandContext.client, + this.clientPlatform, this.appservice.accessControlRoomID, this.reactionHandler, this.commandTable, diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 2ee18d31..8cb786fd 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -37,13 +37,19 @@ import { InterfaceAcceptor, PromptOptions, PromptableArgumentStream } from "./Pr import { ParameterDescription } from "./ParameterParsing"; import { promptDefault, promptSuggestions } from "./MatrixPromptForAccept"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionError, ActionResult, ResultError, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; +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, + // 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, } diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index 9cf0ae2b..87abdc33 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -3,7 +3,7 @@ * All rights reserved. */ -import { Logger, RoomEvent, StringRoomID, Task, Value, isError } from "matrix-protection-suite"; +import { ClientPlatform, Logger, RoomEvent, StringRoomID, Task, Value, isError } from "matrix-protection-suite"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { BaseFunction, CommandTable, InterfaceCommand } from "./InterfaceCommand"; import { JSXFactory } from "./JSXFactory"; @@ -39,6 +39,7 @@ function continueCommandAcceptingPrompt( client: MatrixSendClient, + clientPlatform: ClientPlatform, commandRoomID: StringRoomID, reactionHandler: MatrixReactionHandler, commandTable: CommandTable, @@ -88,6 +91,7 @@ export function makeListenerForPromptDefault( client: MatrixSendClient, + clientPlatform: ClientPlatform, commandRoomID: StringRoomID, reactionHandler: MatrixReactionHandler, commandTable: CommandTable, @@ -115,6 +120,7 @@ export function makeListenerForArgumentPrompt Date: Fri, 16 Feb 2024 14:36:43 +0000 Subject: [PATCH 095/160] use MPS `ClientPlatform` to use preemptive joiner in Rooms commands. --- src/commands/Rooms.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/Rooms.tsx b/src/commands/Rooms.tsx index 9ca427ef..aabbaccd 100644 --- a/src/commands/Rooms.tsx +++ b/src/commands/Rooms.tsx @@ -83,7 +83,8 @@ defineInterfaceCommand({ } ]), command: async function (this: DraupnirContext, _keywords, roomRef: MatrixRoomReference): Promise> { - const room = await resolveRoomReferenceSafe(this.client, roomRef); + 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.\ From 0c2d391ff41e6b55d2632cdd96263af2335bc034 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 16 Feb 2024 14:49:25 +0000 Subject: [PATCH 096/160] Fix inverted boolean logic in CommandReader. We couldn't read room references. --- src/commands/interface-manager/CommandReader.ts | 2 +- test/commands/CommandReaderTest.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/interface-manager/CommandReader.ts b/src/commands/interface-manager/CommandReader.ts index 283834ec..45f46767 100644 --- a/src/commands/interface-manager/CommandReader.ts +++ b/src/commands/interface-manager/CommandReader.ts @@ -210,7 +210,7 @@ function readRoomIDOrAlias(stream: StringStream): MatrixRoomReference|string { } readUntil(/\s/, stream, word); const wholeWord = word.join(''); - if (!isStringRoomID(wholeWord) || !isStringRoomAlias(wholeWord)) { + if (!isStringRoomID(wholeWord) && !isStringRoomAlias(wholeWord)) { return wholeWord; } return MatrixRoomReference.fromRoomIDOrAlias(wholeWord); diff --git a/test/commands/CommandReaderTest.ts b/test/commands/CommandReaderTest.ts index 15133e29..143f717d 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 { MatrixRoomAlias, MatrixRoomID, MatrixRoomReference } 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) => { From 89ee808f6afe5a45a870cf2a56782b4a074e3602 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 16 Feb 2024 14:56:40 +0000 Subject: [PATCH 097/160] Change leftover references to matrix-bot-sdk `UserID`. These should now reference the MPS equivalent. --- src/commands/Ban.tsx | 3 +-- src/commands/Rules.tsx | 3 +-- src/commands/interface-manager/MatrixPresentations.tsx | 3 +-- src/protections/BanPropagation.tsx | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/commands/Ban.tsx b/src/commands/Ban.tsx index c0f4c1a2..efa70a8b 100644 --- a/src/commands/Ban.tsx +++ b/src/commands/Ban.tsx @@ -25,7 +25,6 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { UserID } from "matrix-bot-sdk"; import { DraupnirContext } from "./CommandHandler"; import { defineInterfaceCommand,findTableCommand } from "./interface-manager/InterfaceCommand"; import { findPresentationType, ParameterDescription, parameters, ParsedKeywords, RestDescription, union } from "./interface-manager/ParameterParsing"; @@ -34,7 +33,7 @@ import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfac import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { PromptOptions } from "./interface-manager/PromptForAccept"; import { Draupnir } from "../Draupnir"; -import { ActionResult, MatrixRoomReference, PolicyRoomEditor, PolicyRuleType, isError } from "matrix-protection-suite"; +import { ActionResult, MatrixRoomReference, PolicyRoomEditor, PolicyRuleType, isError, UserID } from "matrix-protection-suite"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; diff --git a/src/commands/Rules.tsx b/src/commands/Rules.tsx index dc0ee527..eb150c76 100644 --- a/src/commands/Rules.tsx +++ b/src/commands/Rules.tsx @@ -33,8 +33,7 @@ import { JSXFactory } from "./interface-manager/JSXFactory"; import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; import { findPresentationType, parameters, union } from "./interface-manager/ParameterParsing"; -import { UserID } from "matrix-bot-sdk"; -import { ActionResult, MatrixRoomID, MatrixRoomReference, Ok, PolicyRoomWatchProfile, PolicyRule, StringRoomID, isError } from "matrix-protection-suite"; +import { ActionResult, MatrixRoomID, MatrixRoomReference, Ok, PolicyRoomWatchProfile, PolicyRule, StringRoomID, isError, UserID } from "matrix-protection-suite"; import { listInfo } from "./StatusCommand"; async function renderListMatches( diff --git a/src/commands/interface-manager/MatrixPresentations.tsx b/src/commands/interface-manager/MatrixPresentations.tsx index 7b0f5b0d..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 { definePresentationRenderer } from "./DeadDocumentPresentation"; import { JSXFactory } from "./JSXFactory"; import { DocumentNode } from "./DeadDocument"; -import { MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomAlias, MatrixRoomID } from "matrix-protection-suite"; +import { MatrixEventViaAlias, MatrixEventViaRoomID, MatrixRoomAlias, MatrixRoomID, UserID } from "matrix-protection-suite"; makePresentationType({ diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index e4e16b94..d09fdacf 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -31,10 +31,9 @@ limitations under the License. import { JSXFactory } from "../commands/interface-manager/JSXFactory"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; -import { UserID } from "matrix-bot-sdk"; import { ListMatches, renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName, UserID } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirProtection } from "./Protection"; From 2f8215761cf0348365866d025172d03ff2b883d7 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 16 Feb 2024 17:19:20 +0000 Subject: [PATCH 098/160] Load Draupnir protections in the DraupnirProtectedRoomsSet. --- src/draupnirfactory/DraupnirProtectedRoomsSet.ts | 1 + src/protections/DraupnirProtectionsIndex.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/protections/DraupnirProtectionsIndex.ts diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index 60998138..d2f7c63e 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -28,6 +28,7 @@ limitations under the License. import { ActionResult, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsConfig, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; import { BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration"; +import '../protections/DraupnirProtectionsIndex'; async function makePolicyListConfig( client: MatrixSendClient, diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts new file mode 100644 index 00000000..0050035f --- /dev/null +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -0,0 +1,15 @@ +/** + * 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 './MessageIsMedia'; +import './MessageIsVoice'; +import './TrustedReporters'; +import './WordList'; From c190753e1df67dd0a124f31d2bcbffa375b41447 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 16 Feb 2024 17:19:47 +0000 Subject: [PATCH 099/160] Enable MPS's Member/ServerBanSynchronisationProtections by default. --- .../DefaultEnabledProtectionsMigration.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts index b01f8c26..c8a9ee09 100644 --- a/src/protections/DefaultEnabledProtectionsMigration.ts +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -3,7 +3,7 @@ * All rights reserved. */ -import { ActionError,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value } from "matrix-protection-suite"; +import { ActionError,ActionException,ActionExceptionKind,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value, findProtection } from "matrix-protection-suite"; export const DefaultEnabledProtectionsMigration = new SchemedDataManager([ async function enableBanPropagationByDefault(input) { @@ -12,11 +12,44 @@ export const DefaultEnabledProtectionsMigration = new SchemedDataManager Date: Sat, 17 Feb 2024 11:20:25 +0000 Subject: [PATCH 100/160] Missing root node on consequence renderer. --- src/StandardConsequenceProvider.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx index 591f8d99..39885b6c 100644 --- a/src/StandardConsequenceProvider.tsx +++ b/src/StandardConsequenceProvider.tsx @@ -120,10 +120,12 @@ const consequenceForUsersInRevision: BasicConsequenceProvider['consequenceForUse (_description, roomID, userID, reason) => banUser(this.client, description, roomID, userID, reason) ); Task(renderMatrixAndSend( - renderSetMembershipBans( - Banning {results.size} users in protected rooms., - results - ), + { + renderSetMembershipBans( + Banning {results.size} users in protected rooms., + results + ) + }, this.managementRoomID, undefined, this.client From 10905a0b29acbf1ca48c2e0e71fbc1836cb864ac Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 17 Feb 2024 23:43:03 +0000 Subject: [PATCH 101/160] Only inform protected rooms set of protected rooms. --- src/Draupnir.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index bd722795..22cdb59e 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -170,6 +170,9 @@ export class Draupnir implements Client { 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 { From bc673927e3991a8cce4ec58a4b0f3269083900e0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 17 Feb 2024 23:43:26 +0000 Subject: [PATCH 102/160] Typo in status command. --- src/commands/StatusCommand.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index b0b5ab21..b27935bd 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -101,7 +101,7 @@ defineMatrixInterfaceAdaptor({ const renderedLists = lists.map(list => { return
    1. {list.revision.room.toRoomIDOrAlias()} propagation: {list.watchedListProfile.propagation} - (rules: {list.revision.allRulesOfType(PolicyRuleType.Server).length} servers, {list.revision.allRulesOfType(PolicyRuleType.User)} users, {list.revision.allRulesOfType(PolicyRuleType.Room).length} rooms) + (rules: {list.revision.allRulesOfType(PolicyRuleType.Server).length} servers, {list.revision.allRulesOfType(PolicyRuleType.User).length} users, {list.revision.allRulesOfType(PolicyRuleType.Room).length} rooms)
    2. }); return From e05e55ef943aad92d940ecbe1efb0d0e85ee1971 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 13:48:41 +0000 Subject: [PATCH 103/160] Fix bug where poller only showed reports from protected rooms. Was introduced in https://github.com/matrix-org/mjolnir/pull/371 for whatever reason. --- src/report/ReportPoller.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index b81994c3..4ba35b97 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -109,9 +109,6 @@ export class ReportPoller { continue; } const report = reportResult.ok; - if (!this.draupnir.protectedRoomsSet.isProtectedRoom(report.room_id)) { - continue; - } // 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. From 3eb3bae0853b687c4fe07e437922795e3145f19c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 13:49:56 +0000 Subject: [PATCH 104/160] Instantiate web apis in an appropriate place. --- src/DraupnirBotMode.ts | 5 +++++ src/index.ts | 4 +++- test/integration/fixtures.ts | 4 ++++ test/integration/manualLaunchScript.ts | 3 +++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index f34c558d..67a4cb23 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -47,9 +47,14 @@ import { 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'. diff --git a/src/index.ts b/src/index.ts index 7e9411f4..b801958d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ import { import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { read as configRead } from "./config"; import { initializeSentry, patchMatrixClient } from "./utils"; -import { makeDraupnirBotModeFromConfig } from "./DraupnirBotMode"; +import { constructWebAPIs, makeDraupnirBotModeFromConfig } from "./DraupnirBotMode"; import { Draupnir } from "./Draupnir"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder } from "matrix-protection-suite"; @@ -97,6 +97,8 @@ import { DefaultEventDecoder } from "matrix-protection-suite"; try { await bot.start(); await config.RUNTIME.client.start(); + const apis = constructWebAPIs(bot); + await apis.start(); healthz.isHealthy = true; } catch (err) { console.error(`Mjolnir failed to start: ${err}`); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 72e1599a..00ce19d8 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -1,3 +1,4 @@ +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"; @@ -26,6 +27,8 @@ export const mochaHooks = { this.mjolnir.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { references: [] }), ]); await this.mjolnir.start(); + this.apis = constructWebAPIs(this.mjolnir); + await this.apis.start(); console.log("mochaHooks.beforeEach DONE"); } ], @@ -33,6 +36,7 @@ export const mochaHooks = { async function() { this.timeout(10000) await this.mjolnir.stop(); + await this.apis?.stop(); await Promise.all([ this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }), this.mjolnir.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { references: [] }), diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 634184e7..42c22854 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -4,10 +4,13 @@ import { draupnirClient, makeMjolnir } from "./mjolnirSetupUtils"; import { read as configRead } from '../../src/config'; +import { constructWebAPIs } from "../../src/DraupnirBotMode"; (async () => { const config = configRead(); let mjolnir = await makeMjolnir(config); await mjolnir.start(); + const apis = constructWebAPIs(mjolnir); await draupnirClient()?.start(); + await apis.start(); })(); From cc97c8550e75815a97b96007da35abadf92ba620 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 17:07:37 +0000 Subject: [PATCH 105/160] Naively fix appservice integration tests. --- test/appservice/integration/listUnstartedMjolnir.ts | 5 ++++- test/appservice/utils/AppserviceBotCommandClient.ts | 4 ++-- test/appservice/utils/harness.ts | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/test/appservice/integration/listUnstartedMjolnir.ts b/test/appservice/integration/listUnstartedMjolnir.ts index f8908b0c..d19d515c 100644 --- a/test/appservice/integration/listUnstartedMjolnir.ts +++ b/test/appservice/integration/listUnstartedMjolnir.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 @@ -22,7 +23,9 @@ describe("Just test some commands innit", function() { it("Can list any unstarted mjolnir", 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/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); } From d718967a7c0f93afc4b9649cb4ada9fea3439334 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 18:33:13 +0000 Subject: [PATCH 106/160] Fix hello test (it works). Ok so this is pretty shit, i hate the integration test suite now. The reason why we return the test functions with `as any` in the hello test is because we had to remove `Record` from mocha's test context interface, otherwise the interface would have been completely useless. Maybe there is a ts setting though to not infer any from `this` at all? and just ignore those properties. The tsconfig.json situation is a bit weird, i don't understand why it's in this situation. However, it seems like we can try to https://github.com/jaredpalmer/tsdx/issues/84#issuecomment-489690504 use this workaround so that ts language features work in the test directory. I think we should focus on doing as little effort as possible getting these tests into working condition. If something is too complicated, it will need removing. If we need to make additional tests, this entire integration tests directory should be moved to a legacy-integration directory and we can start afresh. We should also ideally not integration tests as much as possible and try to reuse the unit helpers from MPS. This is even going to be critical later on. --- test/integration/fixtures.ts | 45 ++++++++++++++++----------- test/integration/helloTest.ts | 28 ++++++++--------- test/integration/mjolnirSetupUtils.ts | 9 +++++- tsconfig.json | 4 ++- 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 00ce19d8..72d4389d 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -1,8 +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(); @@ -13,37 +13,44 @@ 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(); - this.apis = constructWebAPIs(this.mjolnir); + 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 this.apis?.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(); + // 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..ab019658 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 any) }) diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index a1bd5761..4573faf4 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -28,11 +28,18 @@ import { Draupnir } from "../../src/Draupnir"; import { makeDraupnirBotModeFromConfig } from "../../src/DraupnirBotMode"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder } from "matrix-protection-suite"; +import { WebAPIs } from "../../src/webapis/WebAPIs"; patchMatrixClient(); -export interface DraupnirTestContext extends Mocha.Context { +// 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, } /** diff --git a/tsconfig.json b/tsconfig.json index 046c87d5..c2f0503b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,7 @@ "./test/integration/protectionSettingsTest.ts", "./test/integration/banPropagationTest.ts", "./test/integration/protectedRoomsConfigTest.ts", - ] + "./test/integration/fixtures.ts", + "./test/integration/helloTest.ts" + ], } From 5a991897dcdfe5917c7b7bbb693b28f57dbdeb05 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 20:04:51 +0000 Subject: [PATCH 107/160] Fix Draupnir's joinOnInviteListener. --- src/Draupnir.ts | 8 ++++++++ src/DraupnirBotMode.ts | 3 +++ 2 files changed, 11 insertions(+) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 22cdb59e..94e23997 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -163,6 +163,14 @@ export class Draupnir implements Client { if (isError(loadResult)) { throw loadResult.error; } + // 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)) { + throw managementRoomProtectResult.error; + } return draupnir; } diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 67a4cb23..622f4b33 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -109,6 +109,9 @@ export async function makeDraupnirBotModeFromConfig( 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); From 91358af8745a1d775d294144fda3dec16abfccda Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 20:23:29 +0000 Subject: [PATCH 108/160] Fix bug where report manager was only being shown 'm.room.message' --- src/Draupnir.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 94e23997..10142e49 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -212,8 +212,8 @@ export class Draupnir implements Client { 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); } + this.reportManager.handleTimelineEvent(roomID, event); } /** From 0d87c687fe8272b5f0681e2169c914c768a5f35b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 20:24:02 +0000 Subject: [PATCH 109/160] Restrict ReportManager's reaction listsener to management room. --- src/report/ReportManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index cb84f11b..18c5f808 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -94,7 +94,7 @@ export class ReportManager extends EventEmitter { } public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { - if (event.type === 'm.reaction') { + if (roomID === this.draupnir.managementRoomID && event.type === 'm.reaction') { Task(this.handleReaction({ roomID, event })); } } From e17ebe1ff0f588f3ff5322012dfd875abdce0b04 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 20:25:09 +0000 Subject: [PATCH 110/160] Fix abuseReportTest (it works!). --- test/integration/abuseReportTest.ts | 51 ++++++++++++++++++----------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 9484a831..39c8af5c 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); } }); @@ -219,23 +225,28 @@ 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" }}); @@ -250,8 +261,8 @@ 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. @@ -281,7 +292,7 @@ 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 @@ -318,7 +329,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; } } @@ -345,7 +356,7 @@ describe("Test: Reporting abuse", async () => { // 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 +364,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); }); From dba3aefca38a180c4413fbcfd6f60a8da2f0c1d7 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 18 Feb 2024 20:34:58 +0000 Subject: [PATCH 111/160] Fix acceptInviteFromSpaceTest. (it works!) --- .../integration/acceptInvitesFromSpaceTest.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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); }); From e16e6b9fbfcebab061e74dd639621637b930c69e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 19 Feb 2024 19:44:14 +0000 Subject: [PATCH 112/160] Give roomJoiner to MjolnirWatchedListsConfig (MPS). --- src/draupnirfactory/DraupnirFactory.ts | 3 ++- .../DraupnirProtectedRoomsSet.ts | 21 +++++++------------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index f754322f..946644ea 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -34,18 +34,19 @@ export class DraupnirFactory { return clientRooms; } this.clientsInRoomMap.addClientRooms(clientRooms.ok); + const clientPlatform = this.clientCapabilityFactory.makeClientPlatform(clientUserID, client); const protectedRoomsSet = await makeProtectedRoomsSet( managementRoom, roomStateManager, policyRoomManager, roomMembershipManager, client, + clientPlatform, clientUserID ); if (isError(protectedRoomsSet)) { return protectedRoomsSet; } - const clientPlatform = this.clientCapabilityFactory.makeClientPlatform(clientUserID, client); return Ok(await Draupnir.makeDraupnirBot( client, clientUserID, diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index d2f7c63e..270c263f 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -25,14 +25,15 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MatrixRoomID, MatrixRoomReference, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, ProtectedRoomsConfig, ProtectedRoomsSet, ProtectionsConfig, RoomMembershipManager, RoomStateManager, SetMembership, SetRoomState, StandardProtectedRoomsSet, StandardSetMembership, StandardSetRoomState, StringRoomAlias, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; -import { BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +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 + policyRoomManager: PolicyRoomManager, + roomJoiner: RoomJoiner, ): Promise> { const result = await MjolnirPolicyRoomsConfig.createFromStore( new BotSDKMatrixAccountData( @@ -41,16 +42,7 @@ async function makePolicyListConfig( client ), policyRoomManager, - { resolveRoom: async (stringReference: StringRoomID | StringRoomAlias) => { - const reference = MatrixRoomReference.fromRoomIDOrAlias(stringReference); - const resolvedReference = await resolveRoomReferenceSafe(client, reference); - if (isError(resolvedReference)) { - return resolvedReference; - } else { - return Ok(resolvedReference.ok.toRoomIDOrAlias()) - } - } - } + roomJoiner ); return result; } @@ -120,6 +112,7 @@ export async function makeProtectedRoomsSet( policyRoomManager: PolicyRoomManager, roomMembershipManager: RoomMembershipManager, client: MatrixSendClient, + clientPlatform: ClientPlatform, userID: StringUserID ): Promise> { const protectedRoomsConfig = await makeProtectedRoomsConfig(client) @@ -140,7 +133,7 @@ export async function makeProtectedRoomsSet( if (isError(membershipSet)) { return membershipSet; } - const policyListConfig = await makePolicyListConfig(client, policyRoomManager); + const policyListConfig = await makePolicyListConfig(client, policyRoomManager, clientPlatform.toRoomJoiner()); if (isError(policyListConfig)) { return policyListConfig; } From 2fe1ad1f3756895cc46fe8f209f3c37b7577a883 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 21 Feb 2024 15:11:56 +0000 Subject: [PATCH 113/160] Fix renderSetMembershipBans - it wasn't finished. --- src/StandardConsequenceProvider.tsx | 33 +++++++++++++++++-- .../interface-manager/MatrixHelpRenderer.tsx | 6 +++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx index 39885b6c..512f571e 100644 --- a/src/StandardConsequenceProvider.tsx +++ b/src/StandardConsequenceProvider.tsx @@ -25,13 +25,14 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionException, ActionExceptionKind, ActionResult, BasicConsequenceProvider, DEFAULT_CONSEQUENCE_PROVIDER, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, RoomUpdateError, RoomUpdateException, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership, describeConsequenceProvider, isError } from "matrix-protection-suite"; +import { ActionError, ActionException, ActionExceptionKind, ActionResult, BasicConsequenceProvider, DEFAULT_CONSEQUENCE_PROVIDER, MatrixRoomReference, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, RoomUpdateError, RoomUpdateException, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership, describeConsequenceProvider, isError } 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"; import { DocumentNode } from "./commands/interface-manager/DeadDocument"; import { printActionResult } from "./models/RoomUpdateError"; import { Draupnir } from "./Draupnir"; +import { renderRoomPill } from "./commands/interface-manager/MatrixHelpRenderer"; interface ProviderContext { client: MatrixSendClient; @@ -94,6 +95,32 @@ const consequenceForUserInRoom: BasicConsequenceProvider['consequenceForUserInRo return banUser(this.client, protection, roomID, userID, reason); } +/** + * This is an accompniment to `renderSetMembershipbans. + * Something more generic should be made, probably for RoomUpdateError and we + * make sure the ban consequence returns RoomUpdateError's. + */ +function renderRoomOutcome(roomID: StringRoomID, result: ActionResult): DocumentNode { + return +
      + {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - {result.isOkay ? 'okay' : 'failed'} + {result.match(() => , (error) =>

      + There was an unexpected error when processing this ban:
      + {error.message}
      + {error instanceof ActionException + ?

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

      + : } +

      )} +
      +
      +} + +// TODO: Why do we only have StringRoomID's in the map? +// TODO: How do we make a common renderer for ActionResults? +// so that failures are shown consistently? function renderSetMembershipBans(title: DocumentNode, map: SetMemberBanResultMap): DocumentNode { return {title}, @@ -101,8 +128,8 @@ function renderSetMembershipBans(title: DocumentNode, map: SetMemberBanResultMap [...map.entries()].map(([userID, roomResults]) => { return
      {userID} will be banned from {roomResults.size} rooms. -
        {[...roomResults.entries()].map((roomID) => { - return
      • {roomID}
      • +
          {[...roomResults.entries()].map(([roomID, outcome]) => { + return
        • {renderRoomOutcome(roomID, outcome)}
        • })}
      }) diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 98a0dbf3..4d17715b 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -10,7 +10,7 @@ import { DocumentNode } from "./DeadDocument"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { LogService } from "matrix-bot-sdk"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionException, ActionResult, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; +import { ActionException, ActionResult, MatrixRoomReference, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; function requiredArgument(argumentName: string): string { return `<${argumentName}>`; @@ -158,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()} +} From 70d0cec6958f0178ddb4edbd07d17ac320714468 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 21 Feb 2024 22:38:34 +0000 Subject: [PATCH 114/160] Typo in RoomUpdateError We really need a way to type the embedded expressions within JSX to stop this happening, aswell as accidentally passing `undefined`. --- src/commands/interface-manager/DeadDocument.ts | 4 ++++ src/commands/interface-manager/JSXFactory.ts | 2 +- src/models/RoomUpdateError.tsx | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/interface-manager/DeadDocument.ts b/src/commands/interface-manager/DeadDocument.ts index 4e209290..adb5e1f4 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', diff --git a/src/commands/interface-manager/JSXFactory.ts b/src/commands/interface-manager/JSXFactory.ts index 2c890e14..63553ac5 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)) { diff --git a/src/models/RoomUpdateError.tsx b/src/models/RoomUpdateError.tsx index 1b14ede7..e525ee85 100644 --- a/src/models/RoomUpdateError.tsx +++ b/src/models/RoomUpdateError.tsx @@ -33,7 +33,7 @@ import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; function renderErrorItem(error: RoomUpdateError): DocumentNode { return
    3. - {error.room.toRoomIDOrAlias} - {error.message} + {error.room.toRoomIDOrAlias()} - {error.message}
    4. } From 0dc7c84afddbb2d016f550c955695441e1fbda90 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 21 Feb 2024 22:39:49 +0000 Subject: [PATCH 115/160] Fix BanPropagationProtectionTest (it works!) However, it does hang because something is sat on the event loop, i can't find out what yet. --- src/protections/BanPropagation.tsx | 21 ++++---- test/integration/banPropagationTest.ts | 69 +++++++++++++++----------- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index d09fdacf..ec65cad5 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -30,7 +30,7 @@ limitations under the License. import { JSXFactory } from "../commands/interface-manager/JSXFactory"; import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; -import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; +import { renderMentionPill, renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; import { ListMatches, renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName, UserID } from "matrix-protection-suite"; @@ -38,6 +38,7 @@ 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'); @@ -46,7 +47,9 @@ const UNBAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.unban_p // FIXME: https://github.com/the-draupnir-project/Draupnir/issues/160 function makePolicyRoomReactionReferenceMap(rooms: MatrixRoomID[]): Map { - return rooms.reduce((map, room, index) => (map.set(`${index + 1}.`, room.toPermalink()), map), new Map()) + return MatrixReactionHandler.createItemizedReactionMap( + rooms.map(room => room.toPermalink()) + ); } // would be nice to be able to use presentation types here idk. @@ -76,7 +79,7 @@ async function promptBanPropagation( in {change.roomID} by {new UserID(change.sender)} for {change.content.reason ?? ''}.
      Would you like to add the ban to a policy list?
        - {editablePolicyRoomIDs} + {editablePolicyRoomIDs.map((room) =>
      1. {room.toRoomIDOrAlias()}
      2. )}
      , draupnir.managementRoomID, @@ -96,16 +99,16 @@ async function promptBanPropagation( async function promptUnbanPropagation( draupnir: Draupnir, - event: any, - roomId: string, + 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:
        { @@ -121,8 +124,8 @@ async function promptUnbanPropagation( UNBAN_PROPAGATION_PROMPT_LISTENER, reactionMap, { - target: event["state_key"], - reason: event["content"]?.["reason"], + target: membershipChange.userID, + reason: membershipChange.content.reason, } ) )).at(0) as string; 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) }) From c1215ab0451671da950a7b662edf764f9033fc6c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 21 Feb 2024 23:41:25 +0000 Subject: [PATCH 116/160] Create a `test/tsconfig.json`. We then run this as part of the build step, then run tsc with the original project file to get the side effect of emitting the source files. Since the `test/tsconfig.json` has `noEmit: true`. --- package.json | 4 ++-- test/tsconfig.json | 7 +++++++ tsconfig.json | 13 +------------ 3 files changed, 10 insertions(+), 14 deletions(-) create mode 100644 test/tsconfig.json diff --git a/package.json b/package.json index c0cdd85c..41098deb 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "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", 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 c2f0503b..5e425e6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,17 +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", - "./test/integration/fixtures.ts", - "./test/integration/helloTest.ts" + "./src/**/*" ], } From 380f4b1a77644eff23433df09ecbcec45ce421aa Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 22 Feb 2024 12:43:47 +0000 Subject: [PATCH 117/160] Update for ClientsInRoomsMap rework (MPS). --- src/Draupnir.ts | 13 ++++- src/draupnirfactory/DraupnirClientRooms.ts | 52 ------------------- src/draupnirfactory/DraupnirFactory.ts | 7 +-- .../StandardDraupnirManager.ts | 4 +- src/index.ts | 6 ++- test/integration/fixtures.ts | 1 + 6 files changed, 21 insertions(+), 62 deletions(-) delete mode 100644 src/draupnirfactory/DraupnirClientRooms.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 10142e49..972ceb7c 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -102,7 +102,6 @@ export class Draupnir implements Client { if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); } - this.clientRooms.on('timeline', this.timelineEventListener); this.commandContext = { draupnir: this, roomID: this.managementRoomID, client: this.client, reactionHandler: this.reactionHandler, clientPlatform: this.clientPlatform @@ -283,8 +282,13 @@ export class Draupnir implements Client { } } + /** + * 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 { - // FIXME: This method needs to be removed it probably won't be called at all. + this.clientRooms.on('timeline', this.timelineEventListener); if (this.reportPoller) { const reportPollSetting = await ReportPoller.getReportPollSetting( this.client, @@ -294,6 +298,11 @@ export class Draupnir implements Client { } } + public stop(): void { + this.clientRooms.off('timeline', this.timelineEventListener); + this.reportPoller?.stop() + } + public createRoomReference(roomID: StringRoomID): MatrixRoomID { return new MatrixRoomID( roomID, diff --git a/src/draupnirfactory/DraupnirClientRooms.ts b/src/draupnirfactory/DraupnirClientRooms.ts deleted file mode 100644 index f7013141..00000000 --- a/src/draupnirfactory/DraupnirClientRooms.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright (C) 2023-2024 Gnuxie - * All rights reserved. - */ - -import { ActionException, ActionExceptionKind, ActionResult, ClientRooms, JoinedRoomsRevision, JoinedRoomsSafe, Ok, RoomStateManager, StandardClientRooms, StandardJoinedRoomsRevision, StringUserID, isError } from "matrix-protection-suite"; - -export class DraupnirClientRooms extends StandardClientRooms implements ClientRooms { - private constructor( - roomStateManager: RoomStateManager, - joinedRoomsThunk: JoinedRoomsSafe, - clientUserID: StringUserID, - joinedRoomsRevision: JoinedRoomsRevision - ) { - super( - roomStateManager, - joinedRoomsThunk, - clientUserID, - joinedRoomsRevision - ); - } - - public static async makeClientRooms( - roomStateManager: RoomStateManager, - joinedRoomsThunk: JoinedRoomsSafe, - clientUserID: StringUserID, - ): Promise> { - try { - const joinedRooms = await joinedRoomsThunk(); - if (isError(joinedRooms)) { - return joinedRooms; - } - const revision = StandardJoinedRoomsRevision.blankRevision( - clientUserID - ).reviseFromJoinedRooms(joinedRooms.ok); - return Ok(new DraupnirClientRooms( - roomStateManager, - joinedRoomsThunk, - clientUserID, - revision - )) - } catch (exception) { - return ActionException.Result( - `Couldn't create client rooms`, - { - exception, - exceptionKind: ActionExceptionKind.Unknown - } - ) - } - } -} diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index 946644ea..43b23814 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -6,7 +6,6 @@ import { ActionResult, ClientsInRoomMap, MatrixRoomID, Ok, StringUserID, isError } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { ClientCapabilityFactory, ClientForUserID, RoomStateManagerFactory, joinedRoomsSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { DraupnirClientRooms } from "./DraupnirClientRooms"; import { IConfig } from "../config"; import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet"; @@ -25,15 +24,13 @@ export class DraupnirFactory { const policyRoomManager = await this.roomStateManagerFactory.getPolicyRoomManager(clientUserID); const roomMembershipManager = await this.roomStateManagerFactory.getRoomMembershipManager(clientUserID); const client = await this.clientProvider(clientUserID); - const clientRooms = await DraupnirClientRooms.makeClientRooms( - roomStateManager, + const clientRooms = await this.clientsInRoomMap.makeClientRooms( + clientUserID, async () => joinedRoomsSafe(client), - clientUserID ); if (isError(clientRooms)) { return clientRooms; } - this.clientsInRoomMap.addClientRooms(clientRooms.ok); const clientPlatform = this.clientCapabilityFactory.makeClientPlatform(clientUserID, client); const protectedRoomsSet = await makeProtectedRoomsSet( managementRoom, diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index 589379af..817198e1 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -105,7 +105,7 @@ export class StandardDraupnirManager { if (draupnir === undefined) { throw new TypeError(`Trying to start a draupnir that hasn't been created ${clientUserID}`); } - this.clientsInRooms.addClientRooms(draupnir.clientRooms); + draupnir.start(); this.listeningDraupnirs.set(clientUserID, draupnir); this.readyDraupnirs.delete(clientUserID); } @@ -117,7 +117,7 @@ export class StandardDraupnirManager { if (draupnir === undefined) { return; } else { - this.clientsInRooms.removeClientRooms(draupnir.clientRooms); + draupnir.stop(); this.listeningDraupnirs.delete(clientUserID); this.readyDraupnirs.set(clientUserID, draupnir); } diff --git a/src/index.ts b/src/index.ts index b801958d..3c094e6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ import { constructWebAPIs, makeDraupnirBotModeFromConfig } from "./DraupnirBotMo 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"; (async function () { @@ -68,6 +69,7 @@ import { DefaultEventDecoder } from "matrix-protection-suite"; } 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")); @@ -90,6 +92,7 @@ import { DefaultEventDecoder } from "matrix-protection-suite"; config.RUNTIME.client = client; bot = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); + apis = constructWebAPIs(bot); } catch (err) { console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`); throw err; @@ -97,11 +100,12 @@ import { DefaultEventDecoder } from "matrix-protection-suite"; try { await bot.start(); await config.RUNTIME.client.start(); - const apis = constructWebAPIs(bot); await apis.start(); healthz.isHealthy = true; } catch (err) { console.error(`Mjolnir failed to start: ${err}`); + bot.stop(); + apis.stop(); throw err; } })(); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 72d4389d..e8f5b570 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -38,6 +38,7 @@ export const mochaHooks = { this.timeout(10000) this.apis?.stop(); draupnirClient()?.stop(); + this.draupnir?.stop(); // remove alias from management room and leave it. if (this.draupnir !== undefined) { From 28d220142e6061ccf1eaa21f021a276295615e11 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 22 Feb 2024 12:55:21 +0000 Subject: [PATCH 118/160] Chore: Delete random crap that was accidentally comitted sometime --- test/commands/CommandClient.ts | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 test/commands/CommandClient.ts 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.... From 2198c34b55a80c32a5592c45d239e412048310c5 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 22 Feb 2024 12:55:51 +0000 Subject: [PATCH 119/160] Chore: please typescript in abuseReportTest. --- test/integration/abuseReportTest.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 39c8af5c..9d2d8b57 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -55,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. @@ -159,7 +158,7 @@ describe("Test: Reporting abuse", async () => { 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); @@ -251,8 +250,6 @@ describe("Test: Reporting abuse", async () => { // 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); @@ -266,22 +263,11 @@ 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 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) { From 8044a78fa68f680f84bdb821a98facef7041f8cb Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 22 Feb 2024 13:11:53 +0000 Subject: [PATCH 120/160] Fix reportPollerTest (it works!) --- src/report/ReportManager.ts | 14 ++++--- test/integration/reportPollingTest.ts | 58 ++++++++++++++++++--------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index 18c5f808..d4912ec5 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -30,9 +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 { Draupnir } from "../Draupnir"; -import { ReactionContent, RoomEvent, StringEventID, StringRoomID, Task, Value, isError } from "matrix-protection-suite"; +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]`. @@ -86,10 +85,9 @@ enum Kind { /** * A class designed to respond to abuse reports. */ -export class ReportManager extends EventEmitter { +export class ReportManager { private displayManager: DisplayManager; constructor(public draupnir: Draupnir) { - super(); this.displayManager = new DisplayManager(this); } @@ -117,7 +115,13 @@ export class ReportManager extends EventEmitter { * @param reason A reason provided by the reporter. */ public async handleServerAbuseReport({ roomID, reporterId, event, reason }: { roomID: StringRoomID, reporterId: string, event: RoomEvent, reason?: string }) { - this.emit("report.new", { roomID: roomID, reporterId: reporterId, event: event, reason: reason }); + 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 }); } diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 606ed5e8..061ec94b 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -1,33 +1,51 @@ -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, DEFAULT_CONSEQUENCE_PROVIDER, MatrixRoomReference, Ok, Protection, ProtectionDescription, StandardProtectionSettings, StringRoomID, findConsequenceProvider } 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", + factory: function (description, consequenceProvider, protectedRoomsSet, context, settings): ActionResult { + return Ok({ + handleEventReport(report) { + if (report.reason === "x5h1Je") { + resolve(null); + } + return Promise.resolve(Ok(undefined)); + }, + description: testProtectionDescription, + requiredEventPermissions: [], + requiredPermissions: [] + }) + }, + protectionSettings: new StandardProtectionSettings( + {}, + {} + ) + } + const defaultConsequenceProvider = findConsequenceProvider(DEFAULT_CONSEQUENCE_PROVIDER); + if (defaultConsequenceProvider === undefined) { + throw new TypeError(`Default consequence provider should be defined mate`); + } + await draupnir.protectedRoomsSet.protections.addProtection(testProtectionDescription, defaultConsequenceProvider, draupnir.protectedRoomsSet, draupnir); await client.doRequest( "POST", `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { @@ -42,5 +60,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); }); From 41db3392dfbdccd75cc883a525166313df89aa7b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 22 Feb 2024 13:49:43 +0000 Subject: [PATCH 121/160] Stop throwing in `Draupnir['makeDraupnirBot']` --- src/Draupnir.ts | 10 +++++----- src/draupnirfactory/DraupnirFactory.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 972ceb7c..daae044f 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { 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 { 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"; @@ -135,7 +135,7 @@ export class Draupnir implements Client { policyRoomManager: PolicyRoomManager, roomMembershipManager: RoomMembershipManager, config: IConfig - ): Promise { + ): Promise> { const draupnir = new Draupnir( client, clientUserID, @@ -160,7 +160,7 @@ export class Draupnir implements Client { ) ); if (isError(loadResult)) { - throw loadResult.error; + 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). @@ -168,9 +168,9 @@ export class Draupnir implements Client { managementRoom ); if (isError(managementRoomProtectResult)) { - throw managementRoomProtectResult.error; + return managementRoomProtectResult; } - return draupnir; + return Ok(draupnir); } public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index 43b23814..26422df2 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -3,7 +3,7 @@ * All rights reserved. */ -import { ActionResult, ClientsInRoomMap, MatrixRoomID, Ok, StringUserID, isError } from "matrix-protection-suite"; +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"; @@ -44,7 +44,7 @@ export class DraupnirFactory { if (isError(protectedRoomsSet)) { return protectedRoomsSet; } - return Ok(await Draupnir.makeDraupnirBot( + return await Draupnir.makeDraupnirBot( client, clientUserID, clientPlatform, @@ -55,6 +55,6 @@ export class DraupnirFactory { policyRoomManager, roomMembershipManager, config - )) + ); } } From c1bfcf610ea9f292af6c15205729e710ef5e4496 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 25 Feb 2024 19:32:05 +0000 Subject: [PATCH 122/160] Begin moving from ConsequenceProvider to CapabilityProviderSet. We gotta sort out the glue and the renderering yet. --- src/commands/ProtectionsCommands.tsx | 15 ++++----- src/protections/BanPropagation.tsx | 41 +++++++++++++++++------- src/protections/BasicFlooding.ts | 38 ++++++++++++++++------ src/protections/FirstMessageIsImage.ts | 41 +++++++++++++++++------- src/protections/JoinWaveShortCircuit.tsx | 23 ++++++++----- src/protections/MessageIsMedia.ts | 32 ++++++++++++------ src/protections/MessageIsVoice.ts | 31 ++++++++++++------ src/protections/Protection.ts | 2 +- src/protections/TrustedReporters.ts | 39 ++++++++++++++++------ src/protections/WordList.ts | 41 ++++++++++++++++++------ test/integration/reportPollingTest.ts | 12 +++---- 11 files changed, 220 insertions(+), 95 deletions(-) diff --git a/src/commands/ProtectionsCommands.tsx b/src/commands/ProtectionsCommands.tsx index 85b3c3d7..c8d9f018 100644 --- a/src/commands/ProtectionsCommands.tsx +++ b/src/commands/ProtectionsCommands.tsx @@ -27,7 +27,7 @@ limitations under the 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, findConsequenceProvider, findProtection, getAllProtections, isError } from "matrix-protection-suite"; +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"; @@ -60,16 +60,13 @@ defineInterfaceCommand({ if (protectionDescription === undefined) { return ActionError.Result(`Couldn't find a protection named ${protectionName}`); } - const consequenceProviderName = keywords.getKeyword("consequence-provider"); - const consequenceProviderDescription = consequenceProviderName !== undefined - ? Ok(findConsequenceProvider(consequenceProviderName)) - : await this.draupnir.protectedRoomsSet.protections.getConsequenceProviderDescriptionForProtection(protectionDescription); - if (isError(consequenceProviderDescription) || consequenceProviderDescription.ok === undefined) { - return ActionError.Result(`Couldn't find a consequence provider named ${consequenceProviderName}`); + 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, - consequenceProviderDescription.ok, + capabilityProviderSet.ok, this.draupnir.protectedRoomsSet, this.draupnir ) @@ -329,7 +326,7 @@ async function changeSettingsForCommands } defineInterfaceCommand({ diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index ec65cad5..675b9ee4 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -33,7 +33,7 @@ import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentM import { renderMentionPill, renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer"; import { ListMatches, renderListRules } from "../commands/Rules"; import { printActionResult } from "../models/RoomUpdateError"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName, UserID } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName, UserID, UnknownSettings, UserConsequences } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirProtection } from "./Protection"; @@ -132,15 +132,29 @@ async function promptUnbanPropagation( await draupnir.reactionHandler.addReactionsToEvent(draupnir.client, draupnir.managementRoomID, promptEventId, reactionMap); } -export class BanPropagationProtection extends AbstractProtection implements DraupnirProtection { +export type BanPropagationProtectionCapabilities = { + userConsequences: UserConsequences +}; +export type BanPropagationProtectionCapabilitiesDescription = ProtectionDescription< + Draupnir, + UnknownSettings, + BanPropagationProtectionCapabilities +>; + +export class BanPropagationProtection + extends AbstractProtection + implements DraupnirProtection { + + private readonly userConsequences: UserConsequences; constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: BanPropagationProtectionCapabilitiesDescription, + capabilities: BanPropagationProtectionCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { - super(description, consequenceProvider, protectedRoomsSet, [], []); + 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)); @@ -263,10 +277,9 @@ export class BanPropagationProtection extends AbstractProtection implements Drau { title: `There were errors unbanning ${context.target} from all lists.`} )); } else { - this.consequenceProvider.unbanUserFromRoomsInSet( - this.description, + this.userConsequences.unbanUserFromRoomSet( context.target as StringUserID, - this.protectedRoomsSet + '' ) } } else { @@ -275,17 +288,23 @@ export class BanPropagationProtection extends AbstractProtection implements Drau } } -describeProtection({ +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.", - factory: (decription, consequenceProvider, protectedRoomsSet, draupnir, _settings) => + capabilityInterfaces: { + userConsequences: 'UserConsequences' + }, + defaultCapabilities: { + userConsequences: 'StandardUserConsequences', + }, + factory: (decription, protectedRoomsSet, draupnir, capabilities, _settings) => Ok( new BanPropagationProtection( decription, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir ) diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index e74ec6a9..fd4c58f6 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -28,7 +28,7 @@ limitations under the License. import { Draupnir } from "../Draupnir"; import { DraupnirProtection } from "./Protection"; import { LogLevel } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringRoomID, StringUserID, describeProtection, isError } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, EventConsequences, Logger, MatrixRoomID, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringRoomID, StringUserID, UserConsequences, describeProtection, isError } from "matrix-protection-suite"; const log = new Logger('BasicFloodingProtection'); @@ -40,13 +40,28 @@ type BasicFloodingProtectionSettings = { export const DEFAULT_MAX_PER_MINUTE = 10; const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase -describeProtection({ +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.", - factory: (description, consequenceProvider, protectedRoomsSet, draupnir, rawSettings) => { + 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; @@ -54,7 +69,7 @@ describeProtection({ return Ok( new BasicFloodingProtection( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir, parsedSettings.ok @@ -71,25 +86,28 @@ describeProtection({ }); -export class BasicFloodingProtection extends AbstractProtection implements DraupnirProtection { +export class BasicFloodingProtection extends AbstractProtection implements DraupnirProtection { private lastEvents: { [roomID: StringRoomID]: { [userID: StringUserID]: { originServerTs: number, eventID: StringEventID }[] } } = {}; private recentlyBanned: string[] = []; + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; public constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: BasicFloodingProtectionDescription, + capabilities: BasicFloodingProtectionCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, private readonly settings: BasicFloodingProtectionSettings, ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, [], [] ) + this.userConsequences = capabilities.userConsequences; } public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { @@ -116,7 +134,7 @@ export class BasicFloodingProtection extends AbstractProtection implements Draup 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.consequenceProvider.consequenceForUserInRoom(this.description, room.toRoomIDOrAlias(), event['sender'], 'spam'); + await this.userConsequences.consequenceForUserInRoom(room.toRoomIDOrAlias(), event['sender'], 'spam'); } else { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, room.toRoomIDOrAlias()); } @@ -130,7 +148,7 @@ export class BasicFloodingProtection extends AbstractProtection implements Draup // Redact all the things the user said too if (!this.draupnir.config.noop) { for (const eventID of forUser.map(e => e.eventID)) { - await this.consequenceProvider.consequenceForEvent(this.description, room.toRoomIDOrAlias(), eventID, 'spam'); + await this.eventConsequences.consequenceForEvent(room.toRoomIDOrAlias(), eventID, 'spam'); } } else { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${room.toRoomIDOrAlias()} but Mjolnir is running in no-op mode`, room.toRoomIDOrAlias()); diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 1b7ddd93..376e5b65 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -26,20 +26,35 @@ limitations under the License. */ import { LogLevel, LogService } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, EventConsequences, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, UserConsequences, Value, describeProtection } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -type FirstMessageIsImageProtectionSettings = {} +type FirstMessageIsImageProtectionSettings = {}; -describeProtection({ +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.", - factory: function (description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + capabilityInterfaces: { + userConsequences: 'UserConsequences', + eventConsequences: 'EventConsequences', + }, + defaultCapabilities: { + userConsequences: 'StandardUserConsequences', + eventConsequences: 'StandardEventConsequences', + }, + factory: function (description, protectedRoomsSet, draupnir, capabilities, _settings) { return Ok( new FirstMessageIsImageProtection( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir ) @@ -47,24 +62,28 @@ describeProtection({ } }) -export class FirstMessageIsImageProtection extends AbstractProtection implements Protection { +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: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: FirstMessageIsImageProtectionDescription, + capabilities: FirstMessageIsImageProtectionCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, [], [] ); + this.userConsequences = capabilities.userConsequences; + this.eventConsequences = capabilities.eventConsequences; } public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { @@ -91,7 +110,7 @@ export class FirstMessageIsImageProtection extends AbstractProtection implements if (isMedia && this.justJoined[roomID].includes(event['sender'])) { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomID}.`); if (!this.draupnir.config.noop) { - await this.consequenceProvider.consequenceForUserInRoom(this.description,roomID, event['sender'], 'spam'); + await this.userConsequences.consequenceForUserInRoom(roomID, event['sender'], 'spam'); } else { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomID} but Mjolnir is running in no-op mode`, roomID); } @@ -104,7 +123,7 @@ export class FirstMessageIsImageProtection extends AbstractProtection implements // Redact the event if (!this.draupnir.config.noop) { - await this.draupnir.client.redactEvent(roomID, event['event_id'], "spam"); + await this.eventConsequences.consequenceForEvent(roomID, event['event_id'], "spam"); } else { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomID} but Mjolnir is running in no-op mode`, roomID); } diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx index 15110dae..d936ec74 100644 --- a/src/protections/JoinWaveShortCircuit.tsx +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, ProtectionDescription, RoomMembershipRevision, SafeIntegerProtectionSetting, StandardProtectionSettings, StringRoomID, describeProtection, isError } from "matrix-protection-suite"; +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"; @@ -41,10 +41,17 @@ type JoinWaveShortCircuitProtectionSettings = { timescaleMinutes: number, } -describeProtection({ +// 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.", - factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, settings) { + capabilityInterfaces: {}, + defaultCapabilities: {}, + factory: function(description, protectedRoomsSet, draupnir, capabilities, settings) { const parsedSettings = description.protectionSettings.parseSettings(settings); if (isError(parsedSettings)) { return parsedSettings @@ -52,7 +59,7 @@ describeProtection({ return Ok( new JoinWaveShortCircuitProtection( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir, parsedSettings.ok @@ -73,7 +80,7 @@ describeProtection({ }) }) -export class JoinWaveShortCircuitProtection extends AbstractProtection implements DraupnirProtection { +export class JoinWaveShortCircuitProtection extends AbstractProtection implements DraupnirProtection { requiredStatePermissions = ["m.room.join_rules"] private joinBuckets: { @@ -84,15 +91,15 @@ export class JoinWaveShortCircuitProtection extends AbstractProtection implement } = {}; constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: JoinWaveShortCircuitProtectionDescription, + capabilities: CapabilitySet, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, public readonly settings: JoinWaveShortCircuitProtectionSettings ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, ["m.room.join_rules"], [] diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 580437fa..65beb331 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -26,19 +26,31 @@ limitations under the License. */ import { LogLevel } from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, EventConsequences, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; type MessageIsMediaProtectionSettings = {}; -describeProtection({ +type MessageIsMediaCapabilities = { + eventConsequences: EventConsequences; +} + +type MessageIsMediaProtectionDescription = ProtectionDescription; + +describeProtection({ name: 'MessageIsMediaProtection', description: "If a user posts an image or video, that message will be redacted. No bans are issued.", - factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + capabilityInterfaces: { + eventConsequences: 'EventConsequences', + }, + defaultCapabilities: { + eventConsequences: 'StandardEventConsequences', + }, + factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { return Ok( new MessageIsMediaProtection( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir ) @@ -46,20 +58,22 @@ describeProtection({ } }) -export class MessageIsMediaProtection extends AbstractProtection implements Protection { +export class MessageIsMediaProtection extends AbstractProtection implements Protection { + private readonly eventConsequences: EventConsequences; constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: MessageIsMediaProtectionDescription, + capabilities: MessageIsMediaCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, [], [] ); + this.eventConsequences = this.eventConsequences; } public async handleTimelineEvent(room: MatrixRoomID, event: RoomEvent): Promise> { @@ -75,7 +89,7 @@ export class MessageIsMediaProtection extends AbstractProtection implements Prot await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/video. ${Permalinks.forEvent(roomID, event['event_id'], [serverName(this.draupnir.clientUserID)])}`); // Redact the event if (this.draupnir.config.noop) { - await this.draupnir.client.redactEvent(roomID, event['event_id'], "Images/videos are not permitted here"); + await this.eventConsequences.consequenceForEvent(roomID, event['event_id'], "Images/videos are not permitted here"); } else { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomID} but Mjolnir is running in no-op mode`, roomID); } diff --git a/src/protections/MessageIsVoice.ts b/src/protections/MessageIsVoice.ts index 9a87c8dd..5dc2293d 100644 --- a/src/protections/MessageIsVoice.ts +++ b/src/protections/MessageIsVoice.ts @@ -26,17 +26,29 @@ limitations under the License. */ import { LogLevel} from "matrix-bot-sdk"; -import { AbstractProtection, ActionResult, BasicConsequenceProvider, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, CapabilitySet, EventConsequences, MatrixRoomID, Ok, Permalinks, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMessage, Value, describeProtection, serverName } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; -describeProtection({ +type MessageIsVoiceCapabilities = { + eventConsequences: EventConsequences; +}; + +type MessageIsVoiceDescription = ProtectionDescription; + +describeProtection({ name: 'MessageIsVoiceProtection', description: 'If a user posts a voice message, that message will be redacted', - factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + capabilityInterfaces: { + eventConsequences: 'EventConsequences', + }, + defaultCapabilities: { + eventConsequences: 'StandardEventConsequences' + }, + factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { return Ok( new MessageIsVoiceProtection( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir ) @@ -44,16 +56,17 @@ describeProtection({ } }) -export class MessageIsVoiceProtection extends AbstractProtection implements Protection { +export class MessageIsVoiceProtection extends AbstractProtection implements Protection { + private readonly eventConsequences: EventConsequences; constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: MessageIsVoiceDescription, + capabilities: CapabilitySet, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, [], [] @@ -69,7 +82,7 @@ export class MessageIsVoiceProtection extends AbstractProtection implements Prot await this.draupnir.managementRoomOutput.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomID, event['event_id'], [serverName(this.draupnir.clientUserID)])}`); // Redact the event if (!this.draupnir.config.noop) { - return await this.consequenceProvider.consequenceForEvent(this.description, roomID, event['event_id'], "Voice messages are not permitted here"); + return await this.eventConsequences.consequenceForEvent(roomID, event['event_id'], "Voice messages are not permitted here"); } else { await this.draupnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomID} but Mjolnir is running in no-op mode`, roomID); return Ok(undefined); diff --git a/src/protections/Protection.ts b/src/protections/Protection.ts index 9d46f8bf..c2e91ee1 100644 --- a/src/protections/Protection.ts +++ b/src/protections/Protection.ts @@ -30,7 +30,7 @@ import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { ParsedKeywords } from "../commands/interface-manager/ParameterParsing"; import { ReadItem } from "../commands/interface-manager/CommandReader"; -export interface DraupnirProtection extends Protection { +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/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 73a16a44..366442a9 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, BasicConsequenceProvider, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringUserID, StringUserIDSetProtectionSettings, describeProtection, isError } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, EventConsequences, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, SafeIntegerProtectionSetting, StandardProtectionSettings, StringEventID, StringUserID, StringUserIDSetProtectionSettings, UserConsequences, describeProtection, isError } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; const MAX_REPORTED_EVENT_BACKLOG = 20; @@ -37,10 +37,25 @@ type TrustedReportersProtectionSettings = { banThreshold: number, } -describeProtection({ +type TrustedReportersCapabilities = { + userConsequences: UserConsequences; + eventConsequences: EventConsequences; +} + +type TrustedReportersDescription = ProtectionDescription; + +describeProtection({ name: 'TrustedReporters', description: "Count reports from trusted reporters and take a configured action", - factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, rawSettings) { + 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; @@ -48,7 +63,7 @@ describeProtection({ return Ok( new TrustedReporters( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir, parsedSettings.ok @@ -76,23 +91,27 @@ describeProtection({ * Hold a list of users trusted to make reports, and enact consequences on * events that surpass configured report count thresholds */ -export class TrustedReporters extends AbstractProtection implements Protection { +export class TrustedReporters extends AbstractProtection implements Protection { private recentReported = new Map>(); + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; public constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: TrustedReportersDescription, + capabilities: TrustedReportersCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, public readonly settings: TrustedReportersProtectionSettings ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, [], [] ); + this.userConsequences = capabilities.userConsequences; + this.eventConsequences = capabilities.eventConsequences; } public async handleEventReport(report: EventReport): Promise> { @@ -122,11 +141,11 @@ export class TrustedReporters extends AbstractProtection implements Protection } if (reporters.size === this.settings.redactThreshold) { met.push("redact"); - await this.consequenceProvider.consequenceForEvent(this.description, report.room_id, report.event_id, "abuse detected"); + await this.eventConsequences.consequenceForEvent(report.room_id, report.event_id, "abuse detected"); } if (reporters.size === this.settings.banThreshold) { met.push("ban"); - await this.consequenceProvider.consequenceForEvent(this.description, report.room_id, report.event_id, "abuse detected"); + await this.userConsequences.consequenceForUserInRoom(report.room_id, report.event.sender, "abuse detected"); } if (met.length > 0) { diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index 3c20a0ce..b144b28e 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -25,20 +25,37 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { AbstractProtection, ActionResult, BasicConsequenceProvider, Logger, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, Value, describeProtection } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, EventConsequences, Logger, MatrixRoomID, MembershipChange, MembershipChangeType, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, RoomEvent, RoomMembershipRevision, RoomMessage, StringRoomID, StringUserID, UserConsequences, Value, describeProtection } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; const log = new Logger('WordList'); -describeProtection({ +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.", - factory: function(description, consequenceProvider, protectedRoomsSet, draupnir, _settings) { + capabilityInterfaces: { + userConsequences: 'UserConsequences', + eventConsequences: 'EventConsequences', + }, + defaultCapabilities: { + userConsequences: 'StandardUserConsequences', + eventConsequences: 'StandardEventConsequences', + }, + factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { return Ok( new WordListProtection( description, - consequenceProvider, + capabilities, protectedRoomsSet, draupnir ) @@ -46,23 +63,27 @@ describeProtection({ } }); -export class WordListProtection extends AbstractProtection implements Protection { +export class WordListProtection extends AbstractProtection implements Protection { private justJoined: { [roomID: StringRoomID]: { [username: StringUserID]: Date} } = {}; private badWords?: RegExp; + private readonly userConsequences: UserConsequences; + private readonly eventConsequences: EventConsequences; constructor( - description: ProtectionDescription, - consequenceProvider: BasicConsequenceProvider, + description: WordListDescription, + capabilities: WordListCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, ) { super( description, - consequenceProvider, + capabilities, protectedRoomsSet, [], [] ); + this.userConsequences = capabilities.userConsequences; + this.eventConsequences = capabilities.eventConsequences; } public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { const roomID = revision.room.toRoomIDOrAlias(); @@ -130,8 +151,8 @@ export class WordListProtection extends AbstractProtection implements Protection const match = this.badWords!.exec(message ?? ''); if (match) { const reason = `bad word: ${match[0]}`; - await this.consequenceProvider.consequenceForUserInRoom(this.description, roomID, event.sender, reason); - await this.consequenceProvider.consequenceForEvent(this.description, roomID, event.event_id, reason); + await this.userConsequences.consequenceForUserInRoom(roomID, event.sender, reason); + await this.eventConsequences.consequenceForEvent(roomID, event.event_id, reason); } } return Ok(undefined); diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 061ec94b..49e6d30b 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -1,7 +1,7 @@ import { MatrixClient } from "matrix-bot-sdk"; import { newTestUser } from "./clientHelper"; import { DraupnirTestContext } from "./mjolnirSetupUtils"; -import { ActionResult, DEFAULT_CONSEQUENCE_PROVIDER, MatrixRoomReference, Ok, Protection, ProtectionDescription, StandardProtectionSettings, StringRoomID, findConsequenceProvider } from "matrix-protection-suite"; +import { ActionResult, MatrixRoomReference, Ok, Protection, ProtectionDescription, StandardProtectionSettings, StringRoomID } from "matrix-protection-suite"; describe("Test: Report polling", function() { let client: MatrixClient; @@ -23,7 +23,9 @@ describe("Test: Report polling", function() { const testProtectionDescription: ProtectionDescription = { name: "jYvufI", description: "A test protection", - factory: function (description, consequenceProvider, protectedRoomsSet, context, settings): ActionResult { + capabilities: {}, + defaultCapabilities: {}, + factory: function (description, protectedRoomsSet, context, capabilities, settings): ActionResult> { return Ok({ handleEventReport(report) { if (report.reason === "x5h1Je") { @@ -41,11 +43,7 @@ describe("Test: Report polling", function() { {} ) } - const defaultConsequenceProvider = findConsequenceProvider(DEFAULT_CONSEQUENCE_PROVIDER); - if (defaultConsequenceProvider === undefined) { - throw new TypeError(`Default consequence provider should be defined mate`); - } - await draupnir.protectedRoomsSet.protections.addProtection(testProtectionDescription, defaultConsequenceProvider, draupnir.protectedRoomsSet, draupnir); + await draupnir.protectedRoomsSet.protections.addProtection(testProtectionDescription, {}, draupnir.protectedRoomsSet, draupnir); await client.doRequest( "POST", `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { From 6b9162bc9d01c71228b51ec7244c02959c649982 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 29 Feb 2024 19:10:17 +0000 Subject: [PATCH 123/160] Capability renderers and glue. --- src/Draupnir.ts | 3 + src/StandardConsequenceProvider.tsx | 258 ------------------ src/capabilities/CommonRenderers.tsx | 88 ++++++ src/capabilities/RendererMessageCollector.ts | 49 ++++ .../StandardUserConsequencesRenderer.tsx | 116 ++++++++ src/protections/DraupnirProtectionsIndex.ts | 3 + 6 files changed, 259 insertions(+), 258 deletions(-) delete mode 100644 src/StandardConsequenceProvider.tsx create mode 100644 src/capabilities/CommonRenderers.tsx create mode 100644 src/capabilities/RendererMessageCollector.ts create mode 100644 src/capabilities/StandardUserConsequencesRenderer.tsx diff --git a/src/Draupnir.ts b/src/Draupnir.ts index daae044f..40dadbac 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -40,6 +40,7 @@ import { renderProtectionFailedToStart } from "./StandardConsequenceProvider"; 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"; const log = new Logger('Draupnir'); @@ -80,6 +81,8 @@ export class Draupnir implements Client { private readonly timelineEventListener = this.handleTimelineEvent.bind(this); + public readonly capabilityMessageRenderer: RendererMessageCollector; + private constructor( public readonly client: MatrixSendClient, public readonly clientUserID: StringUserID, diff --git a/src/StandardConsequenceProvider.tsx b/src/StandardConsequenceProvider.tsx deleted file mode 100644 index 512f571e..00000000 --- a/src/StandardConsequenceProvider.tsx +++ /dev/null @@ -1,258 +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-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 { ActionError, ActionException, ActionExceptionKind, ActionResult, BasicConsequenceProvider, DEFAULT_CONSEQUENCE_PROVIDER, MatrixRoomReference, Ok, Permalinks, ProtectionDescription, ProtectionDescriptionInfo, RoomUpdateError, RoomUpdateException, SetMemberBanResultMap, StringEventID, StringRoomID, StringUserID, Task, applyPolicyRevisionToSetMembership, describeConsequenceProvider, isError } 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"; -import { DocumentNode } from "./commands/interface-manager/DeadDocument"; -import { printActionResult } from "./models/RoomUpdateError"; -import { Draupnir } from "./Draupnir"; -import { renderRoomPill } from "./commands/interface-manager/MatrixHelpRenderer"; - -interface ProviderContext { - client: MatrixSendClient; - managementRoomID: StringRoomID; -} - -async function renderConsequenceForEvent(client: MatrixSendClient, managementRoomID: StringRoomID, protection: ProtectionDescriptionInfo, roomID: StringRoomID, eventID: StringEventID, reason: string): Promise> { - await renderMatrixAndSend( - - Protection {protection.name}: Redacting event {Permalinks.forEvent(roomID, eventID)} for {reason}. - , - managementRoomID, - undefined, - client - ) - return Ok(undefined); -} - -const consequenceForEvent: BasicConsequenceProvider['consequenceForEvent'] = async function( - this: ProviderContext, protection, roomID, eventID, reason -): Promise> { - Task(renderConsequenceForEvent(this.client, this.managementRoomID, protection, roomID, eventID, reason)) - return this.client.redactEvent(roomID, eventID, reason).then( - (_) => Ok(undefined), - (exception) => ActionException.Result( - `Unable to redact the event ${eventID} when enforcing a consequence`, - { exception, exceptionKind: ActionExceptionKind.Unknown } - ) - ) -} - -async function renderConsequenceForUserInRoom(client: MatrixSendClient, managementRoomID: StringRoomID, protection: ProtectionDescriptionInfo, roomID: StringRoomID, userID: StringUserID, reason: string): Promise> { - await renderMatrixAndSend( - - Protection: {protection.name}: Banning user {userID} in {Permalinks.forRoom(roomID)} for {reason}. - , - managementRoomID, - undefined, - client - ); - return Ok(undefined); -} - -function banUser(client: MatrixSendClient, protection: ProtectionDescriptionInfo, roomID: StringRoomID, userID: StringUserID, reason: string): Promise> { - return client.banUser( - userID, roomID, reason - ).then( - (_) => Ok(undefined), - (exception) => ActionException.Result( - `Unable to ban the user ${userID} in ${roomID} when enforcing a consequence`, - { exception, exceptionKind: ActionExceptionKind.Unknown } - ) - ) -} - -const consequenceForUserInRoom: BasicConsequenceProvider['consequenceForUserInRoom'] = async function( - this: ProviderContext, protection, roomID, userID, reason -): Promise> { - Task(renderConsequenceForUserInRoom(this.client, this.managementRoomID, protection, roomID, userID, reason)); - return banUser(this.client, protection, roomID, userID, reason); -} - -/** - * This is an accompniment to `renderSetMembershipbans. - * Something more generic should be made, probably for RoomUpdateError and we - * make sure the ban consequence returns RoomUpdateError's. - */ -function renderRoomOutcome(roomID: StringRoomID, result: ActionResult): DocumentNode { - return -
        - {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} - {result.isOkay ? 'okay' : 'failed'} - {result.match(() => , (error) =>

        - There was an unexpected error when processing this ban:
        - {error.message}
        - {error instanceof ActionException - ?

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

        - : } -

        )} -
        -
        -} - -// TODO: Why do we only have StringRoomID's in the map? -// TODO: How do we make a common renderer for ActionResults? -// so that failures are shown consistently? -function renderSetMembershipBans(title: DocumentNode, map: SetMemberBanResultMap): DocumentNode { - return - {title}, - { - [...map.entries()].map(([userID, roomResults]) => { - return
        - {userID} will be banned from {roomResults.size} rooms. -
          {[...roomResults.entries()].map(([roomID, outcome]) => { - return
        • {renderRoomOutcome(roomID, outcome)}
        • - })}
        -
        - }) - } -
        -} - -const consequenceForUsersInRevision: BasicConsequenceProvider['consequenceForUsersInRevision'] = async function( - this: ProviderContext, description, setMembership, revision -) { - const results = await applyPolicyRevisionToSetMembership( - description, - revision, - setMembership, - (_description, roomID, userID, reason) => banUser(this.client, description, roomID, userID, reason) - ); - Task(renderMatrixAndSend( - { - renderSetMembershipBans( - Banning {results.size} users in protected rooms., - results - ) - }, - this.managementRoomID, - undefined, - this.client - ).then((_) => Ok(undefined))) - return Ok(undefined); -} - -const consequenceForServerACL: BasicConsequenceProvider['consequenceForServerACL'] = async function( - this: ProviderContext, aclContent -): Promise> { - // nothing to do - return Ok(undefined) -} - -const consequenceForServerACLInRoom: BasicConsequenceProvider['consequenceForServerACLInRoom'] = async function( - this: ProviderContext, _protection, roomID, aclContent -): Promise> { - return this.client.sendStateEvent(roomID, 'm.room.server_acl', '', aclContent).then( - (_) => Ok(undefined), - (exception) => ActionException.Result( - `Unable to set the server ACL in the room ${roomID}`, - { exception, exceptionKind: ActionExceptionKind.Unknown } - ) - ) -} - -const consequenceForServerInRoom: BasicConsequenceProvider['consequenceForServerInRoom'] = async function( -) { - return Ok(undefined); -} - -const unbanUserFromRoomsInSet: BasicConsequenceProvider['unbanUserFromRoomsInSet'] = async function( - this: ProviderContext, _protection, userID, protectedRoomsSet -): Promise> { - const errors: RoomUpdateError[] = []; - for (const room of protectedRoomsSet.protectedRoomsConfig.allRooms) { - const unbanResult = await this.client.unbanUser(userID, room.toRoomIDOrAlias()) - .then( - (_) => Ok(undefined), - (exception) => RoomUpdateException.Result( - `Unable to ban the user ${userID} from the room ${room.toPermalink()}`, { - exception, - exceptionKind: ActionExceptionKind.Unknown, - room, - } - ) - ); - if (isError(unbanResult)) { - errors.push(unbanResult.error); - } - } - Task(printActionResult(this.client, this.managementRoomID, errors, { - title: `There were errors unbanning ${userID} from protected rooms.`, - noErrorsText: `Done unbanning ${userID} from protected rooms - no errors.` - })); - return Ok(undefined); -} - -export function makeStandardBasicConsequenceProvider( - client: MatrixSendClient, - managementRoomID: StringRoomID -): BasicConsequenceProvider { - return { - consequenceForEvent, - consequenceForServerACL, - consequenceForUserInRoom, - consequenceForServerACLInRoom, - consequenceForServerInRoom, - consequenceForUsersInRevision, - unbanUserFromRoomsInSet, - client, - managementRoomID - } as unknown as BasicConsequenceProvider; -} - -describeConsequenceProvider({ - name: DEFAULT_CONSEQUENCE_PROVIDER, - description: 'Does what it says on the tin', - factory: function(draupnir) { - return makeStandardBasicConsequenceProvider( - draupnir.client, - draupnir.managementRoomID - ) - } -}) - -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/capabilities/CommonRenderers.tsx b/src/capabilities/CommonRenderers.tsx new file mode 100644 index 00000000..78e2c170 --- /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, 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 renderRoomSetResults(roomResults: Map>, { summary }: { summary: DocumentNode }): DocumentNode { + return
        + {summary} +
          {[...roomResults.entries()].map(([roomID, outcome]) => { + return
        • {renderRoomOutcome(roomID, outcome)}
        • + })}
        +
        +} 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/StandardUserConsequencesRenderer.tsx b/src/capabilities/StandardUserConsequencesRenderer.tsx new file mode 100644 index 00000000..4b620040 --- /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, renderRoomSetResults } 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 renderRoomSetResults(roomResults, { summary: {userID} will be {nnedword} from {roomResults.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/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts index 0050035f..3b04756d 100644 --- a/src/protections/DraupnirProtectionsIndex.ts +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -13,3 +13,6 @@ import './MessageIsMedia'; import './MessageIsVoice'; import './TrustedReporters'; import './WordList'; + +// import capability renderers and glue too. +import "../capabilities/capabilityIndex"; From fcc22dac5c0c814952154125f08a4171dade5bc5 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Mar 2024 19:21:17 +0000 Subject: [PATCH 124/160] capablity renderer stuff that didn't get committed --- .../DraupnirRendererMessageCollector.tsx | 38 ++++++++ .../ServerACLConsequencesRenderer.tsx | 88 +++++++++++++++++++ .../StandardEventConsequencesRenderer.tsx | 56 ++++++++++++ src/capabilities/capabilityIndex.ts | 3 + .../ProtectedRoomsSetRenderers.tsx | 32 +++++++ 5 files changed, 217 insertions(+) create mode 100644 src/capabilities/DraupnirRendererMessageCollector.tsx create mode 100644 src/capabilities/ServerACLConsequencesRenderer.tsx create mode 100644 src/capabilities/StandardEventConsequencesRenderer.tsx create mode 100644 src/capabilities/capabilityIndex.ts create mode 100644 src/protections/ProtectedRoomsSetRenderers.tsx diff --git a/src/capabilities/DraupnirRendererMessageCollector.tsx b/src/capabilities/DraupnirRendererMessageCollector.tsx new file mode 100644 index 00000000..9a47cabe --- /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/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/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/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 + ) +} From b145790b16710db07deb15b51ae100a3597296b0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Mar 2024 17:50:26 +0000 Subject: [PATCH 125/160] Add capabilityMessageRender to Draupnir class. --- src/Draupnir.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 40dadbac..36a2f6a7 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -36,11 +36,12 @@ import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReacti 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 { renderProtectionFailedToStart } from "./StandardConsequenceProvider"; 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'); @@ -125,6 +126,7 @@ export class Draupnir implements Client { this.commandTable, this.commandContext )); + this.capabilityMessageRenderer = new DraupnirRendererMessageCollector(this.client, this.managementRoomID); } public static async makeDraupnirBot( From 7c6c0bf189c83262171ce7e506c4cc6290abc754 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Mar 2024 17:50:56 +0000 Subject: [PATCH 126/160] Introduce MPS RoomSetResult to capability renderers. --- src/capabilities/CommonRenderers.tsx | 6 +++--- src/capabilities/StandardUserConsequencesRenderer.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/capabilities/CommonRenderers.tsx b/src/capabilities/CommonRenderers.tsx index 78e2c170..53fc31b7 100644 --- a/src/capabilities/CommonRenderers.tsx +++ b/src/capabilities/CommonRenderers.tsx @@ -8,7 +8,7 @@ // https://github.com/matrix-org/mjolnir // -import { ActionError, ActionException, ActionResult, DescriptionMeta, MatrixRoomReference, StringRoomID, isOk } from "matrix-protection-suite"; +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"; @@ -78,10 +78,10 @@ export function renderRoomOutcome(roomID: StringRoomID, result: ActionResult>, { summary }: { summary: DocumentNode }): DocumentNode { +export function renderRoomSetResult(roomResults: RoomSetResult, { summary }: { summary: DocumentNode }): DocumentNode { return
        {summary} -
          {[...roomResults.entries()].map(([roomID, outcome]) => { +
            {[...roomResults.map.entries()].map(([roomID, outcome]) => { return
          • {renderRoomOutcome(roomID, outcome)}
          • })}
        diff --git a/src/capabilities/StandardUserConsequencesRenderer.tsx b/src/capabilities/StandardUserConsequencesRenderer.tsx index 4b620040..0b171d2b 100644 --- a/src/capabilities/StandardUserConsequencesRenderer.tsx +++ b/src/capabilities/StandardUserConsequencesRenderer.tsx @@ -11,7 +11,7 @@ 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, renderRoomSetResults } from "./CommonRenderers"; +import { renderFailedSingularConsequence, renderRoomSetResult } from "./CommonRenderers"; import { DocumentNode } from "../commands/interface-manager/DeadDocument"; import { Draupnir } from "../Draupnir"; @@ -28,7 +28,7 @@ function renderResultForUserInSetMap(usersInSetMap: ResultForUserInSetMap, { return
        {description.name}: {ingword} {usersInSetMap.size} {usersInSetMap.size === 1 ? 'user' : 'users'} from protected rooms. {[...usersInSetMap.entries()].map(([userID, roomResults]) => { - return renderRoomSetResults(roomResults, { summary: {userID} will be {nnedword} from {roomResults.size} rooms. }) + return renderRoomSetResult(roomResults, { summary: {userID} will be {nnedword} from {roomResults.map.size} rooms. }) })}
        } From 16b8b5b07aa522354bcc81e9139e174c8b76d083 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Mar 2024 18:15:07 +0000 Subject: [PATCH 127/160] Remove displayname from command handler prefixes. It's unclear to me how to reliably implement this, so i have removed it for now. The problem is setting up code to watch for changes to the displyname in the management room. --- src/Draupnir.ts | 2 -- src/commands/CommandHandler.ts | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 36a2f6a7..d8c92cc3 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -52,7 +52,6 @@ const log = new Logger('Draupnir'); // And giving it to the class was a dumb easy way of doing that. export class Draupnir implements Client { - private readonly displayName: string; /** * This is for users who are not listed on a watchlist, * but have been flagged by the automatic spam detection as suispicous @@ -204,7 +203,6 @@ export class Draupnir implements Client { { prefix: COMMAND_PREFIX, localpart: userLocalpart(this.clientUserID), - displayName: this.displayName, userId: this.clientUserID, additionalPrefixes: this.config.commands.additionalPrefixes, allowNoPrefix: this.config.commands.allowNoPrefix, diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 5dddb84e..4dbe2d45 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -104,19 +104,17 @@ export function extractCommandFromMessageBody( body: string, { prefix, localpart, - displayName, userId, additionalPrefixes, allowNoPrefix }: { prefix: string, localpart: string, - displayName: string, userId: string, additionalPrefixes: string[], allowNoPrefix: boolean }): string | undefined { - const plainPrefixes = [prefix, localpart, displayName, userId, ...additionalPrefixes]; + const plainPrefixes = [prefix, localpart, userId, ...additionalPrefixes]; const allPossiblePrefixes = [ ...plainPrefixes.map(p => `!${p}`), ...plainPrefixes.map(p => `${p}:`), From d1c13d63dd4d86f1fdf43ab975feb2b709460e9d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Mar 2024 18:55:39 +0000 Subject: [PATCH 128/160] MPS updates. See https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk/commit/058f3a07f3ed07ea75b048368c11c7a92e3c248a --- package.json | 4 ++-- yarn.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 41098deb..6e83dff9 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git#0.9.0", - "matrix-protection-suite-for-matrix-bot-sdk": "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#0.9.0", + "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git", + "matrix-protection-suite-for-matrix-bot-sdk": "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/yarn.lock b/yarn.lock index 5e33b406..192199c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2319,13 +2319,13 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#0.9.0": +"matrix-protection-suite-for-matrix-bot-sdk@git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git": version "0.9.0" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#ce998f2c56cdeed74229c45977fd836464894854" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#058f3a07f3ed07ea75b048368c11c7a92e3c248a" -"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git#0.9.0": - version "0.9.0" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#fa2944d9ef325d88d0113e0d50936d50da2180d8" +"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git": + version "0.9.1" + resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#335f39a90307d79c690bc50fa9cd6fac57cfbfb5" dependencies: await-lock "^2.2.2" crypto-js "^4.1.1" From 14e6dc84573e9fabae0d68210f6e1b77f2b99477 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 1 Mar 2024 19:20:10 +0000 Subject: [PATCH 129/160] Remove suprious method from DraupnirManager The ClientsInRoomsMap is updated in the appservice event handler anyhow. --- src/draupnirfactory/StandardDraupnirManager.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index 817198e1..520eda4e 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionResult, ClientsInRoomMap, MatrixRoomID, RoomEvent, StringRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { ActionError, ActionResult, ClientsInRoomMap, MatrixRoomID, StringUserID, isError } from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; @@ -122,10 +122,6 @@ export class StandardDraupnirManager { this.readyDraupnirs.set(clientUserID, draupnir); } } - - public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { - this.clientsInRooms.handleTimelineEvent(roomID, event); - } } export class UnstartedDraupnir { From 7a10ec4a1d655db277b8911c2f7c177c18a4e0a6 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 14:08:35 +0000 Subject: [PATCH 130/160] Use NPM for MPS depeendencies rather than git. --- package.json | 4 ++-- yarn.lock | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 6e83dff9..08f64108 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "git+https://github.com/Gnuxie/matrix-protection-suite.git", - "matrix-protection-suite-for-matrix-bot-sdk": "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.10.4", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.10.1", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/yarn.lock b/yarn.lock index 192199c0..4da22270 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2319,13 +2319,15 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git": - version "0.9.0" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite-for-matrix-bot-sdk.git#058f3a07f3ed07ea75b048368c11c7a92e3c248a" - -"matrix-protection-suite@git+https://github.com/Gnuxie/matrix-protection-suite.git": - version "0.9.1" - resolved "git+https://github.com/Gnuxie/matrix-protection-suite.git#335f39a90307d79c690bc50fa9cd6fac57cfbfb5" +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.10.1.tgz#aff679c9ae15ed6a97cdb35213f9425f0fd7a461" + integrity sha512-mgNLWTxt8lbN39/SNczOOQEX7Ox/GEg6SBLyGow+EA7of/qwAPtrBpXTmkI6x//PPAZ4XMX29TO9+Jis29Dmng== + +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.10.4.tgz#bf6068801883318eddfc9bd3835057c39cb0376d" + integrity sha512-WpxgGUYAAgTG/PHvWwkqJGyAqyGyouhKDExBqPJA/orrlOQ82/xUpuZ58ZARZ4rpzmcwLv5Fes8JEQzerZuVbQ== dependencies: await-lock "^2.2.2" crypto-js "^4.1.1" From 96428d617dedb5e48bbb982a8b3f760593778ee0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 16:33:15 +0000 Subject: [PATCH 131/160] Fix CommandReaderTest.ts. --- test/commands/CommandReaderTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/commands/CommandReaderTest.ts b/test/commands/CommandReaderTest.ts index 143f717d..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 { MatrixRoomAlias, MatrixRoomID, MatrixRoomReference } from "matrix-protection-suite"; +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() { From 0cb0beecb04e96bcf5385c54f6a124303ddd0e09 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 16:59:02 +0000 Subject: [PATCH 132/160] Tidy up helloTest slightly. --- test/integration/helloTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/helloTest.ts b/test/integration/helloTest.ts index ab019658..cdc4425f 100644 --- a/test/integration/helloTest.ts +++ b/test/integration/helloTest.ts @@ -25,5 +25,5 @@ describe("Test: !help command", function() { }); await client.sendMessage(this.draupnir!.managementRoomID, {msgtype: "m.text", body: "!draupnir help"}) await reply - } as any) + } as unknown as Mocha.AsyncFunc) }) From 692bc298e152dabb6346d29031d682e3ae4f1208 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 16:59:35 +0000 Subject: [PATCH 133/160] Add 'done' from mocha test context to draupnir test context. --- test/integration/mjolnirSetupUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 4573faf4..1500b244 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -33,7 +33,7 @@ import { WebAPIs } from "../../src/webapis/WebAPIs"; patchMatrixClient(); // they are add [key: string]: any to their interface, amazing. -export type SafeMochaContext = Pick +export type SafeMochaContext = Pick export interface DraupnirTestContext extends SafeMochaContext { draupnir?: Draupnir From 40ffa2a114f22d0164ffd3adbacedb3eef45ae73 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 16:59:58 +0000 Subject: [PATCH 134/160] Fix hijack room command test. --- .../commands/hijackRoomCommandTest.ts | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/test/integration/commands/hijackRoomCommandTest.ts b/test/integration/commands/hijackRoomCommandTest.ts index 3c630f15..2fe39372 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 { 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); }); From c3cfad93e7be334d982a6b94dab15d46c576df64 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 17:08:21 +0000 Subject: [PATCH 135/160] Fix shutdown room command test. --- .../commands/shutdownCommandTest.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) 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); }); From 6ff8c52acac72efc0c18ad378ccfe882a2d22b43 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 20 Mar 2024 17:10:25 +0000 Subject: [PATCH 136/160] Remove detectFederationLagTest idk this one might come back some time but we don't have the protection and associated command anymore. --- test/integration/detectFederationLagTest.ts | 266 -------------------- 1 file changed, 266 deletions(-) delete mode 100644 test/integration/detectFederationLagTest.ts 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"); - }); -}); From 6d52137501378734e52a8915ad7877f384fb6ee4 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 15:26:33 +0000 Subject: [PATCH 137/160] Attempt make draupnirFactory accessible to integration tests But this won't work as we'd have to mess about a lot with the clientsinroommap --- src/DraupnirBotMode.ts | 54 +++++++++++-------- .../BotSDKManualClientProvider.ts | 51 ++++++++++++++++++ src/draupnirfactory/DraupnirFactory.ts | 8 +-- src/index.ts | 6 +-- test/integration/clientHelper.ts | 7 ++- test/integration/clientProviderUtils.ts | 43 +++++++++++++++ test/integration/fixtures.ts | 9 +++- test/integration/manualLaunchScript.ts | 4 +- test/integration/mjolnirSetupUtils.ts | 15 ++++-- 9 files changed, 158 insertions(+), 39 deletions(-) create mode 100644 src/draupnirfactory/BotSDKManualClientProvider.ts create mode 100644 test/integration/clientProviderUtils.ts diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 622f4b33..a9dbe57c 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -55,6 +55,34 @@ export function constructWebAPIs(draupnir: Draupnir): WebAPIs { return new WebAPIs(draupnir.reportManager, draupnir.config); } +export async function makeDraupnirFactoryForBotMode( + client: MatrixSendClient +): Promise { + const clientUserId = await client.getUserId(); + if (!isStringUserID(clientUserId)) { + throw new TypeError(`${clientUserId} is not a valid mxid`); + } + 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 + ); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + return new DraupnirFactory( + clientsInRoomMap, + clientCapabilityFactory, + clientProvider, + roomStateManagerFactory + ); +} + /** * This is a file for providing default concrete implementations * for all things to bootstrap Draupnir in 'bot mode'. @@ -65,6 +93,7 @@ export function constructWebAPIs(draupnir: Draupnir): WebAPIs { export async function makeDraupnirBotModeFromConfig( client: MatrixSendClient, + draupnirFactory: DraupnirFactory, matrixEmitter: SafeMatrixEmitter, config: IConfig ): Promise { @@ -81,25 +110,6 @@ export async function makeDraupnirBotModeFromConfig( 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 - ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); - const draupnirFactory = new DraupnirFactory( - clientsInRoomMap, - clientCapabilityFactory, - clientProvider, - roomStateManagerFactory - ); const draupnir = await draupnirFactory.makeDraupnir( clientUserId, managementRoom.ok, @@ -110,11 +120,11 @@ export async function makeDraupnirBotModeFromConfig( throw new Error(`Unable to create Draupnir: ${error.message}`); } matrixEmitter.on('room.invite', (roomID, event) => { - clientsInRoomMap.handleTimelineEvent(roomID, event); + draupnirFactory.clientsInRoomMap.handleTimelineEvent(roomID, event); }) matrixEmitter.on('room.event', (roomID, event) => { - roomStateManagerFactory.handleTimelineEvent(roomID, event); - clientsInRoomMap.handleTimelineEvent(roomID, event); + draupnirFactory.roomStateManagerFactory.handleTimelineEvent(roomID, event); + draupnirFactory.clientsInRoomMap.handleTimelineEvent(roomID, event); }) return draupnir.ok; } diff --git a/src/draupnirfactory/BotSDKManualClientProvider.ts b/src/draupnirfactory/BotSDKManualClientProvider.ts new file mode 100644 index 00000000..4834f861 --- /dev/null +++ b/src/draupnirfactory/BotSDKManualClientProvider.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { StringUserID } from "matrix-protection-suite"; +import { ClientForUserID, MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; + +interface ClientProvider { + findClientAsync: ClientForUserID; +} + +/** + * Implements `ClientForUserID` for the bot mode of Draupnir. + * Introduced to allow us to use MPS from Draupnir's old Mjolnir integration + * tests. Which we do want to eliminate soon, but we need to get them working + * for Draupnir MPS first. + * + * DO NOT USE in the appservice unless you want to have memory leaks and a real + * bad time. + */ +export class BotSDKManualClientProvider implements ClientProvider { + private readonly clients = new Map(); + public constructor() { + // nothing to do. + } + + public findClient(clientUserID: StringUserID): MatrixSendClient { + const entry = this.clients.get(clientUserID); + if (entry === undefined) { + throw new TypeError(`Cannot find a client for ${clientUserID}`); + } else { + return entry; + } + } + + public async findClientAsync(clientUserID: StringUserID): Promise { + return this.findClient(clientUserID); + } + + public toClientForUserID(): ClientForUserID { + return this.findClientAsync.bind(this); + } + + public addClient(clientUserID: StringUserID, client: MatrixSendClient): void { + this.clients.set(clientUserID, client); + } + + public removeClient(clientUserID: StringUserID): void { + this.clients.delete(clientUserID); + } +} diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index 26422df2..17a07a73 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -11,10 +11,10 @@ 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 + public readonly clientsInRoomMap: ClientsInRoomMap, + public readonly clientCapabilityFactory: ClientCapabilityFactory, + public readonly clientProvider: ClientForUserID, + public readonly roomStateManagerFactory: RoomStateManagerFactory ) { // nothing to do. } diff --git a/src/index.ts b/src/index.ts index 3c094e6c..2c8fc4fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ import { import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { read as configRead } from "./config"; import { initializeSentry, patchMatrixClient } from "./utils"; -import { constructWebAPIs, makeDraupnirBotModeFromConfig } from "./DraupnirBotMode"; +import { constructWebAPIs, makeDraupnirBotModeFromConfig, makeDraupnirFactoryForBotMode } from "./DraupnirBotMode"; import { Draupnir } from "./Draupnir"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder } from "matrix-protection-suite"; @@ -90,8 +90,8 @@ import { WebAPIs } from "./webapis/WebAPIs"; } patchMatrixClient(); config.RUNTIME.client = client; - - bot = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); + const draupnirFactory = await makeDraupnirFactoryForBotMode(client); + bot = await makeDraupnirBotModeFromConfig(client, draupnirFactory, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); apis = constructWebAPIs(bot); } catch (err) { console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`); diff --git a/test/integration/clientHelper.ts b/test/integration/clientHelper.ts index ce9fe5c1..d0b556a9 100644 --- a/test/integration/clientHelper.ts +++ b/test/integration/clientHelper.ts @@ -1,6 +1,8 @@ import { HmacSHA1 } from "crypto-js"; import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk"; import "../../src/utils"; // we need this for the patches to matrix-bot-sdk's `getRequestFn`. +import { findBotSDKManualClientProvider } from "./clientProviderUtils"; +import { StringUserID } from "matrix-protection-suite"; const REGISTRATION_ATTEMPTS = 10; const REGISTRATION_RETRY_BASE_DELAY_MS = 100; @@ -122,9 +124,10 @@ export async function newTestUser(homeserver: string, options: RegistrationOptio const username = await registerNewTestUser(homeserver, options); const pantalaimon = new PantalaimonClient(homeserver, new MemoryStorageProvider()); const client = await pantalaimon.createClientWithCredentials(username, username); + const clientUserID = await client.getUserId() as StringUserID; + findBotSDKManualClientProvider().addClient(clientUserID, client); if (!options.isThrottled) { - let userId = await client.getUserId(); - await overrideRatelimitForUser(homeserver, userId); + await overrideRatelimitForUser(homeserver, clientUserID); } return client; } diff --git a/test/integration/clientProviderUtils.ts b/test/integration/clientProviderUtils.ts new file mode 100644 index 00000000..6cdfc28e --- /dev/null +++ b/test/integration/clientProviderUtils.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { DefaultEventDecoder, StandardClientsInRoomMap } from 'matrix-protection-suite'; +import { BotSDKManualClientProvider } from '../../src/draupnirfactory/BotSDKManualClientProvider'; +import { DraupnirFactory } from '../../src/draupnirfactory/DraupnirFactory'; +import { ClientCapabilityFactory, RoomStateManagerFactory } from 'matrix-protection-suite-for-matrix-bot-sdk'; + +// I hate this but whatever. +// We need this so that test clients get access to the room state manager +// factory. +// Again, integration tests need nuking and replacing with dedicated tests +// for commands and glue, seperately. + +let clientProvider: BotSDKManualClientProvider | undefined; + +export function findBotSDKManualClientProvider(): BotSDKManualClientProvider { + if (clientProvider === undefined) { + clientProvider = new BotSDKManualClientProvider(); + } + return clientProvider; +} + +export function destroyBotSDKManualClientProvider(): void { + clientProvider = undefined; +} + +export function makeDraupnirFactoryForIntegrationTest(): DraupnirFactory { + const clientsInRoomMap = new StandardClientsInRoomMap(); + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + findBotSDKManualClientProvider().toClientForUserID(), + DefaultEventDecoder + ); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + return new DraupnirFactory( + clientsInRoomMap, + clientCapabilityFactory, + findBotSDKManualClientProvider().toClientForUserID(), + roomStateManagerFactory + ); +} diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index e8f5b570..1c3ea505 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -3,6 +3,7 @@ import { constructWebAPIs } from "../../src/DraupnirBotMode"; import { read as configRead } from "../../src/config"; import { patchMatrixClient } from "../../src/utils"; import { DraupnirTestContext, draupnirClient, makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils"; +import { destroyBotSDKManualClientProvider, findBotSDKManualClientProvider, makeDraupnirFactoryForIntegrationTest } from "./clientProviderUtils"; patchMatrixClient(); @@ -20,7 +21,11 @@ export const mochaHooks = { this.timeout(30000); const config = this.config = configRead(); this.managementRoomAlias = config.managementRoom; - this.draupnir = await makeMjolnir(config); + // draupnir factory + const draupnirFactory = makeDraupnirFactoryForIntegrationTest(); + this.roomStateManagerFactory = draupnirFactory.roomStateManagerFactory; + // draupnir + this.draupnir = await makeMjolnir(config, draupnirFactory); config.RUNTIME.client = draupnirClient()!; await Promise.all([ this.draupnir.client.setAccountData(MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, { rooms: [] }), @@ -39,7 +44,7 @@ export const mochaHooks = { this.apis?.stop(); draupnirClient()?.stop(); this.draupnir?.stop(); - + destroyBotSDKManualClientProvider(); // remove alias from management room and leave it. if (this.draupnir !== undefined) { await Promise.all([ diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 42c22854..0b0fb9ae 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -5,10 +5,12 @@ import { draupnirClient, makeMjolnir } from "./mjolnirSetupUtils"; import { read as configRead } from '../../src/config'; import { constructWebAPIs } from "../../src/DraupnirBotMode"; +import { makeDraupnirFactoryForIntegrationTest } from "./clientProviderUtils"; (async () => { const config = configRead(); - let mjolnir = await makeMjolnir(config); + + let mjolnir = await makeMjolnir(config, makeDraupnirFactoryForIntegrationTest()); await mjolnir.start(); const apis = constructWebAPIs(mjolnir); await draupnirClient()?.start(); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 1500b244..51e9c975 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -26,9 +26,11 @@ 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 } from "matrix-protection-suite"; +import { RoomStateManagerFactory, SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEventDecoder, StringUserID } from "matrix-protection-suite"; import { WebAPIs } from "../../src/webapis/WebAPIs"; +import { DraupnirFactory } from "../../src/draupnirfactory/DraupnirFactory"; +import { findBotSDKManualClientProvider } from "./clientProviderUtils"; patchMatrixClient(); @@ -40,6 +42,7 @@ export interface DraupnirTestContext extends SafeMochaContext { managementRoomAlias?: string, apis?: WebAPIs, config: IConfig, + roomStateManagerFactory: RoomStateManagerFactory, } /** @@ -90,16 +93,18 @@ let globalMjolnir: Draupnir | null; /** * Return a test instance of Mjolnir. */ -export async function makeMjolnir(config: IConfig): Promise { +export async function makeMjolnir(config: IConfig, draupnirFactory: DraupnirFactory): Promise { await configureMjolnir(config); LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); LogService.info("test/mjolnirSetupUtils", "Starting bot..."); const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()); const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password); - await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId()); + const clientUserID = await client.getUserId() as StringUserID; + findBotSDKManualClientProvider().addClient(clientUserID, client); + await overrideRatelimitForUser(config.homeserverUrl, clientUserID); await ensureAliasedRoomExists(client, config.managementRoom); - let mj = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); + let mj = await makeDraupnirBotModeFromConfig(client, draupnirFactory, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); globalClient = client; globalMjolnir = mj; return mj; From 979bef6013ddb39be023fa7c9090300523cc3b0a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 15:27:29 +0000 Subject: [PATCH 138/160] Revert "Attempt make draupnirFactory accessible to integration tests" This reverts commit 68ca69882bd7627414951aef11985408fd29dec8. --- src/DraupnirBotMode.ts | 54 ++++++++----------- .../BotSDKManualClientProvider.ts | 51 ------------------ src/draupnirfactory/DraupnirFactory.ts | 8 +-- src/index.ts | 6 +-- test/integration/clientHelper.ts | 7 +-- test/integration/clientProviderUtils.ts | 43 --------------- test/integration/fixtures.ts | 9 +--- test/integration/manualLaunchScript.ts | 4 +- test/integration/mjolnirSetupUtils.ts | 15 ++---- 9 files changed, 39 insertions(+), 158 deletions(-) delete mode 100644 src/draupnirfactory/BotSDKManualClientProvider.ts delete mode 100644 test/integration/clientProviderUtils.ts diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index a9dbe57c..622f4b33 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -55,34 +55,6 @@ export function constructWebAPIs(draupnir: Draupnir): WebAPIs { return new WebAPIs(draupnir.reportManager, draupnir.config); } -export async function makeDraupnirFactoryForBotMode( - client: MatrixSendClient -): Promise { - const clientUserId = await client.getUserId(); - if (!isStringUserID(clientUserId)) { - throw new TypeError(`${clientUserId} is not a valid mxid`); - } - 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 - ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); - return new DraupnirFactory( - clientsInRoomMap, - clientCapabilityFactory, - clientProvider, - roomStateManagerFactory - ); -} - /** * This is a file for providing default concrete implementations * for all things to bootstrap Draupnir in 'bot mode'. @@ -93,7 +65,6 @@ export async function makeDraupnirFactoryForBotMode( export async function makeDraupnirBotModeFromConfig( client: MatrixSendClient, - draupnirFactory: DraupnirFactory, matrixEmitter: SafeMatrixEmitter, config: IConfig ): Promise { @@ -110,6 +81,25 @@ export async function makeDraupnirBotModeFromConfig( 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 + ); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + const draupnirFactory = new DraupnirFactory( + clientsInRoomMap, + clientCapabilityFactory, + clientProvider, + roomStateManagerFactory + ); const draupnir = await draupnirFactory.makeDraupnir( clientUserId, managementRoom.ok, @@ -120,11 +110,11 @@ export async function makeDraupnirBotModeFromConfig( throw new Error(`Unable to create Draupnir: ${error.message}`); } matrixEmitter.on('room.invite', (roomID, event) => { - draupnirFactory.clientsInRoomMap.handleTimelineEvent(roomID, event); + clientsInRoomMap.handleTimelineEvent(roomID, event); }) matrixEmitter.on('room.event', (roomID, event) => { - draupnirFactory.roomStateManagerFactory.handleTimelineEvent(roomID, event); - draupnirFactory.clientsInRoomMap.handleTimelineEvent(roomID, event); + roomStateManagerFactory.handleTimelineEvent(roomID, event); + clientsInRoomMap.handleTimelineEvent(roomID, event); }) return draupnir.ok; } diff --git a/src/draupnirfactory/BotSDKManualClientProvider.ts b/src/draupnirfactory/BotSDKManualClientProvider.ts deleted file mode 100644 index 4834f861..00000000 --- a/src/draupnirfactory/BotSDKManualClientProvider.ts +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Gnuxie -// -// SPDX-License-Identifier: AFL-3.0 - -import { StringUserID } from "matrix-protection-suite"; -import { ClientForUserID, MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; - -interface ClientProvider { - findClientAsync: ClientForUserID; -} - -/** - * Implements `ClientForUserID` for the bot mode of Draupnir. - * Introduced to allow us to use MPS from Draupnir's old Mjolnir integration - * tests. Which we do want to eliminate soon, but we need to get them working - * for Draupnir MPS first. - * - * DO NOT USE in the appservice unless you want to have memory leaks and a real - * bad time. - */ -export class BotSDKManualClientProvider implements ClientProvider { - private readonly clients = new Map(); - public constructor() { - // nothing to do. - } - - public findClient(clientUserID: StringUserID): MatrixSendClient { - const entry = this.clients.get(clientUserID); - if (entry === undefined) { - throw new TypeError(`Cannot find a client for ${clientUserID}`); - } else { - return entry; - } - } - - public async findClientAsync(clientUserID: StringUserID): Promise { - return this.findClient(clientUserID); - } - - public toClientForUserID(): ClientForUserID { - return this.findClientAsync.bind(this); - } - - public addClient(clientUserID: StringUserID, client: MatrixSendClient): void { - this.clients.set(clientUserID, client); - } - - public removeClient(clientUserID: StringUserID): void { - this.clients.delete(clientUserID); - } -} diff --git a/src/draupnirfactory/DraupnirFactory.ts b/src/draupnirfactory/DraupnirFactory.ts index 17a07a73..26422df2 100644 --- a/src/draupnirfactory/DraupnirFactory.ts +++ b/src/draupnirfactory/DraupnirFactory.ts @@ -11,10 +11,10 @@ import { makeProtectedRoomsSet } from "./DraupnirProtectedRoomsSet"; export class DraupnirFactory { public constructor( - public readonly clientsInRoomMap: ClientsInRoomMap, - public readonly clientCapabilityFactory: ClientCapabilityFactory, - public readonly clientProvider: ClientForUserID, - public readonly roomStateManagerFactory: RoomStateManagerFactory + private readonly clientsInRoomMap: ClientsInRoomMap, + private readonly clientCapabilityFactory: ClientCapabilityFactory, + private readonly clientProvider: ClientForUserID, + private readonly roomStateManagerFactory: RoomStateManagerFactory ) { // nothing to do. } diff --git a/src/index.ts b/src/index.ts index 2c8fc4fd..3c094e6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ import { import { StoreType } from "@matrix-org/matrix-sdk-crypto-nodejs"; import { read as configRead } from "./config"; import { initializeSentry, patchMatrixClient } from "./utils"; -import { constructWebAPIs, makeDraupnirBotModeFromConfig, makeDraupnirFactoryForBotMode } from "./DraupnirBotMode"; +import { constructWebAPIs, makeDraupnirBotModeFromConfig } from "./DraupnirBotMode"; import { Draupnir } from "./Draupnir"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder } from "matrix-protection-suite"; @@ -90,8 +90,8 @@ import { WebAPIs } from "./webapis/WebAPIs"; } patchMatrixClient(); config.RUNTIME.client = client; - const draupnirFactory = await makeDraupnirFactoryForBotMode(client); - bot = await makeDraupnirBotModeFromConfig(client, draupnirFactory, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); + + bot = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); apis = constructWebAPIs(bot); } catch (err) { console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`); diff --git a/test/integration/clientHelper.ts b/test/integration/clientHelper.ts index d0b556a9..ce9fe5c1 100644 --- a/test/integration/clientHelper.ts +++ b/test/integration/clientHelper.ts @@ -1,8 +1,6 @@ import { HmacSHA1 } from "crypto-js"; import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk"; import "../../src/utils"; // we need this for the patches to matrix-bot-sdk's `getRequestFn`. -import { findBotSDKManualClientProvider } from "./clientProviderUtils"; -import { StringUserID } from "matrix-protection-suite"; const REGISTRATION_ATTEMPTS = 10; const REGISTRATION_RETRY_BASE_DELAY_MS = 100; @@ -124,10 +122,9 @@ export async function newTestUser(homeserver: string, options: RegistrationOptio const username = await registerNewTestUser(homeserver, options); const pantalaimon = new PantalaimonClient(homeserver, new MemoryStorageProvider()); const client = await pantalaimon.createClientWithCredentials(username, username); - const clientUserID = await client.getUserId() as StringUserID; - findBotSDKManualClientProvider().addClient(clientUserID, client); if (!options.isThrottled) { - await overrideRatelimitForUser(homeserver, clientUserID); + let userId = await client.getUserId(); + await overrideRatelimitForUser(homeserver, userId); } return client; } diff --git a/test/integration/clientProviderUtils.ts b/test/integration/clientProviderUtils.ts deleted file mode 100644 index 6cdfc28e..00000000 --- a/test/integration/clientProviderUtils.ts +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Gnuxie -// -// SPDX-License-Identifier: AFL-3.0 - -import { DefaultEventDecoder, StandardClientsInRoomMap } from 'matrix-protection-suite'; -import { BotSDKManualClientProvider } from '../../src/draupnirfactory/BotSDKManualClientProvider'; -import { DraupnirFactory } from '../../src/draupnirfactory/DraupnirFactory'; -import { ClientCapabilityFactory, RoomStateManagerFactory } from 'matrix-protection-suite-for-matrix-bot-sdk'; - -// I hate this but whatever. -// We need this so that test clients get access to the room state manager -// factory. -// Again, integration tests need nuking and replacing with dedicated tests -// for commands and glue, seperately. - -let clientProvider: BotSDKManualClientProvider | undefined; - -export function findBotSDKManualClientProvider(): BotSDKManualClientProvider { - if (clientProvider === undefined) { - clientProvider = new BotSDKManualClientProvider(); - } - return clientProvider; -} - -export function destroyBotSDKManualClientProvider(): void { - clientProvider = undefined; -} - -export function makeDraupnirFactoryForIntegrationTest(): DraupnirFactory { - const clientsInRoomMap = new StandardClientsInRoomMap(); - const roomStateManagerFactory = new RoomStateManagerFactory( - clientsInRoomMap, - findBotSDKManualClientProvider().toClientForUserID(), - DefaultEventDecoder - ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); - return new DraupnirFactory( - clientsInRoomMap, - clientCapabilityFactory, - findBotSDKManualClientProvider().toClientForUserID(), - roomStateManagerFactory - ); -} diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 1c3ea505..e8f5b570 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -3,7 +3,6 @@ import { constructWebAPIs } from "../../src/DraupnirBotMode"; import { read as configRead } from "../../src/config"; import { patchMatrixClient } from "../../src/utils"; import { DraupnirTestContext, draupnirClient, makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils"; -import { destroyBotSDKManualClientProvider, findBotSDKManualClientProvider, makeDraupnirFactoryForIntegrationTest } from "./clientProviderUtils"; patchMatrixClient(); @@ -21,11 +20,7 @@ export const mochaHooks = { this.timeout(30000); const config = this.config = configRead(); this.managementRoomAlias = config.managementRoom; - // draupnir factory - const draupnirFactory = makeDraupnirFactoryForIntegrationTest(); - this.roomStateManagerFactory = draupnirFactory.roomStateManagerFactory; - // draupnir - this.draupnir = await makeMjolnir(config, draupnirFactory); + this.draupnir = await makeMjolnir(config); config.RUNTIME.client = draupnirClient()!; await Promise.all([ this.draupnir.client.setAccountData(MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, { rooms: [] }), @@ -44,7 +39,7 @@ export const mochaHooks = { this.apis?.stop(); draupnirClient()?.stop(); this.draupnir?.stop(); - destroyBotSDKManualClientProvider(); + // remove alias from management room and leave it. if (this.draupnir !== undefined) { await Promise.all([ diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 0b0fb9ae..42c22854 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -5,12 +5,10 @@ import { draupnirClient, makeMjolnir } from "./mjolnirSetupUtils"; import { read as configRead } from '../../src/config'; import { constructWebAPIs } from "../../src/DraupnirBotMode"; -import { makeDraupnirFactoryForIntegrationTest } from "./clientProviderUtils"; (async () => { const config = configRead(); - - let mjolnir = await makeMjolnir(config, makeDraupnirFactoryForIntegrationTest()); + let mjolnir = await makeMjolnir(config); await mjolnir.start(); const apis = constructWebAPIs(mjolnir); await draupnirClient()?.start(); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 51e9c975..1500b244 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -26,11 +26,9 @@ import { initializeSentry, patchMatrixClient } from "../../src/utils"; import { IConfig } from "../../src/config"; import { Draupnir } from "../../src/Draupnir"; import { makeDraupnirBotModeFromConfig } from "../../src/DraupnirBotMode"; -import { RoomStateManagerFactory, SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { DefaultEventDecoder, StringUserID } from "matrix-protection-suite"; +import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { DefaultEventDecoder } from "matrix-protection-suite"; import { WebAPIs } from "../../src/webapis/WebAPIs"; -import { DraupnirFactory } from "../../src/draupnirfactory/DraupnirFactory"; -import { findBotSDKManualClientProvider } from "./clientProviderUtils"; patchMatrixClient(); @@ -42,7 +40,6 @@ export interface DraupnirTestContext extends SafeMochaContext { managementRoomAlias?: string, apis?: WebAPIs, config: IConfig, - roomStateManagerFactory: RoomStateManagerFactory, } /** @@ -93,18 +90,16 @@ let globalMjolnir: Draupnir | null; /** * Return a test instance of Mjolnir. */ -export async function makeMjolnir(config: IConfig, draupnirFactory: DraupnirFactory): Promise { +export async function makeMjolnir(config: IConfig): Promise { await configureMjolnir(config); LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); LogService.info("test/mjolnirSetupUtils", "Starting bot..."); const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()); const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password); - const clientUserID = await client.getUserId() as StringUserID; - findBotSDKManualClientProvider().addClient(clientUserID, client); - await overrideRatelimitForUser(config.homeserverUrl, clientUserID); + await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId()); await ensureAliasedRoomExists(client, config.managementRoom); - let mj = await makeDraupnirBotModeFromConfig(client, draupnirFactory, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); + let mj = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); globalClient = client; globalMjolnir = mj; return mj; From c9db03304310256e912e5892dc94d08a2923a707 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 16:51:50 +0000 Subject: [PATCH 139/160] fix utilsTest.ts --- test/integration/utilsTest.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) 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); }); From 1d0c57cea8562fd2ac3129eb387831b15f3cd41f Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 16:52:06 +0000 Subject: [PATCH 140/160] fix throttleQueueTest --- test/integration/throttleQueueTest.ts | 3 --- 1 file changed, 3 deletions(-) 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() { From c438eca08c36de1878727e34cb001d9a8b85366e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 16:54:43 +0000 Subject: [PATCH 141/160] Fix commandUtils Alright, they're not using the safe emitter, but neither are the consumers. --- test/integration/commands/commandUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 565df8b3620d65f2f1c39dc28ecb2ac18b31b230 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 16:55:11 +0000 Subject: [PATCH 142/160] Delete protectedRoomsConfigTest.ts We just don't have a way to test this right now without changing a bunch of code. Which we would basically want to do to rewrite the testing harness anyhow, so it's not worth it right now. --- test/integration/protectedRoomsConfigTest.ts | 64 -------------------- 1 file changed, 64 deletions(-) delete mode 100644 test/integration/protectedRoomsConfigTest.ts 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); - }) -}); From 0613447e07877e8b711404873ad084efb7b9ff38 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 16:58:50 +0000 Subject: [PATCH 143/160] Remove banListTest farewell old friend. --- test/integration/banListTest.ts | 639 -------------------------------- 1 file changed, 639 deletions(-) delete mode 100644 test/integration/banListTest.ts diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts deleted file mode 100644 index 2aa65be4..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 { DraupnirTestContext } 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: DraupnirTestContext) { - if (this.draupnir === undefined) { - throw new TypeError("Mjolnir was never created.") - } - const mjolnir: Mjolnir = this.draupnir; - 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); - }) -}) From ae93e9c8d4a56e8c9cf6c4d81ee6e2390225ec6c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 17:00:59 +0000 Subject: [PATCH 144/160] remove protectionSettingsTest they now belong in MPS. --- test/integration/protectionSettingsTest.ts | 163 --------------------- 1 file changed, 163 deletions(-) delete mode 100644 test/integration/protectionSettingsTest.ts 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)" - ) - }); -}); From 704e9b94c3224b8304257d87e0a35a478db8efef Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 17:01:17 +0000 Subject: [PATCH 145/160] remove roomMembersTest this is superseded by `SetMembership` from MPS. we forgot to remove the test. --- test/integration/roomMembersTest.ts | 739 ---------------------------- 1 file changed, 739 deletions(-) delete mode 100644 test/integration/roomMembersTest.ts 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; - } - } - } - } - }); -}); From 02d9712e12d95d46ee6826857d75b346f4c63a1c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 17:01:46 +0000 Subject: [PATCH 146/160] remove standardConsequenceTest superseded with capability providers in MPS. We forgot to remove the test. --- test/integration/standardConsequenceTest.ts | 155 -------------------- 1 file changed, 155 deletions(-) delete mode 100644 test/integration/standardConsequenceTest.ts 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 - }); -}); From 9a47ec3204017262748a3e601b2f1b4cd9734b75 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 17:56:22 +0000 Subject: [PATCH 147/160] fix redactCommandTest. --- .../integration/commands/redactCommandTest.ts | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/test/integration/commands/redactCommandTest.ts b/test/integration/commands/redactCommandTest.ts index e969726c..0961340a 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -1,15 +1,20 @@ 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(); }); - 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 +27,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 +63,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 +80,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 +113,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 +139,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) }); From 0b658fb5d026f2049815c19f64e897f540b05586 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 18:13:54 +0000 Subject: [PATCH 148/160] fix roomsTests.ts --- test/integration/commands/roomsTest.ts | 37 +++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/test/integration/commands/roomsTest.ts b/test/integration/commands/roomsTest.ts index bc8ab5e1..aadf2ae7 100644 --- a/test/integration/commands/roomsTest.ts +++ b/test/integration/commands/roomsTest.ts @@ -1,39 +1,44 @@ 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(); }); - 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) }) From ad01b097739a55693623b6a5cef6394be9668c63 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 19:44:25 +0000 Subject: [PATCH 149/160] Fix appservice provisioning. --- src/appservice/AccessControl.ts | 10 ++++++---- src/appservice/AppService.ts | 19 ++++++++++++++++--- src/appservice/AppServiceDraupnirManager.ts | 9 +++++---- .../bot/AppserviceCommandHandler.ts | 5 +++-- .../StandardDraupnirManager.ts | 9 ++++----- test/appservice/integration/provisionTest.ts | 2 +- test/appservice/integration/webAPITest.ts | 2 +- test/integration/manualLaunchScript.ts | 1 + 8 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/appservice/AccessControl.ts b/src/appservice/AccessControl.ts index 6f407457..c04b7a69 100644 --- a/src/appservice/AccessControl.ts +++ b/src/appservice/AccessControl.ts @@ -25,8 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { Bridge } from "matrix-appservice-bridge"; -import { ActionResult, EntityAccess, MatrixRoomID, Ok, PolicyListRevisionIssuer, PolicyRoomManager, StringUserID, isError, AccessControl as MPSAccess, PolicyRoomEditor, PolicyRuleType, Recommendation } from "matrix-protection-suite"; +import { ActionResult, EntityAccess, MatrixRoomID, Ok, PolicyListRevisionIssuer, PolicyRoomManager, StringUserID, isError, AccessControl as MPSAccess, PolicyRoomEditor, PolicyRuleType, Recommendation, RoomJoiner } from "matrix-protection-suite"; /** * Utility to manage which users have access to the application service, @@ -52,9 +51,12 @@ export class AccessControl { /** The room id for the access control list. */ accessControlRoom: MatrixRoomID, policyRoomManager: PolicyRoomManager, - bridge: Bridge, + bridgeBotJoiner: RoomJoiner, ): Promise> { - await bridge.getBot().getClient().joinRoom(accessControlRoom.toRoomIDOrAlias()); + const joinResult = await bridgeBotJoiner.joinRoom(accessControlRoom.toRoomIDOrAlias()); + if (isError(joinResult)) { + return joinResult; + } const revisionIssuer = await policyRoomManager.getPolicyRoomRevisionIssuer(accessControlRoom); if (isError(revisionIssuer)) { return revisionIssuer; diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 8430f402..74ed1054 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -34,7 +34,7 @@ import { AccessControl } from "./AccessControl"; import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler"; import { SOFTWARE_VERSION } from "../config"; import { Registry } from 'prom-client'; -import { ClientCapabilityFactory, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +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"; @@ -71,6 +71,7 @@ export class MjolnirAppService { this.commands = new AppserviceCommandHandler( botUserID, client, + accessControlRoomID, this.clientCapabilityFactory.makeClientPlatform(botUserID, client), this ); @@ -129,8 +130,16 @@ export class MjolnirAppService { ); 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, bridge); + const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, botRoomJoiner); if (isError(accessControl)) { throw accessControl.error; } @@ -155,6 +164,7 @@ export class MjolnirAppService { accessControl.ok, roomStateManagerFactory, clientCapabilityFactory, + clientProvider, instanceCountGauge ); const appService = new MjolnirAppService( @@ -213,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.draupnirManager.provisionNewDraupnir(mxEvent.sender as StringUserID) + 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. diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index 3870c7ed..a1ea53ef 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -35,7 +35,7 @@ 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, RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { ClientCapabilityFactory, ClientForUserID, RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DraupnirFailType, StandardDraupnirManager, UnstartedDraupnir } from "../draupnirfactory/StandardDraupnirManager"; import { DraupnirFactory } from "../draupnirfactory/DraupnirFactory"; @@ -59,9 +59,9 @@ export class AppServiceDraupnirManager { private readonly accessControl: AccessControl, private readonly roomStateManagerFactory: RoomStateManagerFactory, private readonly clientCapabilityFactory: ClientCapabilityFactory, + clientProvider: ClientForUserID, private readonly instanceCountGauge: Gauge<"status" | "uuid"> ) { - const clientProvider = this.bridge.getIntent.bind(this.bridge); const draupnirFactory = new DraupnirFactory( this.roomStateManagerFactory.clientsInRoomMap, this.clientCapabilityFactory, @@ -69,8 +69,7 @@ export class AppServiceDraupnirManager { this.roomStateManagerFactory ); this.baseManager = new StandardDraupnirManager( - draupnirFactory, - roomStateManagerFactory.clientsInRoomMap + draupnirFactory ); } @@ -92,6 +91,7 @@ export class AppServiceDraupnirManager { accessControl: AccessControl, roomStateManagerFactory: RoomStateManagerFactory, clientCapabilityFactory: ClientCapabilityFactory, + clientProvider: ClientForUserID, instanceCountGauge: Gauge<"status" | "uuid"> ): Promise { const draupnirManager = new AppServiceDraupnirManager( @@ -101,6 +101,7 @@ export class AppServiceDraupnirManager { accessControl, roomStateManagerFactory, clientCapabilityFactory, + clientProvider, instanceCountGauge ); await draupnirManager.startDraupnirs(await dataStore.list()); diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index ea230410..5563a71d 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -10,7 +10,7 @@ import { defineMatrixInterfaceAdaptor, findMatrixInterfaceAdaptor, MatrixContext import { ArgumentStream, RestDescription, findPresentationType, parameters } from '../../commands/interface-manager/ParameterParsing'; import { MjolnirAppService } from '../AppService'; import { renderHelp } from '../../commands/interface-manager/MatrixHelpRenderer'; -import { ActionResult, ClientPlatform, Ok, RoomMessage, StringUserID, Value, isError } from 'matrix-protection-suite'; +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'; @@ -50,6 +50,7 @@ export class AppserviceCommandHandler { constructor( public readonly clientUserID: StringUserID, private readonly client: MatrixSendClient, + private readonly adminRoomID: StringRoomID, private readonly clientPlatform: ClientPlatform, private readonly appservice: MjolnirAppService, ) { @@ -84,7 +85,7 @@ export class AppserviceCommandHandler { } public handleEvent(mxEvent: WeakEvent): void { - if (mxEvent.room_id !== this.appservice.config.adminRoom) { + if (mxEvent.room_id !== this.adminRoomID) { return; } const parsedEventResult = Value.Decode(RoomMessage, mxEvent); diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index 520eda4e..d1418a60 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionError, ActionResult, ClientsInRoomMap, MatrixRoomID, StringUserID, isError } from "matrix-protection-suite"; +import { ActionError, ActionResult, MatrixRoomID, StringUserID, isError } from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; @@ -36,8 +36,7 @@ export class StandardDraupnirManager { private readonly failedDraupnirs = new Map(); public constructor( - protected readonly draupnirFactory: DraupnirFactory, - private readonly clientsInRooms: ClientsInRoomMap + protected readonly draupnirFactory: DraupnirFactory ) { // nothing to do. } @@ -52,9 +51,9 @@ export class StandardDraupnirManager { managementRoom, config ); - if (this.readyDraupnirs.has(clientUserID)) { + if (this.isDraupnirReady(clientUserID)) { return ActionError.Result(`There is a draupnir for ${clientUserID} already waiting to be started`); - } else if (this.clientsInRooms.getClientRooms(clientUserID) !== undefined) { + } else if (this.isDraupnirListening(clientUserID)) { return ActionError.Result(`There is a draupnir for ${clientUserID} already running`); } if (isError(draupnir)) { 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/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 42c22854..7e691dac 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -9,6 +9,7 @@ import { constructWebAPIs } from "../../src/DraupnirBotMode"; (async () => { const config = configRead(); let mjolnir = await makeMjolnir(config); + console.info(`management room ${mjolnir.managementRoom.toPermalink()}`); await mjolnir.start(); const apis = constructWebAPIs(mjolnir); await draupnirClient()?.start(); From 4a8962b371c006510cceb1445ebefe721fb8529d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 19:47:16 +0000 Subject: [PATCH 150/160] Fix draupnirMXID creation from mjolnir record. --- src/appservice/AppServiceDraupnirManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index a1ea53ef..83ac9c20 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -74,7 +74,7 @@ export class AppServiceDraupnirManager { } public draupnirMXID(mjolnirRecord: MjolnirRecord): StringUserID { - return `${mjolnirRecord.local_part}:${this.serverName}` as StringUserID; + return `@${mjolnirRecord.local_part}:${this.serverName}` as StringUserID; } /** From 326d95df67edc28c4167bcd5ca5c9fe7f4f17c29 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 19:47:44 +0000 Subject: [PATCH 151/160] fix listUnstarted appservice integration test. --- .../{listUnstartedMjolnir.ts => listUnstartedDraupnirTest.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/appservice/integration/{listUnstartedMjolnir.ts => listUnstartedDraupnirTest.ts} (94%) diff --git a/test/appservice/integration/listUnstartedMjolnir.ts b/test/appservice/integration/listUnstartedDraupnirTest.ts similarity index 94% rename from test/appservice/integration/listUnstartedMjolnir.ts rename to test/appservice/integration/listUnstartedDraupnirTest.ts index d19d515c..9b2fd153 100644 --- a/test/appservice/integration/listUnstartedMjolnir.ts +++ b/test/appservice/integration/listUnstartedDraupnirTest.ts @@ -20,7 +20,7 @@ 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"); if (isError(result)) { From 6957b3e404d59aff40f84f72616deec48499262c Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 21 Mar 2024 20:13:41 +0000 Subject: [PATCH 152/160] Fix eslint (i don't think this was ever working :/) --- .eslintrc.js | 6 +- package.json | 3 +- src/appservice/AppService.ts | 12 +-- src/capabilities/CommonRenderers.tsx | 10 +- .../DraupnirRendererMessageCollector.tsx | 8 +- .../StandardUserConsequencesRenderer.tsx | 10 +- src/commands/Ban.tsx | 44 ++++----- src/commands/CommandHandler.ts | 10 +- src/commands/KickCommand.tsx | 16 +-- src/commands/RedactCommand.ts | 44 ++++----- src/commands/StatusCommand.tsx | 2 +- src/commands/Unban.ts | 16 +-- .../interface-manager/DeadDocument.ts | 2 +- .../interface-manager/DeadDocumentMarkdown.ts | 4 +- .../interface-manager/DeadDocumentMatrix.ts | 6 +- src/commands/interface-manager/JSXFactory.ts | 1 + .../MatrixInterfaceAdaptor.ts | 6 +- .../MatrixPromptForAccept.tsx | 2 +- src/protections/BanPropagation.tsx | 32 +++--- src/protections/BasicFlooding.ts | 20 ++-- .../DefaultEnabledProtectionsMigration.ts | 88 ++++++++--------- src/protections/MessageIsVoice.ts | 2 +- src/queues/ProtectedRoomActivityTracker.ts | 4 +- test/integration/abuseReportTest.ts | 12 +-- test/integration/clientHelper.ts | 2 +- .../commands/hijackRoomCommandTest.ts | 2 +- .../integration/commands/redactCommandTest.ts | 4 +- test/integration/commands/roomsTest.ts | 4 +- test/scripts/memberQueryTest.ts | 8 +- yarn.lock | 99 ++++++++++++++++++- 30 files changed, 293 insertions(+), 186 deletions(-) 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/package.json b/package.json index 08f64108..b6f8bee3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "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\"", @@ -40,6 +40,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", diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 74ed1054..98cadfa2 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -242,12 +242,12 @@ export class MjolnirAppService { 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; + 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); diff --git a/src/capabilities/CommonRenderers.tsx b/src/capabilities/CommonRenderers.tsx index 53fc31b7..76d6634e 100644 --- a/src/capabilities/CommonRenderers.tsx +++ b/src/capabilities/CommonRenderers.tsx @@ -15,9 +15,9 @@ import { renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer export function renderElaborationTrail(error: ActionError): DocumentNode { return
        Elaboration trail -
          - {error.getElaborations().map((elaboration) =>
        • {elaboration}
        • )} -
        +
          + {error.getElaborations().map((elaboration) =>
        • {elaboration}
        • )} +
        } @@ -82,7 +82,7 @@ export function renderRoomSetResult(roomResults: RoomSetResult, { summary }: { s return
        {summary}
          {[...roomResults.map.entries()].map(([roomID, outcome]) => { - return
        • {renderRoomOutcome(roomID, outcome)}
        • - })}
        + return
      • {renderRoomOutcome(roomID, outcome)}
      • + })}
      } diff --git a/src/capabilities/DraupnirRendererMessageCollector.tsx b/src/capabilities/DraupnirRendererMessageCollector.tsx index 9a47cabe..30c4d8c6 100644 --- a/src/capabilities/DraupnirRendererMessageCollector.tsx +++ b/src/capabilities/DraupnirRendererMessageCollector.tsx @@ -19,10 +19,10 @@ export class DraupnirRendererMessageCollector implements RendererMessageCollecto private sendMessage(document: DocumentNode): void { Task((async () => { await renderMatrixAndSend( - {document}, - this.managementRoomID, - undefined, - this.client, + {document}, + this.managementRoomID, + undefined, + this.client, ) })()); } diff --git a/src/capabilities/StandardUserConsequencesRenderer.tsx b/src/capabilities/StandardUserConsequencesRenderer.tsx index 0b171d2b..c1e62e4e 100644 --- a/src/capabilities/StandardUserConsequencesRenderer.tsx +++ b/src/capabilities/StandardUserConsequencesRenderer.tsx @@ -26,11 +26,11 @@ function renderResultForUserInSetMap(usersInSetMap: ResultForUserInSetMap, { 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. }) - })} -
      + {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. }) + })} + } diff --git a/src/commands/Ban.tsx b/src/commands/Ban.tsx index efa70a8b..673206b2 100644 --- a/src/commands/Ban.tsx +++ b/src/commands/Ban.tsx @@ -51,31 +51,31 @@ async function ban( entity: UserID|MatrixRoomReference|string, policyRoomReference: MatrixRoomReference, ...reasonParts: string[] - ): Promise> { - const policyListEditorResult = await findPolicyRoomEditorFromRoomReference( - this.draupnir, - policyRoomReference +): 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(policyListEditorResult)) { - return policyListEditorResult; - } - const policyListEditor = policyListEditorResult.ok; - const reason = reasonParts.join(' '); - if (entity instanceof UserID) { - return await policyListEditor.banEntity(PolicyRuleType.User, entity.toString(), reason); - } else if (typeof entity === 'string') { - return await policyListEditor.banEntity(PolicyRuleType.Server,entity, reason); - } else { - const resolvedRoomReference = await resolveRoomReferenceSafe( - this.draupnir.client, - entity - ); - if (isError(resolvedRoomReference)) { - return resolvedRoomReference; - } - return await policyListEditor.banEntity(PolicyRuleType.Server, resolvedRoomReference.ok.toRoomIDOrAlias(), reason); + if (isError(resolvedRoomReference)) { + return resolvedRoomReference; } + return await policyListEditor.banEntity(PolicyRuleType.Server, resolvedRoomReference.ok.toRoomIDOrAlias(), reason); } +} defineInterfaceCommand({ designator: ["ban"], diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 4dbe2d45..34c1c815 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -103,17 +103,17 @@ export async function handleCommand( export function extractCommandFromMessageBody( body: string, { prefix, - localpart, - userId, - additionalPrefixes, - allowNoPrefix + localpart, + userId, + additionalPrefixes, + allowNoPrefix }: { prefix: string, localpart: string, userId: string, additionalPrefixes: string[], allowNoPrefix: boolean -}): string | undefined { + }): string | undefined { const plainPrefixes = [prefix, localpart, userId, ...additionalPrefixes]; const allPossiblePrefixes = [ ...plainPrefixes.map(p => `!${p}`), diff --git a/src/commands/KickCommand.tsx b/src/commands/KickCommand.tsx index 4572a95b..d23eeef9 100644 --- a/src/commands/KickCommand.tsx +++ b/src/commands/KickCommand.tsx @@ -51,14 +51,14 @@ function renderUsersToKick(usersToKick: UsersToKick): DocumentNode { Kicking {usersToKick.size} unique users from protected rooms. - {[...usersToKick.entries()].map(([userID, rooms]) => { -
      - Kicking {userID} from {rooms.length} rooms. -
        - {rooms.map(room =>
      • {room}
      • )} -
      -
      - })} + {[...usersToKick.entries()].map(([userID, rooms]) => { +
      + Kicking {userID} from {rooms.length} rooms. +
        + {rooms.map(room =>
      • {room}
      • )} +
      +
      + })}
      } diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index 31c286c7..63670c29 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -88,29 +88,29 @@ defineInterfaceCommand({ findPresentationType("MatrixEventReference") ), }], - new RestDescription( - "reason", - findPresentationType("string"), - async function(_parameter) { - return { - suggestions: this.draupnir.config.commands.ban.defaultReasons - } + 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.' - } - }), + } + ), + 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." diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx index b27935bd..e3d8d398 100644 --- a/src/commands/StatusCommand.tsx +++ b/src/commands/StatusCommand.tsx @@ -41,7 +41,7 @@ defineInterfaceCommand({ table: "mjolnir", parameters: parameters([]), command: async function (this: DraupnirContext): Promise> { - return Ok(await draupnirStatusInfo(this.draupnir)) + return Ok(await draupnirStatusInfo(this.draupnir)) }, summary: "Show the status of the bot." }) diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts index 2b932104..13f7b187 100644 --- a/src/commands/Unban.ts +++ b/src/commands/Unban.ts @@ -73,14 +73,14 @@ async function unban( 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()); - })(); + ? 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; } diff --git a/src/commands/interface-manager/DeadDocument.ts b/src/commands/interface-manager/DeadDocument.ts index adb5e1f4..15cfb134 100644 --- a/src/commands/interface-manager/DeadDocument.ts +++ b/src/commands/interface-manager/DeadDocument.ts @@ -327,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/DeadDocumentMarkdown.ts b/src/commands/interface-manager/DeadDocumentMarkdown.ts index 5c84d88c..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/JSXFactory.ts b/src/commands/interface-manager/JSXFactory.ts index 63553ac5..40f2ce04 100644 --- a/src/commands/interface-manager/JSXFactory.ts +++ b/src/commands/interface-manager/JSXFactory.ts @@ -45,6 +45,7 @@ export function JSXFactory(tag: NodeTag, properties: unknown, ...rawChildren: (D } +// eslint-disable-next-line no-redeclare namespace JSXFactory { export interface IntrinsicElements { [elemName: string]: any; diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 8cb786fd..0e6debfe 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -148,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/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index 87abdc33..e3f2785d 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -30,7 +30,7 @@ type DefaultPromptContext = StaticDecode; const DefaultPromptContext = Type.Composite([ PromptContext, Type.Object({ - default: Type.String(), + default: Type.String(), }) ]); diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 675b9ee4..598012e5 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -78,9 +78,9 @@ async function promptBanPropagation( 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? -
        - {editablePolicyRoomIDs.map((room) =>
      1. {room.toRoomIDOrAlias()}
      2. )} -
      +
        + {editablePolicyRoomIDs.map((room) =>
      1. {room.toRoomIDOrAlias()}
      2. )} +
      , draupnir.managementRoomID, undefined, @@ -111,9 +111,9 @@ async function promptUnbanPropagation( from the room {renderRoomPill(MatrixRoomReference.fromRoomID(roomID))} by {membershipChange.sender} for {membershipChange.content.reason ?? ''}.
      However there are rules in Draupnir's watched lists matching this user:
        - { - rulesMatchingUser.map(match =>
      • {renderListRules(match)}
      • ) - } + { + rulesMatchingUser.map(match =>
      • {renderListRules(match)}
      • ) + }
      Would you like to remove these rules and unban the user from all protected rooms? , @@ -152,7 +152,7 @@ export class BanPropagationProtection 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 @@ -301,12 +301,12 @@ describeProtection({ userConsequences: 'StandardUserConsequences', }, factory: (decription, protectedRoomsSet, draupnir, capabilities, _settings) => - Ok( - new BanPropagationProtection( - decription, - capabilities, - protectedRoomsSet, - draupnir - ) - ), - }); + Ok( + new BanPropagationProtection( + decription, + capabilities, + protectedRoomsSet, + draupnir + ) + ), +}); diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index fd4c58f6..686e71e3 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -68,22 +68,22 @@ describeProtection({ maxPerMinute: new SafeIntegerProtectionSetting( 'maxPerMinute' )}, - { - maxPerMinute: DEFAULT_MAX_PER_MINUTE - }) - }); + { + maxPerMinute: DEFAULT_MAX_PER_MINUTE + }) +}); export class BasicFloodingProtection extends AbstractProtection implements DraupnirProtection { diff --git a/src/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts index c8a9ee09..d3ea6114 100644 --- a/src/protections/DefaultEnabledProtectionsMigration.ts +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -6,50 +6,50 @@ import { ActionError,ActionException,ActionExceptionKind,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value, findProtection } from "matrix-protection-suite"; 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 + 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, }); } - for (const protection of [memberBanSynchronisationProtection, serverBanSynchronisationProtection]) { - enabledProtections.add(protection.name); - } - return Ok({ - enabled: [...enabledProtections], - [DRAUPNIR_SCHEMA_VERSION_KEY]: 2, - }); - } ]); diff --git a/src/protections/MessageIsVoice.ts b/src/protections/MessageIsVoice.ts index 5dc2293d..f5560042 100644 --- a/src/protections/MessageIsVoice.ts +++ b/src/protections/MessageIsVoice.ts @@ -46,7 +46,7 @@ describeProtection({ }, factory: function(description, protectedRoomsSet, draupnir, capabilities, _settings) { return Ok( - new MessageIsVoiceProtection( + new MessageIsVoiceProtection( description, capabilities, protectedRoomsSet, 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/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 9d2d8b57..3121af21 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -151,7 +151,7 @@ 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; } @@ -285,7 +285,7 @@ describe("Test: Reporting abuse", async () => { 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; } @@ -298,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); @@ -335,7 +335,7 @@ 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; } 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/hijackRoomCommandTest.ts b/test/integration/commands/hijackRoomCommandTest.ts index 2fe39372..87f5eda0 100644 --- a/test/integration/commands/hijackRoomCommandTest.ts +++ b/test/integration/commands/hijackRoomCommandTest.ts @@ -1,4 +1,4 @@ -import { strict as assert } from "assert"; +import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; import { getFirstReaction } from "./commandUtils"; import { DraupnirTestContext, draupnirClient } from "../mjolnirSetupUtils"; diff --git a/test/integration/commands/redactCommandTest.ts b/test/integration/commands/redactCommandTest.ts index 0961340a..cb151913 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -12,7 +12,9 @@ interface RedactionTestContext extends DraupnirTestContext { 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(this: RedactionTestContext) { this.timeout(60000); diff --git a/test/integration/commands/roomsTest.ts b/test/integration/commands/roomsTest.ts index aadf2ae7..a1cbf553 100644 --- a/test/integration/commands/roomsTest.ts +++ b/test/integration/commands/roomsTest.ts @@ -10,7 +10,9 @@ interface RoomsTestContext extends DraupnirTestContext { 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(this: RoomsTestContext) { // Create a few users and a room. 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/yarn.lock b/yarn.lock index 4da22270..d52faa31 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" @@ -918,6 +928,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" @@ -1164,6 +1179,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" @@ -1219,6 +1244,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" @@ -1232,6 +1266,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" @@ -2151,6 +2229,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" @@ -2392,6 +2475,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" @@ -3109,6 +3199,13 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== +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" From 45a2429eac27c0b416c8d93a4f3623e5f7eaaaa7 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 22 Mar 2024 17:59:56 +0000 Subject: [PATCH 153/160] Update MPS dependencies to fix banpropagation bugs. --- package.json | 4 ++-- .../DraupnirProtectedRoomsSet.ts | 6 ++++-- yarn.lock | 18 +++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b6f8bee3..8d045110 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.10.4", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.10.1", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.10.6", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.10.2", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index 270c263f..c8ab55ed 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -49,13 +49,15 @@ async function makePolicyListConfig( async function makeProtectedRoomsConfig( client: MatrixSendClient, + roomJoiner: RoomJoiner, ): Promise> { return await MjolnirProtectedRoomsConfig.createFromStore( new BotSDKMatrixAccountData( MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MjolnirProtectedRoomsEvent, client - ) + ), + roomJoiner ); } @@ -115,7 +117,7 @@ export async function makeProtectedRoomsSet( clientPlatform: ClientPlatform, userID: StringUserID ): Promise> { - const protectedRoomsConfig = await makeProtectedRoomsConfig(client) + const protectedRoomsConfig = await makeProtectedRoomsConfig(client, clientPlatform.toRoomJoiner()) if (isError(protectedRoomsConfig)) { return protectedRoomsConfig; } diff --git a/yarn.lock b/yarn.lock index d52faa31..d53087ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2402,15 +2402,15 @@ 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.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.10.1.tgz#aff679c9ae15ed6a97cdb35213f9425f0fd7a461" - integrity sha512-mgNLWTxt8lbN39/SNczOOQEX7Ox/GEg6SBLyGow+EA7of/qwAPtrBpXTmkI6x//PPAZ4XMX29TO9+Jis29Dmng== - -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.10.4": - version "0.10.4" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.10.4.tgz#bf6068801883318eddfc9bd3835057c39cb0376d" - integrity sha512-WpxgGUYAAgTG/PHvWwkqJGyAqyGyouhKDExBqPJA/orrlOQ82/xUpuZ58ZARZ4rpzmcwLv5Fes8JEQzerZuVbQ== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.10.2": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.10.2.tgz#8e2f9f8584fc405eb6233633df8474c4fdee8c0d" + integrity sha512-b1cixjySgvirGEMnTEODsJRZQKNeTavol3+vZE2BfUxFywXXhduKSOcokhAqIhHo1ZmT9PdNr+Nu1IQeaLDl4A== + +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.10.6": + version "0.10.6" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.10.6.tgz#9b65f212419d4cd0abe470dad9c63c2df17adeed" + integrity sha512-yyzHzl0Xu1M+B58jo5jYLTvWsSjh3ePRmbVNhZnfjXZuOQf7wh6bGKYZSmGJOUxAfX9gcriLRfCY2QN1HYoYVQ== dependencies: await-lock "^2.2.2" crypto-js "^4.1.1" From 2392791444657bc360c2bfc00b949a1b3d7aa5ad Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sun, 24 Mar 2024 17:52:30 +0000 Subject: [PATCH 154/160] Add RedactionSynchronisation protection. --- .../DefaultEnabledProtectionsMigration.ts | 22 ++++ src/protections/DraupnirProtectionsIndex.ts | 1 + src/protections/RedactionSynchronisation.ts | 100 ++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/protections/RedactionSynchronisation.ts diff --git a/src/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts index d3ea6114..e82be589 100644 --- a/src/protections/DefaultEnabledProtectionsMigration.ts +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -4,6 +4,7 @@ */ 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) { @@ -51,5 +52,26 @@ export const DefaultEnabledProtectionsMigration = new SchemedDataManager +// +// 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 relevantChanges = changes.filter((change) => + change.membershipChangeType === MembershipChangeType.Joined + || change.membershipChangeType === MembershipChangeType.Rejoined + && ( + (policy) => + policy !== undefined + && this.automaticRedactionReasons.some(reason => reason.test(policy.reason)) + )(this.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision.findRuleMatchingEntity(change.userID, PolicyRuleType.User, Recommendation.Ban)) + ); + 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 + )) + } +}); From c23b284821670cbd2c349c0b7f9746c715b69a83 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 1 Apr 2024 19:20:28 +0100 Subject: [PATCH 155/160] Update for MPS 0.12.0 --- package.json | 4 ++-- yarn.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 8d045110..31ebda93 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.10.6", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.10.2", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.12.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.12.0", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/yarn.lock b/yarn.lock index d53087ec..5f7ff745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2402,15 +2402,15 @@ 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.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.10.2.tgz#8e2f9f8584fc405eb6233633df8474c4fdee8c0d" - integrity sha512-b1cixjySgvirGEMnTEODsJRZQKNeTavol3+vZE2BfUxFywXXhduKSOcokhAqIhHo1ZmT9PdNr+Nu1IQeaLDl4A== - -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.10.6": - version "0.10.6" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.10.6.tgz#9b65f212419d4cd0abe470dad9c63c2df17adeed" - integrity sha512-yyzHzl0Xu1M+B58jo5jYLTvWsSjh3ePRmbVNhZnfjXZuOQf7wh6bGKYZSmGJOUxAfX9gcriLRfCY2QN1HYoYVQ== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.12.0.tgz#a703a34a241e48ee3a4b407008461e7f5de58d30" + integrity sha512-/dkJLkLk+qANkK+eq0cx6vvF0t1c5SrZ1hlMOAfgRHc0ZvsbXHsTrmZxjzu8EKIznt5cEFcaSTJ2hUTXFV7vIA== + +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.12.0.tgz#07751b1e381c0e365689ad3394259d7834ebd88b" + integrity sha512-JCBgkvR0IVzQUUn2DTJuuj4zbz4P2AL0S7r+RoZOAsqEIVOVdFLNJUZPjLa4KRq5QYsEbUmpY2CA/D7K2+eUdw== dependencies: await-lock "^2.2.2" crypto-js "^4.1.1" From 4d50b1be15a0abe3849fb4b077a435930bd8897a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 25 Mar 2024 18:59:33 +0000 Subject: [PATCH 156/160] Add better-sqlite3 so we can use the room state backing store. --- package.json | 2 + yarn.lock | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 220 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 31ebda93..94f3d0ac 100644 --- a/package.json +++ b/package.json @@ -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", @@ -52,6 +53,7 @@ "@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", diff --git a/yarn.lock b/yarn.lock index 5f7ff745..9bd0e692 100644 --- a/yarn.lock +++ b/yarn.lock @@ -226,6 +226,13 @@ 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" @@ -690,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" @@ -704,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" @@ -716,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" @@ -799,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" @@ -859,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" @@ -1047,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" @@ -1072,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" @@ -1209,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" @@ -1397,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" @@ -1517,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" @@ -1615,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" @@ -1665,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" @@ -1914,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" @@ -1950,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" @@ -2463,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" @@ -2501,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" @@ -2593,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" @@ -2624,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" @@ -2680,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== @@ -2919,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" @@ -2963,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" @@ -3040,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" @@ -3199,7 +3374,7 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^7.5.3: +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== @@ -3285,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" @@ -3402,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" @@ -3428,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" From 80ccb6433035e39da66f0a12f587c9277d1ed008 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 1 Apr 2024 18:50:37 +0100 Subject: [PATCH 157/160] Implement RoomStateBackingStore with BetterSqlite. --- config/default.yaml | 8 + src/DraupnirBotMode.ts | 9 +- .../better-sqlite3/BetterSqliteStore.ts | 166 ++++++++++++++++++ .../SqliteRoomStateBackingStore.ts | 144 +++++++++++++++ src/config.ts | 10 +- src/index.ts | 6 +- src/utils.ts | 2 +- test/integration/manualLaunchScript.ts | 5 +- test/integration/mjolnirSetupUtils.ts | 6 +- 9 files changed, 345 insertions(+), 11 deletions(-) create mode 100644 src/backingstore/better-sqlite3/BetterSqliteStore.ts create mode 100644 src/backingstore/better-sqlite3/SqliteRoomStateBackingStore.ts 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/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 622f4b33..2fde654b 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -34,7 +34,8 @@ import { isStringRoomID, StandardClientsInRoomMap, DefaultEventDecoder, - setGlobalLoggerProvider + setGlobalLoggerProvider, + RoomStateBackingStore } from "matrix-protection-suite"; import { BotSDKLogServiceLogger, @@ -66,7 +67,8 @@ export function constructWebAPIs(draupnir: Draupnir): WebAPIs { export async function makeDraupnirBotModeFromConfig( client: MatrixSendClient, matrixEmitter: SafeMatrixEmitter, - config: IConfig + config: IConfig, + backingStore?: RoomStateBackingStore ): Promise { const clientUserId = await client.getUserId(); if (!isStringUserID(clientUserId)) { @@ -91,7 +93,8 @@ export async function makeDraupnirBotModeFromConfig( const roomStateManagerFactory = new RoomStateManagerFactory( clientsInRoomMap, clientProvider, - DefaultEventDecoder + DefaultEventDecoder, + backingStore ); const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); const draupnirFactory = new DraupnirFactory( 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/config.ts b/src/config.ts index 8bf80f58..272557a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,7 +125,12 @@ export interface IConfig { abuseReporting: { enabled: boolean; } - } + }; + // Store room state using sqlite to improve startup time when Synapse responds + // slowly to requests for `/state`. + roomStateBackingStore: { + enabled?: boolean; + }; // Experimental usage of the matrix-bot-sdk rust crypto. // This can not be used with Pantalaimon. experimentalRustCrypto: boolean; @@ -207,6 +212,9 @@ const defaultConfig: IConfig = { enabled: false, }, }, + roomStateBackingStore: { + enabled: false, + }, experimentalRustCrypto: false, // Needed to make the interface happy. diff --git a/src/index.ts b/src/index.ts index 3c094e6c..e769f755 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ 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 () { @@ -90,8 +91,9 @@ import { WebAPIs } from "./webapis/WebAPIs"; } patchMatrixClient(); config.RUNTIME.client = client; - - bot = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), 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}`); diff --git a/src/utils.ts b/src/utils.ts index 3f83a884..34aa7c1c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index 7e691dac..f8843440 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -5,10 +5,13 @@ 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); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 1500b244..527e0e61 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -27,7 +27,7 @@ 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 } from "matrix-protection-suite"; +import { DefaultEventDecoder, RoomStateBackingStore } from "matrix-protection-suite"; import { WebAPIs } from "../../src/webapis/WebAPIs"; patchMatrixClient(); @@ -90,7 +90,7 @@ 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)); @@ -99,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 makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config); + let mj = await makeDraupnirBotModeFromConfig(client, new SafeMatrixEmitterWrapper(client, DefaultEventDecoder), config, backingStore); globalClient = client; globalMjolnir = mj; return mj; From 21e640e9bf17319743e55ba364ae953e736a6987 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 1 Apr 2024 23:50:42 +0100 Subject: [PATCH 158/160] Improve readability of redaction synchronisation protection. --- src/protections/RedactionSynchronisation.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/protections/RedactionSynchronisation.ts b/src/protections/RedactionSynchronisation.ts index 8984f112..21511cfd 100644 --- a/src/protections/RedactionSynchronisation.ts +++ b/src/protections/RedactionSynchronisation.ts @@ -68,15 +68,18 @@ export class RedactionSynchronisationProtection extends AbstractProtection> { - const relevantChanges = changes.filter((change) => - change.membershipChangeType === MembershipChangeType.Joined - || change.membershipChangeType === MembershipChangeType.Rejoined - && ( - (policy) => - policy !== undefined - && this.automaticRedactionReasons.some(reason => reason.test(policy.reason)) - )(this.protectedRoomsSet.issuerManager.policyListRevisionIssuer.currentRevision.findRuleMatchingEntity(change.userID, PolicyRuleType.User, Recommendation.Ban)) - ); + 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()])); } From 1ff59481bed8478db7c6c75c8ad3a0261c862bc0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 6 Apr 2024 17:19:20 +0100 Subject: [PATCH 159/160] Catch all bans in BanPropagationProtection so we can detect edits. --- src/protections/BanPropagation.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 598012e5..dadf398d 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -33,7 +33,7 @@ import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentM 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 } from "matrix-protection-suite"; +import { AbstractProtection, ActionResult, Logger, MatrixRoomID, MatrixRoomReference, MembershipChange, MembershipChangeType, Ok, PermissionError, PolicyRule, PolicyRuleType, ProtectedRoomsSet, ProtectionDescription, Recommendation, RoomActionError, RoomMembershipRevision, RoomUpdateError, StringRoomID, StringUserID, Task, describeProtection, isError, serverName, 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"; @@ -162,7 +162,8 @@ export class BanPropagationProtection } public async handleMembershipChange(revision: RoomMembershipRevision, changes: MembershipChange[]): Promise> { - const bans = changes.filter(change => change.membershipChangeType === MembershipChangeType.Banned && change.sender !== this.protectedRoomsSet.userID); + // 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); From 971b4edf38c1efd16067068b64874bde964bae21 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Sat, 6 Apr 2024 17:29:27 +0100 Subject: [PATCH 160/160] Update for MPS v0.12.1 This fixes a race condition causing a nasty bug in the room state revision issuer that was also causing an intermitent test failure. --- package.json | 4 ++-- yarn.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 94f3d0ac..096f848f 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "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.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.12.0", + "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", diff --git a/yarn.lock b/yarn.lock index 9bd0e692..b5ac2dde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2505,15 +2505,15 @@ 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.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.12.0.tgz#a703a34a241e48ee3a4b407008461e7f5de58d30" - integrity sha512-/dkJLkLk+qANkK+eq0cx6vvF0t1c5SrZ1hlMOAfgRHc0ZvsbXHsTrmZxjzu8EKIznt5cEFcaSTJ2hUTXFV7vIA== - -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.12.0.tgz#07751b1e381c0e365689ad3394259d7834ebd88b" - integrity sha512-JCBgkvR0IVzQUUn2DTJuuj4zbz4P2AL0S7r+RoZOAsqEIVOVdFLNJUZPjLa4KRq5QYsEbUmpY2CA/D7K2+eUdw== +"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"