From 32b0dd9c3d7395f7e6c46bc82e4f027468e14601 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 7 Oct 2024 14:19:19 -0700 Subject: [PATCH 1/4] add prettier --- .prettierignore | 21 +++++++++++++++++++++ .prettierrc.js | 6 ++++++ yarn.lock | 5 +++++ 3 files changed, 32 insertions(+) create mode 100644 .prettierignore create mode 100644 .prettierrc.js diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..76f764c3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +/lib + +/.idea +.vscode +.vscode/ + +/.github +/.config +/.docs +/.src/appservice/config + +/*.log +package-lock.json +yarn.lock +CONTRIBUTING.md +README.md +mx-tester.yml +package.json +releasing.md +tsconfig.json +tslint.json diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..f78bdab5 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + printWidth: 120, + tabWidth: 4, + quoteProps: "consistent", + trailingComma: "all", +}; diff --git a/yarn.lock b/yarn.lock index 29113bca..4fef91cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3218,6 +3218,11 @@ prelude-ls@~1.1.2: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== +prettier@3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + pretty-format@^27.2.4: version "27.2.4" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-27.2.4.tgz" From e59b353d1b2ba52c46a94eb90c30a239eee2a34d Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 7 Oct 2024 14:20:20 -0700 Subject: [PATCH 2/4] run prettier --- src/ErrorCache.ts | 7 +- src/ManagementRoomOutput.ts | 48 +- src/MatrixEmitter.ts | 24 +- src/Mjolnir.ts | 179 +++-- src/ProtectedRoomsConfig.ts | 39 +- src/ProtectedRoomsSet.ts | 175 +++-- src/RoomMembers.ts | 24 +- src/appservice/AccessControl.ts | 8 +- src/appservice/Api.ts | 52 +- src/appservice/AppService.ts | 80 ++- src/appservice/MjolnirManager.ts | 93 +-- src/appservice/cli.ts | 6 +- src/appservice/datastore.ts | 21 +- .../AddRemoveProtectedRoomsCommand.ts | 11 +- .../AddRemoveRoomFromDirectoryCommand.ts | 19 +- src/commands/AliasCommands.ts | 12 +- src/commands/CommandHandler.ts | 114 ++-- src/commands/CreateBanListCommand.ts | 9 +- src/commands/DeactivateCommand.ts | 4 +- src/commands/DumpRulesCommand.ts | 14 +- src/commands/ImportCommand.ts | 25 +- src/commands/KickCommand.ts | 26 +- src/commands/MakeRoomAdminCommand.ts | 10 +- src/commands/ProtectionsCommands.ts | 15 +- src/commands/RedactCommand.ts | 18 +- src/commands/SetDefaultBanListCommand.ts | 4 +- src/commands/SetPowerLevelCommand.ts | 15 +- .../SetupDecentralizedReportingCommand.ts | 27 +- src/commands/ShutdownRoomCommand.ts | 4 +- src/commands/SinceCommand.ts | 107 ++- src/commands/StatusCommand.ts | 33 +- src/commands/SuspendCommand.ts | 16 +- src/commands/UnbanBanCommand.ts | 71 +- src/commands/UnsuspendCommand.ts | 16 +- src/commands/WatchUnwatchCommand.ts | 4 +- src/config.ts | 45 +- src/health/healthz.ts | 7 +- src/index.ts | 32 +- src/models/AccessControlUnit.ts | 44 +- src/models/ListRule.ts | 39 +- src/models/PolicyList.ts | 284 +++++--- src/models/RuleServer.ts | 160 +++-- src/models/ServerAcl.ts | 12 +- src/protections/BasicFlooding.ts | 69 +- src/protections/DetectFederationLag.ts | 171 +++-- src/protections/FirstMessageIsImage.ts | 62 +- src/protections/IProtection.ts | 12 +- src/protections/JoinWaveShortCircuit.ts | 62 +- src/protections/LocalAbuseReports.ts | 34 +- src/protections/MentionSpam.ts | 54 +- src/protections/MessageIsMedia.ts | 34 +- src/protections/MessageIsVoice.ts | 24 +- src/protections/NsfwProtection.ts | 50 +- src/protections/ProtectionManager.ts | 120 ++-- src/protections/ProtectionSettings.ts | 37 +- src/protections/TrustedReporters.ts | 18 +- src/protections/WordList.ts | 54 +- src/protections/consequence.ts | 5 +- src/queues/EventRedactionQueue.ts | 24 +- src/queues/ProtectedRoomActivityTracker.ts | 9 +- src/queues/ThrottlingQueue.ts | 11 +- src/queues/UnlistedUserRedactionQueue.ts | 26 +- src/report/ReportManager.ts | 444 ++++++++---- src/report/ReportPoller.ts | 68 +- src/utils.ts | 324 +++++---- src/webapis/OpenMetrics.ts | 9 +- src/webapis/WebAPIs.ts | 41 +- test/appservice/integration/provisionTest.ts | 30 +- test/appservice/integration/webAPITest.ts | 31 +- test/appservice/utils/harness.ts | 3 +- test/appservice/utils/webAPIClient.ts | 44 +- test/commands/UnbanBanCommandTest.ts | 120 ++-- test/integration/abuseReportTest.ts | 170 +++-- .../integration/acceptInvitesFromSpaceTest.ts | 27 +- test/integration/banListTest.ts | 644 ++++++++++++------ test/integration/clientHelper.ts | 114 ++-- test/integration/commands/commandUtils.ts | 84 ++- .../commands/makedminCommandTest.ts | 45 +- .../integration/commands/redactCommandTest.ts | 136 ++-- .../commands/shutdownCommandTest.ts | 44 +- .../commands/suspendCommandTest.ts | 74 +- test/integration/detectFederationLagTest.ts | 105 +-- test/integration/fixtures.ts | 28 +- test/integration/helloTest.ts | 34 +- test/integration/manualLaunchScript.ts | 2 +- test/integration/mentionSpamProtectionTest.ts | 119 ++-- test/integration/mjolnirSetupUtils.ts | 28 +- test/integration/moderationRequestTest.ts | 216 ++++-- test/integration/nsfwProtectionTest.ts | 79 ++- test/integration/openMetricsTest.ts | 37 +- test/integration/policyConsumptionTest.ts | 198 ++++-- test/integration/protectedRoomsConfigTest.ts | 26 +- test/integration/protectionSettingsTest.ts | 274 +++++--- test/integration/reportPollingTest.ts | 27 +- test/integration/roomMembersTest.ts | 346 +++++++--- test/integration/standardConsequenceTest.ts | 153 +++-- test/integration/throttleQueueTest.ts | 44 +- test/integration/throttleTest.ts | 27 +- test/integration/timelinePaginationTest.ts | 88 ++- test/integration/utilsTest.ts | 29 +- 100 files changed, 4454 insertions(+), 2687 deletions(-) diff --git a/src/ErrorCache.ts b/src/ErrorCache.ts index 73951201..a5317557 100644 --- a/src/ErrorCache.ts +++ b/src/ErrorCache.ts @@ -29,10 +29,9 @@ const TRIGGER_INTERVALS: { [key: string]: number } = { * The ErrorCache is an attempt to make sure the error is reported only once. */ export default class ErrorCache { - private roomsToErrors: Map> = new Map(); + private roomsToErrors: Map> = new Map(); - constructor() { - } + constructor() {} /** * Reset the error cache for a room/kind in the situation where circumstances have changed e.g. if Mjolnir has been informed via sync of a `m.room.power_levels` event in the room, we would want to clear `ERROR_KIND_PERMISSION` @@ -68,7 +67,7 @@ export default class ErrorCache { const now = new Date().getTime(); const interval = TRIGGER_INTERVALS[kind]; - if ((now - lastTriggerTime) >= interval) { + if (now - lastTriggerTime >= interval) { triggers.set(kind, now); return true; } else { diff --git a/src/ManagementRoomOutput.ts b/src/ManagementRoomOutput.ts index fe43e6f9..edb25029 100644 --- a/src/ManagementRoomOutput.ts +++ b/src/ManagementRoomOutput.ts @@ -15,7 +15,15 @@ limitations under the License. */ import * as Sentry from "@sentry/node"; -import { extractRequestError, LogLevel, LogService, MessageType, Permalinks, TextualMessageEventContent, UserID } from "@vector-im/matrix-bot-sdk"; +import { + extractRequestError, + LogLevel, + LogService, + MessageType, + Permalinks, + TextualMessageEventContent, + UserID, +} from "@vector-im/matrix-bot-sdk"; import { IConfig } from "./config"; import { MatrixSendClient } from "./MatrixEmitter"; import { htmlEscape } from "./utils"; @@ -31,14 +39,11 @@ const levelToFn = { * Allows the different componenets of mjolnir to send messages back to the management room without introducing a dependency on the entirity of a `Mjolnir` instance. */ export default class ManagementRoomOutput { - constructor( private readonly managementRoomId: string, private readonly client: MatrixSendClient, private readonly config: IConfig, - ) { - - } + ) {} /** * Take an arbitrary string and a set of room IDs, and return a @@ -52,7 +57,11 @@ export default class ManagementRoomOutput { * @param msgtype The desired message type of the returned TextualMessageEventContent * @returns A TextualMessageEventContent with replaced room IDs */ - private async replaceRoomIdsWithPills(text: string, roomIds: Set, msgtype: MessageType = "m.text"): Promise { + private async replaceRoomIdsWithPills( + text: string, + roomIds: Set, + msgtype: MessageType = "m.text", + ): Promise { const content: TextualMessageEventContent = { body: text, formatted_body: htmlEscape(text), @@ -62,24 +71,33 @@ export default class ManagementRoomOutput { // Though spec doesn't say so, room ids that have slashes in them are accepted by Synapse and Dendrite unfortunately for us. const escapeRegex = (v: string): string => { - return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); }; - const viaServers = [(new UserID(await this.client.getUserId())).domain]; + const viaServers = [new UserID(await this.client.getUserId()).domain]; for (const roomId of roomIds) { let alias = roomId; try { alias = (await this.client.getPublishedAlias(roomId)) || roomId; } catch (e) { // This is a recursive call, so tell the function not to try and call us - await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true); + await this.logMessage( + LogLevel.WARN, + "utils", + `Failed to resolve room alias for ${roomId} - see console for details`, + null, + true, + ); LogService.warn("utils", extractRequestError(e)); } 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); - content.formatted_body = content.formatted_body.replace(regexRoomId, `${htmlEscape(alias)}`); + content.formatted_body = content.formatted_body.replace( + regexRoomId, + `${htmlEscape(alias)}`, + ); } } @@ -95,9 +113,15 @@ export default class ManagementRoomOutput { * @param additionalRoomIds The roomIds in the message that we want to be replaced by room pills. * @param isRecursive Whether logMessage is being called from logMessage. */ - public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise { + public async logMessage( + level: LogLevel, + module: string, + message: string | any, + additionalRoomIds: string[] | string | null = null, + isRecursive = false, + ): Promise { if (level === LogLevel.ERROR) { - Sentry.captureMessage(`${module}: ${message}`, 'error'); + Sentry.captureMessage(`${module}: ${message}`, "error"); } if (!additionalRoomIds) additionalRoomIds = []; if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds]; diff --git a/src/MatrixEmitter.ts b/src/MatrixEmitter.ts index d810101e..26b278ef 100644 --- a/src/MatrixEmitter.ts +++ b/src/MatrixEmitter.ts @@ -25,23 +25,23 @@ import { MatrixClient } from "@vector-im/matrix-bot-sdk"; * 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.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.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.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.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.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 + on(event: "room.archived", listener: (roomId: string, mxEvent: any) => void): this; + emit(event: "room.archived", roomId: string, mxEvent: any): boolean; start(): Promise; stop(): void; diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 9fcf885d..0fd89bc4 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -14,12 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - extractRequestError, - LogLevel, - LogService, - MembershipEvent, -} from "@vector-im/matrix-bot-sdk"; +import { extractRequestError, LogLevel, LogService, MembershipEvent } from "@vector-im/matrix-bot-sdk"; import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule"; import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; @@ -95,7 +90,11 @@ export class Mjolnir { * @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 }) { + 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); @@ -104,12 +103,14 @@ export class Mjolnir { 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.`, + 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.`, + 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.`, }); }; @@ -118,16 +119,19 @@ export class Mjolnir { if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite } else if (options.acceptInvitesFromSpace) { 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); - } - }); + 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); @@ -139,9 +143,18 @@ export class Mjolnir { * @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 but you have not set `acceptInvitesFromSpace`. Please make it empty to accept invites from everywhere or give it a namespace alias or room id."); + static async setupMjolnirFromConfig( + client: MatrixSendClient, + matrixEmitter: MatrixEmitter, + config: IConfig, + ): Promise { + if ( + !config.autojoinOnlyIfManager && + config.acceptInvitesFromSpace === getDefaultConfig().acceptInvitesFromSpace + ) { + throw new TypeError( + "`autojoinOnlyIfManager` has been disabled but you have not set `acceptInvitesFromSpace`. Please make it empty to accept invites from everywhere or give it a namespace alias or room id.", + ); } const joinedRooms = await client.getJoinedRooms(); @@ -153,8 +166,19 @@ export class Mjolnir { } const ruleServer = config.web.ruleServer ? new RuleServer() : null; - const mjolnir = new Mjolnir(client, await client.getUserId(), matrixEmitter, managementRoomId, config, ruleServer); - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); + const mjolnir = new Mjolnir( + client, + await client.getUserId(), + matrixEmitter, + managementRoomId, + config, + ruleServer, + ); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "index", + "Mjolnir is starting up. Use !mjolnir to query status.", + ); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); return mjolnir; } @@ -177,35 +201,35 @@ export class Mjolnir { matrixEmitter.on("room.message", async (roomId, event) => { if (roomId !== this.managementRoomId) return; - if (!event['content']) return; + if (!event["content"]) return; - const content = event['content']; - if (content['msgtype'] === "m.text" && content['body']) { + const content = event["content"]; + if (content["msgtype"] === "m.text" && content["body"]) { const prefixes = [ COMMAND_PREFIX, this.localpart + ":", this.displayName + ":", - await client.getUserId() + ":", + (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} `), + (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())); + 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); + 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']}`); + event["content"]["body"] = COMMAND_PREFIX + restOfBody; + LogService.info("Mjolnir", `Command being run by ${event["sender"]}: ${event["content"]["body"]}`); - client.sendReadReceipt(roomId, event['event_id']).catch((e: any) => { + client.sendReadReceipt(roomId, event["event_id"]).catch((e: any) => { LogService.warn("Mjolnir", "Error sending read receipt: ", e); }); return handleCommand(roomId, event, this); @@ -221,14 +245,17 @@ export class Mjolnir { 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']; - } - }); + 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"); @@ -251,7 +278,8 @@ export class Mjolnir { managementRoomId, this.managementRoomOutput, this.protectionManager, - config); + config, + ); } public get state(): string { @@ -285,7 +313,11 @@ export class Mjolnir { 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.managementRoomOutput.logMessage( + LogLevel.INFO, + "Mjolnir@startup", + "report poll setting does not exist yet", + ); } } this.reportPoller.start(reportPollSetting.from); @@ -319,13 +351,21 @@ export class Mjolnir { } this.currentState = STATE_RUNNING; - await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); + await this.managementRoomOutput.logMessage( + LogLevel.INFO, + "Mjolnir@startup", + "Startup complete. Now monitoring rooms.", + ); } catch (err) { try { LogService.error("Mjolnir", "Error during startup:"); LogService.error("Mjolnir", extractRequestError(err)); this.stop(); - await this.managementRoomOutput.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console"); + 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}`); } @@ -350,7 +390,7 @@ export class Mjolnir { * 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() + return this.protectedRoomsConfig.getExplicitlyProtectedRooms(); } /** @@ -404,7 +444,7 @@ export class Mjolnir { // 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); + const policyListIds = this.policyListManager.lists.map((list) => list.roomId); return roomId !== this.managementRoomId && !policyListIds.includes(roomId); }; @@ -438,22 +478,25 @@ export class Mjolnir { 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. **") { + 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'], '🚨'); + 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); + 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 (ALL_BAN_LIST_RULE_TYPES.includes(event["type"]) || event["type"] === "m.room.redaction") { + policyList.updateForEvent(event.event_id); } } @@ -466,7 +509,7 @@ export class Mjolnir { try { const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`; const response = await this.client.doRequest("GET", endpoint); - return response['admin']; + return response["admin"]; } catch (e) { LogService.error("Mjolnir", "Error determining if Mjolnir is a server admin:"); LogService.error("Mjolnir", extractRequestError(e)); @@ -481,23 +524,22 @@ export class Mjolnir { public async suspendSynapseUser(userId: string): Promise { const endpoint = `/_synapse/admin/v1/suspend/${userId}`; - const body = {"suspend": true} + const body = { suspend: true }; return await this.client.doRequest("PUT", endpoint, null, body); } public async unsuspendSynapseUser(userId: string): Promise { const endpoint = `/_synapse/admin/v1/suspend/${userId}`; - const body = {"suspend": false} + const body = { suspend: false }; return await this.client.doRequest("PUT", endpoint, null, body); } - 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. */ + message: message /* If `undefined`, we'll use Synapse's default message. */, }); } @@ -511,11 +553,10 @@ export class Mjolnir { try { const endpoint = `/_synapse/admin/v1/rooms/${roomId}/make_room_admin`; return await this.client.doRequest("POST", endpoint, null, { - user_id: userId || await this.client.getUserId(), /* if not specified make the bot administrator */ + user_id: userId || (await this.client.getUserId()) /* if not specified make the bot administrator */, }); } catch (e) { return extractRequestError(e); } } } - diff --git a/src/ProtectedRoomsConfig.ts b/src/ProtectedRoomsConfig.ts index fc9af77a..38bbcce3 100644 --- a/src/ProtectedRoomsConfig.ts +++ b/src/ProtectedRoomsConfig.ts @@ -14,28 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import AwaitLock from 'await-lock'; +import AwaitLock from "await-lock"; import { extractRequestError, LogService, Permalinks } from "@vector-im/matrix-bot-sdk"; import { IConfig } from "./config"; -import { MatrixSendClient } from './MatrixEmitter'; +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(); + 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) { - - } + constructor(private readonly client: MatrixSendClient) {} /** * Load any rooms that have been explicitly protected from a Mjolnir config. @@ -66,14 +63,18 @@ export default class ProtectedRoomsConfig { 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']) { + 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.", extractRequestError(e)); + LogService.warn( + "ProtectedRoomsConfig", + "Couldn't find any explicitly protected rooms from Mjolnir's account data, assuming first start.", + extractRequestError(e), + ); } else { throw e; } @@ -104,7 +105,7 @@ export default class ProtectedRoomsConfig { * @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()] + return [...this.explicitlyProtectedRooms.keys()]; } /** @@ -116,9 +117,19 @@ export default class ProtectedRoomsConfig { // 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", extractRequestError(e)), [])); + 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", + extractRequestError(e), + ), + [] + ), + ); const roomsToSave = new Set([...this.explicitlyProtectedRooms.keys(), ...additionalProtectedRooms]); excludeRooms.forEach(roomsToSave.delete, roomsToSave); diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index 5f2908c9..4a4d36d5 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -44,8 +44,7 @@ import { htmlEscape } from "./utils"; * 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(); + private protectedRooms = new Set(); /** * These are the `m.bans` we want to synchronize across this set of rooms. @@ -97,7 +96,10 @@ export class ProtectedRoomsSet { /** * The revision of a each watched list that we have applied to protected rooms. */ - private readonly listRevisions = new Map(); + private readonly listRevisions = new Map< + PolicyList, + /** The last revision we used to sync protected rooms. */ Revision + >(); constructor( private readonly client: MatrixSendClient, @@ -137,8 +139,8 @@ export class ProtectedRoomsSet { return this.automaticRedactionReasons; } - public getProtectedRooms () { - return [...this.protectedRooms.keys()] + public getProtectedRooms() { + return [...this.protectedRooms.keys()]; } public isProtectedRoom(roomId: string): boolean { @@ -149,14 +151,14 @@ export class ProtectedRoomsSet { if (!this.policyLists.includes(policyList)) { this.policyLists.push(policyList); this.accessControlUnit.watchList(policyList); - policyList.on('PolicyList.update', this.listUpdateListener); + policyList.on("PolicyList.update", this.listUpdateListener); } } public unwatchList(policyList: PolicyList): void { - this.policyLists = this.policyLists.filter(list => list.roomId !== policyList.roomId); + this.policyLists = this.policyLists.filter((list) => list.roomId !== policyList.roomId); this.accessControlUnit.unwatchList(policyList); - policyList.off('PolicyList.update', this.listUpdateListener) + policyList.off("PolicyList.update", this.listUpdateListener); } /** @@ -179,24 +181,31 @@ export class ProtectedRoomsSet { } 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 (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'] === '') { + if (event["type"] === "m.room.power_levels" && event["state_key"] === "") { // power levels were updated - recheck permissions this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION); - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId); + await this.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "Mjolnir", + `Power levels changed in ${roomId} - checking permissions...`, + roomId, + ); const errors = await this.protectionManager.verifyPermissionsIn(roomId); const hadErrors = await this.printActionResult(errors); if (!hadErrors) { await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`); } return; - } else if (event['type'] === "m.room.member") { + } 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. @@ -215,12 +224,12 @@ export class ProtectedRoomsSet { let hadErrors = false; const [aclErrors, banErrors] = await Promise.all([ this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()), - this.applyUserBans(this.protectedRoomsByActivity()) + this.applyUserBans(this.protectedRoomsByActivity()), ]); const redactionErrors = await this.processRedactionQueue(); - hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); - hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:"); - hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:"); + hadErrors = hadErrors || (await this.printActionResult(aclErrors, "Errors updating server ACLs:")); + hadErrors = hadErrors || (await this.printActionResult(banErrors, "Errors updating member bans:")); + hadErrors = hadErrors || (await this.printActionResult(redactionErrors, "Error updating redactions:")); if (!hadErrors) { const html = `Done updating rooms - no errors`; @@ -274,7 +283,11 @@ export class ProtectedRoomsSet { * @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 { + 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)) { @@ -298,9 +311,7 @@ export class ProtectedRoomsSet { // 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. return new Promise((resolve, reject) => { - this.aclChain = this.aclChain - .then(() => this._applyServerAcls(lists, roomIds)) - .then(resolve, reject); + this.aclChain = this.aclChain.then(() => this._applyServerAcls(lists, roomIds)).then(resolve, reject); }); } @@ -313,18 +324,31 @@ export class ProtectedRoomsSet { 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)}`); + await this.client.sendNotice( + this.managementRoomId, + `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`, + ); } const errors: RoomUpdateError[] = []; for (const roomId of roomIds) { try { - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId); + 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); + await this.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "ApplyAcl", + `Skipping ACLs for ${roomId} because they are already the right ones`, + roomId, + ); continue; } } catch (e) { @@ -332,16 +356,29 @@ export class ProtectedRoomsSet { } // We specifically use sendNotice to avoid having to escape HTML - await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId); + 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); + 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") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL; + 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") + ? ERROR_KIND_PERMISSION + : ERROR_KIND_FATAL; errors.push({ roomId, errorMessage: message, errorKind: kind }); } } @@ -349,7 +386,7 @@ export class ProtectedRoomsSet { } /** - * Applies the member bans represented by the ban lists to the provided rooms, returning the + * 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. @@ -360,50 +397,73 @@ export class ProtectedRoomsSet { 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); + await this.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "ApplyBan", + `Updating member bans in ${roomId}`, + roomId, + ); - let members: { userId: string, membership: string }[]; + let members: { userId: string; membership: string }[]; if (this.config.fasterMembershipChecks) { const memberIds = await this.client.getJoinedRoomMembers(roomId); - members = memberIds.map(u => { + 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' }; - }); + 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') { + 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 : ''; + 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); + await this.managementRoomOutput.logMessage( + LogLevel.INFO, + "ApplyBan", + `Banning ${member.userId} in ${roomId} for: ${reason}`, + roomId, + ); if (!this.config.noop) { await this.client.banUser(member.userId, roomId, memberAccess.rule!.reason); - if (this.automaticRedactGlobs.find(g => g.test(reason.toLowerCase()))) { + if (this.automaticRedactGlobs.find((g) => g.test(reason.toLowerCase()))) { this.redactUser(member.userId, roomId); } } else { - await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + 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) { - const message = e.message || (e.body ? e.body.error : ''); + const message = e.message || (e.body ? e.body.error : ""); errors.push({ roomId, errorMessage: message, - errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL, + errorKind: + message && message.includes("You don't have permission to ban") + ? ERROR_KIND_PERMISSION + : ERROR_KIND_FATAL, }); } } @@ -422,8 +482,8 @@ export class ProtectedRoomsSet { let html = ""; let text = ""; - const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:'); - const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : ''; + 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`; @@ -432,11 +492,11 @@ export class ProtectedRoomsSet { const rule = change.rule; let ruleKind: string = rule.kind; if (ruleKind === RULE_USER) { - ruleKind = 'user'; + ruleKind = "user"; } else if (ruleKind === RULE_SERVER) { - ruleKind = 'server'; + ruleKind = "server"; } else if (ruleKind === RULE_ROOM) { - ruleKind = 'room'; + ruleKind = "room"; } html += `
  • ${change.changeType} ${htmlEscape(ruleKind)} (${htmlEscape(rule.recommendation ?? "")}): ${htmlEscape(rule.entity)} (${htmlEscape(rule.reason)})
  • `; text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`; @@ -456,9 +516,12 @@ export class ProtectedRoomsSet { if (errors.length <= 0) return false; if (!logAnyways) { - errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind)); + errors = errors.filter((e) => this.errorCache.triggerError(e.roomId, e.errorKind)); if (errors.length <= 0) { - LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room."); + LogService.warn( + "Mjolnir", + "Multiple errors are happening, however they are muted. Please check the management room.", + ); return true; } } @@ -466,12 +529,12 @@ export class ProtectedRoomsSet { let html = ""; let text = ""; - const htmlTitle = title ? `${title}
    ` : ''; - const textTitle = title ? `${title}\n` : ''; + const htmlTitle = title ? `${title}
    ` : ""; + const textTitle = title ? `${title}\n` : ""; html += `${htmlTitle}${errors.length} errors updating protected rooms!
      `; text += `${textTitle}${errors.length} errors updating protected rooms!\n`; - const viaServers = [(new UserID(await this.client.getUserId())).domain]; + const viaServers = [new UserID(await this.client.getUserId()).domain]; for (const error of errors) { const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId; const url = Permalinks.forRoom(alias, viaServers); @@ -491,7 +554,7 @@ export class ProtectedRoomsSet { } public requiredProtectionPermissions() { - throw new TypeError("Unimplemented, need to put protections into here too.") + throw new TypeError("Unimplemented, need to put protections into here too."); } public async verifyPermissions(verbose = true, printRegardless = false) { @@ -500,7 +563,11 @@ export class ProtectedRoomsSet { errors.push(...(await this.protectionManager.verifyPermissionsIn(roomId))); } - const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless); + const hadErrors = await this.printActionResult( + errors, + "Permission errors in protected rooms:", + printRegardless, + ); if (!hadErrors && verbose) { const html = `All permissions look OK.`; const text = "All permissions look OK."; diff --git a/src/RoomMembers.ts b/src/RoomMembers.ts index 2732355a..cee1c26a 100644 --- a/src/RoomMembers.ts +++ b/src/RoomMembers.ts @@ -3,10 +3,10 @@ import { MatrixEmitter } from "./MatrixEmitter"; enum Action { Join, Leave, - Other + Other, } -const LEAVE_OR_BAN = ['leave', 'ban']; +const LEAVE_OR_BAN = ["leave", "ban"]; /** * Storing a join event. @@ -18,8 +18,8 @@ const LEAVE_OR_BAN = ['leave', 'ban']; export class Join { constructor( public readonly userId: string, - public readonly timestamp: number - ) { } + public readonly timestamp: number, + ) {} } /** @@ -78,7 +78,7 @@ class RoomMembers { // Nothing to do. return; } - this._joinsByTimestamp = this._joinsByTimestamp.filter(join => this.isStillValid(join)); + this._joinsByTimestamp = this._joinsByTimestamp.filter((join) => this.isStillValid(join)); this._leaves = new Map(); } @@ -201,7 +201,7 @@ export class RoomMemberManager { * `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 { + public getUserJoin(user: { roomId: string; userId: string }): Date | null { const { roomId, userId } = user; const ts = this.perRoom.get(roomId)?.get(userId) || null; if (!ts) { @@ -227,7 +227,7 @@ export class RoomMemberManager { * Record join/leave events. */ public async handleEvent(roomId: string, event: any, now?: Date) { - if (event['type'] !== 'm.room.member') { + if (event["type"] !== "m.room.member") { // Not a join/leave event. return; } @@ -237,18 +237,18 @@ export class RoomMemberManager { // Not a room we are watching. return; } - const userId = event['state_key']; + const userId = event["state_key"]; if (!userId) { // Ill-formed event. return; } - const userState = event['content']['membership']; - const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || "leave"; + 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") { + if (userState === "join" && prevMembership !== "join") { action = Action.Join; } else if (LEAVE_OR_BAN.includes(userState) && !LEAVE_OR_BAN.includes(prevMembership)) { action = Action.Leave; @@ -267,4 +267,4 @@ export class RoomMemberManager { break; } } -} \ No newline at end of file +} diff --git a/src/appservice/AccessControl.ts b/src/appservice/AccessControl.ts index 13194633..b363da3c 100644 --- a/src/appservice/AccessControl.ts +++ b/src/appservice/AccessControl.ts @@ -25,12 +25,10 @@ import { Permalinks } from "@vector-im/matrix-bot-sdk"; * Internally we use a policy list within matrix to determine who has access via the `AccessControlUnit`. */ export class AccessControl { - private constructor( private readonly accessControlList: PolicyList, - private readonly accessControlUnit: AccessControlUnit - ) { - } + private readonly accessControlUnit: AccessControlUnit, + ) {} /** * Construct and initialize access control for the `MjolnirAppService`. @@ -47,7 +45,7 @@ export class AccessControl { const accessControlList = new PolicyList( accessControlListId, Permalinks.forRoom(accessControlListId), - bridge.getBot().getClient() + bridge.getBot().getClient(), ); const accessControlUnit = new AccessControlUnit([accessControlList]); await accessControlList.updateList(); diff --git a/src/appservice/Api.ts b/src/appservice/Api.ts index 726ca6e4..90d934fd 100644 --- a/src/appservice/Api.ts +++ b/src/appservice/Api.ts @@ -23,28 +23,34 @@ export class Api { * @param accessToken An openID token. * @returns The mxid of the user that this token belongs to or null if the token could not be authenticated. */ - private resolveAccessToken(accessToken: string): Promise { + private resolveAccessToken(accessToken: string): Promise { return new Promise((resolve, reject) => { - request({ - url: `${this.homeserver}/_matrix/federation/v1/openid/userinfo`, - qs: { access_token: accessToken }, - }, (err, homeserver_response, body) => { - if (err) { - log.error(`Error resolving openID token from ${this.homeserver}`, err); - reject(null); - } - - let response: { sub: string}; - try { - response = JSON.parse(body); - } catch (e) { - log.error(`Received ill formed response from ${this.homeserver} when resolving an openID token`, e); - reject(null); - return; - } - - resolve(response.sub); - }); + request( + { + url: `${this.homeserver}/_matrix/federation/v1/openid/userinfo`, + qs: { access_token: accessToken }, + }, + (err, homeserver_response, body) => { + if (err) { + log.error(`Error resolving openID token from ${this.homeserver}`, err); + reject(null); + } + + let response: { sub: string }; + try { + response = JSON.parse(body); + } catch (e) { + log.error( + `Received ill formed response from ${this.homeserver} when resolving an openID token`, + e, + ); + reject(null); + return; + } + + resolve(response.sub); + }, + ); }); } @@ -53,7 +59,7 @@ export class Api { if (!this.httpServer) { throw new TypeError("Server was never started"); } - this.httpServer.close(error => error ? reject(error) : resolve(undefined)) + this.httpServer.close((error) => (error ? reject(error) : resolve(undefined))); }); } @@ -123,7 +129,7 @@ export class Api { return; } - const existing = this.mjolnirManager.getOwnedMjolnirs(userId) + const existing = this.mjolnirManager.getOwnedMjolnirs(userId); response.status(200).json(existing); } diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 953f40a7..4c8f8c86 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -14,7 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { AppServiceRegistration, Bridge, Request, WeakEvent, BridgeContext, MatrixUser, Logger } from "matrix-appservice-bridge"; +import { + AppServiceRegistration, + Bridge, + Request, + WeakEvent, + BridgeContext, + MatrixUser, + Logger, +} from "matrix-appservice-bridge"; import { MjolnirManager } from ".//MjolnirManager"; import { DataStore, PgDataStore } from ".//datastore"; import { Api } from "./Api"; @@ -28,7 +36,6 @@ const log = new Logger("AppService"); * the entrypoint of the application. */ export class MjolnirAppService { - private readonly api: Api; private readonly openMetrics: OpenMetrics; @@ -54,7 +61,11 @@ 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: IAppserviceConfig, dataStore: DataStore, registrationFilePath: string) { + public static async makeMjolnirAppService( + config: IAppserviceConfig, + dataStore: DataStore, + registrationFilePath: string, + ) { const bridge = new Bridge({ homeserverUrl: config.homeserver.url, domain: config.homeserver.domain, @@ -63,8 +74,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, @@ -73,13 +88,7 @@ export class MjolnirAppService { const accessControlListId = await bridge.getBot().getClient().resolveRoom(config.accessControlList); const accessControl = await AccessControl.setupAccessControl(accessControlListId, bridge); const mjolnirManager = await MjolnirManager.makeMjolnirManager(dataStore, bridge, accessControl, config); - const appService = new MjolnirAppService( - config, - bridge, - mjolnirManager, - accessControl, - dataStore - ); + const appService = new MjolnirAppService(config, bridge, mjolnirManager, accessControl, dataStore); bridge.opts.controller = { onUserQuery: appService.onUserQuery.bind(appService), onEvent: appService.onEvent.bind(appService), @@ -93,7 +102,11 @@ export class MjolnirAppService { * @param config The parsed configuration file. * @param registrationFilePath A path to their homeserver registration file. */ - public static async run(port: number, config: IAppserviceConfig, registrationFilePath: string): Promise { + public static async run( + port: number, + config: IAppserviceConfig, + registrationFilePath: string, + ): Promise { Logger.configure(config.logging ?? { console: "debug" }); const dataStore = new PgDataStore(config.db.connectionString); await dataStore.init(); @@ -103,7 +116,7 @@ export class MjolnirAppService { return service; } - public onUserQuery (queriedUser: MatrixUser) { + public onUserQuery(queriedUser: MatrixUser) { return {}; // auto-provision users with no additonal data } @@ -118,15 +131,20 @@ export class MjolnirAppService { const mxEvent = request.getData(); // Provision a new mjolnir for the invitee when the appservice bot (designated by this.bridge.botUserId) is invited to a room. // Acts as an alternative to the web api provided for the widget. - if ('m.room.member' === mxEvent.type) { - 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`); + if ("m.room.member" === mxEvent.type) { + 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`, + ); let mjolnir = null; try { mjolnir = await this.mjolnirManager.getOrProvisionMjolnir(mxEvent.sender); mjolnir.start(); } catch (e: any) { - log.error(`Failed to provision a mjolnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e); + 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. } @@ -135,13 +153,24 @@ export class MjolnirAppService { try { const mjolnirUserId = await mjolnir.getUserId(); await this.bridge.getBot().getClient().joinRoom(mxEvent.room_id); - await this.bridge.getBot().getClient().sendNotice(mxEvent.room_id, `Setting up moderation in this room.`); + await this.bridge + .getBot() + .getClient() + .sendNotice(mxEvent.room_id, `Setting up moderation in this room.`); await this.bridge.getBot().getClient().inviteUser(mjolnirUserId, mxEvent.room_id); await mjolnir.joinRoom(mxEvent.room_id); - log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, substituted ${mjolnirUserId}`); - await this.bridge.getBot().getClient().sendNotice(mxEvent.room_id, `You should now give ${mjolnirUserId} admin privileges.`); + log.info( + `${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, substituted ${mjolnirUserId}`, + ); + await this.bridge + .getBot() + .getClient() + .sendNotice(mxEvent.room_id, `You should now give ${mjolnirUserId} admin privileges.`); } catch (e: any) { - log.error(`Failed to invite provisioned Mjölnir ${await mjolnir.getUserId()} for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e); + log.error( + `Failed to invite provisioned Mjölnir ${await mjolnir.getUserId()} for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, + e, + ); } } @@ -153,7 +182,7 @@ export class MjolnirAppService { } } } - this.accessControl.handleEvent(mxEvent['room_id'], mxEvent); + this.accessControl.handleEvent(mxEvent["room_id"], mxEvent); this.mjolnirManager.onEvent(request, context); } @@ -187,7 +216,10 @@ export class MjolnirAppService { * @param reg Any existing paramaters to be included in the registration, to be mutated by this method. * @param callback To call when the registration has been generated with the final registration. */ - public static generateRegistration(reg: AppServiceRegistration, callback: (finalRegistration: AppServiceRegistration) => void) { + public static generateRegistration( + reg: AppServiceRegistration, + callback: (finalRegistration: AppServiceRegistration) => void, + ) { reg.setId(AppServiceRegistration.generateToken()); reg.setHomeserverToken(AppServiceRegistration.generateToken()); reg.setAppServiceToken(AppServiceRegistration.generateToken()); diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts index d15307de..fd457d23 100644 --- a/src/appservice/MjolnirManager.ts +++ b/src/appservice/MjolnirManager.ts @@ -11,7 +11,7 @@ import { randomUUID } from "crypto"; import EventEmitter from "events"; import { MatrixEmitter } from "../MatrixEmitter"; -const log = new Logger('MjolnirManager'); +const log = new Logger("MjolnirManager"); /** * The MjolnirManager is responsible for: @@ -20,17 +20,15 @@ const log = new Logger('MjolnirManager'); * * Informing mjolnirs about new events. */ export class MjolnirManager { - private readonly perMjolnirId: Map = new Map(); - private readonly perOwnerId: Map = new Map(); + private readonly perMjolnirId: Map = new Map(); + private readonly perOwnerId: Map = new Map(); private constructor( private readonly dataStore: DataStore, private readonly bridge: Bridge, private readonly accessControl: AccessControl, private readonly config: IAppserviceConfig, - ) { - - } + ) {} /** * Create the mjolnir manager from the datastore and the access control. @@ -39,7 +37,12 @@ export class MjolnirManager { * @param accessControl Who has access to the bridge. * @returns A new mjolnir manager. */ - public static async makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl, config: IAppserviceConfig): Promise { + public static async makeMjolnirManager( + dataStore: DataStore, + bridge: Bridge, + accessControl: AccessControl, + config: IAppserviceConfig, + ): Promise { const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl, config); await mjolnirManager.createMjolnirsFromDataStore(); return mjolnirManager; @@ -52,16 +55,16 @@ export class MjolnirManager { * @param client A client for the appservice virtual user that the new mjolnir should use. * @returns A new managed mjolnir. */ - public async makeInstance(requestingUserId: string, managementRoomId: string, client: MatrixClient): Promise { + public async makeInstance( + requestingUserId: string, + managementRoomId: string, + client: MatrixClient, + ): Promise { let mjolnirUserId = await client.getUserId(); const intentListener = new MatrixIntentListener(mjolnirUserId); const managedMjolnir = new ManagedMjolnir( requestingUserId, - await Mjolnir.setupMjolnirFromConfig( - client, - intentListener, - getProvisionedMjolnirConfig(managementRoomId) - ), + await Mjolnir.setupMjolnirFromConfig(client, intentListener, getProvisionedMjolnirConfig(managementRoomId)), intentListener, ); await managedMjolnir.start(); @@ -78,7 +81,7 @@ export class MjolnirManager { * @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 { + public getMjolnir(mjolnirId: string, ownerId: string): ManagedMjolnir | undefined { const mjolnir = this.perMjolnirId.get(mjolnirId); if (mjolnir) { if (mjolnir.ownerId !== ownerId) { @@ -100,7 +103,7 @@ export class MjolnirManager { // 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.perMjolnirId.values()].filter(mjolnir => mjolnir.ownerId !== ownerId); + return [...this.perMjolnirId.values()].filter((mjolnir) => mjolnir.ownerId !== ownerId); } /** @@ -128,7 +131,9 @@ export class MjolnirManager { public async provisionNewMjolnir(requestingUserId: string): Promise { 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); if (provisionedMjolnirs.length === 0) { @@ -136,9 +141,9 @@ export class MjolnirManager { const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart); const managementRoomId = await mjIntent.matrixClient.createRoom({ - preset: 'private_chat', + preset: "private_chat", invite: [requestingUserId], - name: `${requestingUserId}'s mjolnir` + name: `${requestingUserId}'s mjolnir`, }); const mjolnir = await this.makeInstance(requestingUserId, managementRoomId, mjIntent.matrixClient); @@ -152,7 +157,9 @@ export class MjolnirManager { return mjolnir; } else { - throw new Error(`User: ${requestingUserId} has already provisioned ${provisionedMjolnirs.length} mjolnirs.`); + throw new Error( + `User: ${requestingUserId} has already provisioned ${provisionedMjolnirs.length} mjolnirs.`, + ); } } @@ -178,7 +185,10 @@ export class MjolnirManager { 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"}`); + mjIntent.matrixClient.sendNotice( + mjolnirRecord.management_room, + `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`, + ); } else { await this.makeInstance( mjolnirRecord.owner, @@ -187,7 +197,10 @@ export class MjolnirManager { ).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`); + mjIntent.matrixClient.sendNotice( + mjolnirRecord.management_room, + `Your mjolnir could not be started. Please alert the administrator`, + ); }); } } @@ -199,7 +212,7 @@ export class ManagedMjolnir { public readonly ownerId: string, private readonly mjolnir: Mjolnir, private readonly matrixEmitter: MatrixIntentListener, - ) { } + ) {} public async onEvent(request: Request) { this.matrixEmitter.handleEvent(request.getData()); @@ -213,12 +226,9 @@ export class ManagedMjolnir { } public async createFirstList(mjolnirOwnerId: string, shortcode: string) { - const listRoomId = await PolicyList.createList( - this.mjolnir.client, - shortcode, - [mjolnirOwnerId], - { name: `${mjolnirOwnerId}'s policy room` } - ); + const listRoomId = await PolicyList.createList(this.mjolnir.client, shortcode, [mjolnirOwnerId], { + name: `${mjolnirOwnerId}'s policy room`, + }); const roomRef = Permalinks.forRoom(listRoomId); await this.mjolnir.addProtectedRoom(listRoomId); return await this.mjolnir.policyListManager.watchList(roomRef); @@ -250,31 +260,30 @@ export class ManagedMjolnir { */ export class MatrixIntentListener extends EventEmitter implements MatrixEmitter { constructor(private readonly mjolnirId: string) { - super() + 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["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"] === "invite") { + this.emit("room.invite", mxEvent.room_id, mxEvent); } - if (mxEvent['content']['membership'] === 'join') { - this.emit('room.join', 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.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); + 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); - + this.emit("room.event", mxEvent.room_id, mxEvent); } /** diff --git a/src/appservice/cli.ts b/src/appservice/cli.ts index b9bf25e1..9c4d50ba 100644 --- a/src/appservice/cli.ts +++ b/src/appservice/cli.ts @@ -13,10 +13,10 @@ const cli = new Cli({ bridgeConfig: { schema: {}, affectsRegistration: false, - defaults: {} + defaults: {}, }, generateRegistration: MjolnirAppService.generateRegistration, - run: async function(port: number) { + run: async function (port: number) { const config: IAppserviceConfig | null = cli.getConfig() as any; if (config === null) { throw new Error("Couldn't load config"); @@ -25,7 +25,7 @@ const cli = new Cli({ utils.initializeSentry(config); utils.initializeGlobalPerformanceMetrics(config); await MjolnirAppService.run(port, config, cli.getRegistrationFilePath()); - } + }, }); cli.run(); diff --git a/src/appservice/datastore.ts b/src/appservice/datastore.ts index 848e2106..c755b1bd 100644 --- a/src/appservice/datastore.ts +++ b/src/appservice/datastore.ts @@ -16,9 +16,9 @@ limitations under the License. import { Client } from "pg"; export interface MjolnirRecord { - local_part: string, - owner: string, - management_room: string, + local_part: string; + owner: string; + management_room: string; } /** @@ -68,11 +68,13 @@ export class PgDataStore implements DataStore { } public async close(): Promise { - await this.pgClient.end() + await this.pgClient.end(); } public async list(): Promise { - const result = await this.pgClient.query("SELECT local_part, owner, management_room FROM mjolnir"); + const result = await this.pgClient.query( + "SELECT local_part, owner, management_room FROM mjolnir", + ); if (!result.rowCount) { return []; @@ -82,10 +84,11 @@ export class PgDataStore implements DataStore { } public async store(mjolnirRecord: MjolnirRecord): Promise { - await this.pgClient.query( - "INSERT INTO mjolnir (local_part, owner, management_room) VALUES ($1, $2, $3)", - [mjolnirRecord.local_part, mjolnirRecord.owner, mjolnirRecord.management_room], - ); + await this.pgClient.query("INSERT INTO mjolnir (local_part, owner, management_room) VALUES ($1, $2, $3)", [ + mjolnirRecord.local_part, + mjolnirRecord.owner, + mjolnirRecord.management_room, + ]); } public async lookupByOwner(owner: string): Promise { diff --git a/src/commands/AddRemoveProtectedRoomsCommand.ts b/src/commands/AddRemoveProtectedRoomsCommand.ts index a6ec0b94..4e45cb16 100644 --- a/src/commands/AddRemoveProtectedRoomsCommand.ts +++ b/src/commands/AddRemoveProtectedRoomsCommand.ts @@ -21,7 +21,7 @@ import { extractRequestError, LogLevel, LogService } from "@vector-im/matrix-bot export async function execAddProtectedRoom(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const protectedRoomId = await mjolnir.client.joinRoom(parts[3]); await mjolnir.addProtectedRoom(protectedRoomId); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir rooms remove @@ -32,7 +32,12 @@ export async function execRemoveProtectedRoom(roomId: string, event: any, mjolni await mjolnir.client.leaveRoom(protectedRoomId); } catch (e) { LogService.warn("AddRemoveProtectedRoomsCommand", extractRequestError(e)); - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "AddRemoveProtectedRoomsCommand", + `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, + protectedRoomId, + ); } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/AddRemoveRoomFromDirectoryCommand.ts b/src/commands/AddRemoveRoomFromDirectoryCommand.ts index b8f86a13..8cd64189 100644 --- a/src/commands/AddRemoveRoomFromDirectoryCommand.ts +++ b/src/commands/AddRemoveRoomFromDirectoryCommand.ts @@ -17,12 +17,18 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { RichReply } from "@vector-im/matrix-bot-sdk"; -async function addRemoveFromDirectory(inRoomId: string, event: any, mjolnir: Mjolnir, roomRef: string, visibility: "public" | "private") { +async function addRemoveFromDirectory( + inRoomId: string, + event: any, + mjolnir: Mjolnir, + roomRef: string, + visibility: "public" | "private", +) { const isAdmin = await mjolnir.isSynapseAdmin(); if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(inRoomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(inRoomId, reply); return; } @@ -30,7 +36,7 @@ async function addRemoveFromDirectory(inRoomId: string, event: any, mjolnir: Mjo const targetRoomId = await mjolnir.client.resolveRoom(roomRef); await mjolnir.client.setDirectoryVisibility(targetRoomId, visibility); - await mjolnir.client.unstableApis.addReactionToEvent(inRoomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(inRoomId, event["event_id"], "✅"); } // !mjolnir directory add @@ -39,6 +45,11 @@ export async function execAddRoomToDirectoryCommand(roomId: string, event: any, } // !mjolnir directory remove -export async function execRemoveRoomFromDirectoryCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { +export async function execRemoveRoomFromDirectoryCommand( + roomId: string, + event: any, + mjolnir: Mjolnir, + parts: string[], +) { await addRemoveFromDirectory(roomId, event, mjolnir, parts[3], "private"); } diff --git a/src/commands/AliasCommands.ts b/src/commands/AliasCommands.ts index 23493e26..cf545888 100644 --- a/src/commands/AliasCommands.ts +++ b/src/commands/AliasCommands.ts @@ -27,7 +27,7 @@ export async function execMoveAliasCommand(roomId: string, event: any, mjolnir: if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } @@ -36,7 +36,7 @@ export async function execMoveAliasCommand(roomId: string, event: any, mjolnir: const newRoomId = await mjolnir.client.resolveRoom(targetRoom); await mjolnir.client.createRoomAlias(movingAlias, newRoomId); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir alias add @@ -48,7 +48,7 @@ export async function execAddAliasCommand(roomId: string, event: any, mjolnir: M if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } @@ -56,7 +56,7 @@ export async function execAddAliasCommand(roomId: string, event: any, mjolnir: M const newRoomId = await mjolnir.client.resolveRoom(targetRoom); await mjolnir.client.createRoomAlias(aliasToAdd, newRoomId); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir alias remove @@ -67,14 +67,14 @@ export async function execRemoveAliasCommand(roomId: string, event: any, mjolnir if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } await mjolnir.client.deleteRoomAlias(aliasToRemove); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir resolve diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 36384237..faa2efe3 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -29,8 +29,13 @@ import { execImportCommand } from "./ImportCommand"; import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execDeactivateCommand } from "./DeactivateCommand"; import { - execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection, - execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection + execDisableProtection, + execEnableProtection, + execListProtections, + execConfigGetProtection, + execConfigSetProtection, + execConfigAddProtection, + execConfigRemoveProtection, } from "./ProtectionsCommands"; import { execListProtectedRooms } from "./ListProtectedRoomsCommand"; import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand"; @@ -43,100 +48,103 @@ import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { parse as tokenize } from "shell-quote"; import { execSinceCommand } from "./SinceCommand"; import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand"; -import {execSuspendCommand} from "./SuspendCommand"; -import {execUnsuspendCommand} from "./UnsuspendCommand"; - +import { execSuspendCommand } from "./SuspendCommand"; +import { execUnsuspendCommand } from "./UnsuspendCommand"; export const COMMAND_PREFIX = "!mjolnir"; export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { - const cmd = event['content']['body']; - const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); + const cmd = event["content"]["body"]; + const parts = cmd + .trim() + .split(" ") + .filter((p) => p.trim().length > 0); // A shell-style parser that can parse `"a b c"` (with quotes) as a single argument. // We do **not** want to parse `#` as a comment start, though. const tokens = tokenize(cmd.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); try { - if (parts.length === 1 || parts[1] === 'status') { + if (parts.length === 1 || parts[1] === "status") { return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); - } else if (parts[1] === 'ban' && parts.length > 2) { + } else if (parts[1] === "ban" && parts.length > 2) { return await execBanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unban' && parts.length > 2) { + } else if (parts[1] === "unban" && parts.length > 2) { return await execUnbanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') { - return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) - } else if (parts[1] === 'rules') { + } else if (parts[1] === "rules" && parts.length === 4 && parts[2] === "matching") { + return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]); + } else if (parts[1] === "rules") { return await execDumpRulesCommand(roomId, event, mjolnir); - } else if (parts[1] === 'sync') { + } else if (parts[1] === "sync") { return await execSyncCommand(roomId, event, mjolnir); - } else if (parts[1] === 'verify') { + } else if (parts[1] === "verify") { return await execPermissionCheckCommand(roomId, event, mjolnir); - } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { + } else if (parts.length >= 5 && parts[1] === "list" && parts[2] === "create") { return await execCreateListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'watch' && parts.length > 1) { + } else if (parts[1] === "watch" && parts.length > 1) { return await execWatchCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unwatch' && parts.length > 1) { + } else if (parts[1] === "unwatch" && parts.length > 1) { return await execUnwatchCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'redact' && parts.length > 1) { + } else if (parts[1] === "redact" && parts.length > 1) { return await execRedactCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'import' && parts.length > 2) { + } else if (parts[1] === "import" && parts.length > 2) { return await execImportCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'default' && parts.length > 2) { + } else if (parts[1] === "default" && parts.length > 2) { return await execSetDefaultListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'deactivate' && parts.length > 2) { + } else if (parts[1] === "deactivate" && parts.length > 2) { return await execDeactivateCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'protections') { + } else if (parts[1] === "protections") { return await execListProtections(roomId, event, mjolnir, parts); - } else if (parts[1] === 'enable' && parts.length > 1) { + } else if (parts[1] === "enable" && parts.length > 1) { return await execEnableProtection(roomId, event, mjolnir, parts); - } else if (parts[1] === 'disable' && parts.length > 1) { + } 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] === 'rooms' && parts.length > 3 && parts[2] === 'add') { + } 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] === "rooms" && parts.length > 3 && parts[2] === "add") { return await execAddProtectedRoom(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') { + } else if (parts[1] === "rooms" && parts.length > 3 && parts[2] === "remove") { return await execRemoveProtectedRoom(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'setup') { + } else if (parts[1] === "rooms" && parts.length > 3 && parts[2] === "setup") { return await execSetupProtectedRoom(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rooms' && parts.length === 2) { + } else if (parts[1] === "rooms" && parts.length === 2) { return await execListProtectedRooms(roomId, event, mjolnir); - } else if (parts[1] === 'move' && parts.length > 3) { + } else if (parts[1] === "move" && parts.length > 3) { return await execMoveAliasCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'directory' && parts.length > 3 && parts[2] === 'add') { + } else if (parts[1] === "directory" && parts.length > 3 && parts[2] === "add") { return await execAddRoomToDirectoryCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'directory' && parts.length > 3 && parts[2] === 'remove') { + } else if (parts[1] === "directory" && parts.length > 3 && parts[2] === "remove") { return await execRemoveRoomFromDirectoryCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'alias' && parts.length > 4 && parts[2] === 'add') { + } else if (parts[1] === "alias" && parts.length > 4 && parts[2] === "add") { return await execAddAliasCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'alias' && parts.length > 3 && parts[2] === 'remove') { + } else if (parts[1] === "alias" && parts.length > 3 && parts[2] === "remove") { return await execRemoveAliasCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'resolve' && parts.length > 2) { + } else if (parts[1] === "resolve" && parts.length > 2) { return await execResolveCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'powerlevel' && parts.length > 3) { + } else if (parts[1] === "powerlevel" && parts.length > 3) { return await execSetPowerLevelCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'shutdown' && parts[2] === 'room' && parts.length > 3) { + } else if (parts[1] === "shutdown" && parts[2] === "room" && parts.length > 3) { return await execShutdownRoomCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'since') { + } else if (parts[1] === "since") { return await execSinceCommand(roomId, event, mjolnir, tokens); - } else if (parts[1] === 'kick' && parts.length > 2) { + } else if (parts[1] === "kick" && parts.length > 2) { return await execKickCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'make' && parts[2] === 'admin' && parts.length > 3) { + } else if (parts[1] === "make" && parts[2] === "admin" && parts.length > 3) { return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'suspend' && parts.length > 2) { + } else if (parts[1] === "suspend" && parts.length > 2) { return await execSuspendCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unsuspend' && parts.length > 2) { - return await execUnsuspendCommand(roomId, event, mjolnir, parts) + } else if (parts[1] === "unsuspend" && parts.length > 2) { + return await execUnsuspendCommand(roomId, event, mjolnir, parts); } else { // Help menu - const menu = "" + + const menu = + "" + "!mjolnir - Print status information\n" + "!mjolnir status - Print status information\n" + "!mjolnir status protection [subcommand] - Print status information for a protection\n" + @@ -178,7 +186,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir make admin [user alias/ID] - Make the specified user or the bot itself admin of the room\n" + "!mjolnir suspend - Suspend the specified user" + "!mjolnir unsuspend - Unsuspend the specified user" + - "!mjolnir help - This menu\n" + "!mjolnir help - This menu\n"; const html = `Mjolnir help:
      ${htmlEscape(menu)}
      `; const text = `Mjolnir help:\n${menu}`; const reply = RichReply.createFor(roomId, event, text, html); diff --git a/src/commands/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 437c8e5f..af1dfbbc 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -23,12 +23,9 @@ export async function execCreateListCommand(roomId: string, event: any, mjolnir: const shortcode = parts[3]; const aliasLocalpart = parts[4]; - const listRoomId = await PolicyList.createList( - mjolnir.client, - shortcode, - [event['sender']], - { room_alias_name: aliasLocalpart } - ); + const listRoomId = await PolicyList.createList(mjolnir.client, shortcode, [event["sender"]], { + room_alias_name: aliasLocalpart, + }); const roomRef = Permalinks.forRoom(listRoomId); await mjolnir.policyListManager.watchList(roomRef); diff --git a/src/commands/DeactivateCommand.ts b/src/commands/DeactivateCommand.ts index bdcacf3a..6b97a980 100644 --- a/src/commands/DeactivateCommand.ts +++ b/src/commands/DeactivateCommand.ts @@ -25,11 +25,11 @@ export async function execDeactivateCommand(roomId: string, event: any, mjolnir: if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } await mjolnir.deactivateSynapseUser(target); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/DumpRulesCommand.ts b/src/commands/DumpRulesCommand.ts index d4c54e34..7a9a8276 100644 --- a/src/commands/DumpRulesCommand.ts +++ b/src/commands/DumpRulesCommand.ts @@ -33,14 +33,14 @@ export async function execRulesMatchingCommand(roomId: string, event: any, mjoln let html = ""; let text = ""; for (const list of mjolnir.policyListManager.lists) { - const matches = list.rulesMatchingEntity(entity) + const matches = list.rulesMatchingEntity(entity); if (matches.length === 0) { continue; } - const matchesInfo = `Found ${matches.length} ` + (matches.length === 1 ? 'match:' : 'matches:'); - const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : ''; + const matchesInfo = `Found ${matches.length} ` + (matches.length === 1 ? "match:" : "matches:"); + const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : ""; html += `${htmlEscape(list.roomId)}${shortcodeInfo} ${matchesInfo}
        `; text += `${list.roomRef}${shortcodeInfo} ${matchesInfo}:\n`; @@ -50,13 +50,13 @@ export async function execRulesMatchingCommand(roomId: string, event: any, mjoln let ruleKind: string = rule.kind; switch (ruleKind) { case EntityType.RULE_USER: - ruleKind = 'user'; + ruleKind = "user"; break; case EntityType.RULE_SERVER: - ruleKind = 'server'; + ruleKind = "server"; break; case EntityType.RULE_ROOM: - ruleKind = 'room'; + ruleKind = "room"; break; } html += `
      • ${htmlEscape(ruleKind)} (${htmlEscape(rule.recommendation ?? "")}): ${htmlEscape(rule.entity)} (${htmlEscape(rule.reason)})
      • `; @@ -85,7 +85,7 @@ export async function execDumpRulesCommand(roomId: string, event: any, mjolnir: hasLists = true; let hasRules = false; - const shortcodeInfo = list.listShortcode ? ` (shortcode: ${list.listShortcode})` : ''; + const shortcodeInfo = list.listShortcode ? ` (shortcode: ${list.listShortcode})` : ""; html += `${list.roomId}${shortcodeInfo}:
          `; text += `${list.roomRef}${shortcodeInfo}:\n`; diff --git a/src/commands/ImportCommand.ts b/src/commands/ImportCommand.ts index 50d02c49..64adddb4 100644 --- a/src/commands/ImportCommand.ts +++ b/src/commands/ImportCommand.ts @@ -22,7 +22,7 @@ import PolicyList from "../models/PolicyList"; // !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; + 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); @@ -35,22 +35,25 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo const state = await mjolnir.client.getRoomState(importRoomId); for (const stateEvent of state) { - const content = stateEvent['content'] || {}; + const content = stateEvent["content"] || {}; if (!content || Object.keys(content).length === 0) continue; - if (stateEvent['type'] === 'm.room.member' && stateEvent['state_key'] !== '') { + if (stateEvent["type"] === "m.room.member" && stateEvent["state_key"] !== "") { // Member event - check for ban - if (content['membership'] === 'ban') { - const reason = content['reason'] || ''; + 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); + 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++; } - } else if (stateEvent['type'] === 'm.room.server_acl' && stateEvent['state_key'] === '') { + } 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']) { + if (!content["deny"]) continue; + for (const server of content["deny"]) { const reason = ""; await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`); @@ -63,6 +66,6 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo const message = `Imported ${importedRules} rules to ban list`; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; await mjolnir.client.sendMessage(roomId, reply); } diff --git a/src/commands/KickCommand.ts b/src/commands/KickCommand.ts index 4fad1333..19857e6d 100644 --- a/src/commands/KickCommand.ts +++ b/src/commands/KickCommand.ts @@ -46,9 +46,9 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln rooms = [await mjolnir.client.resolveRoom(parts[3])]; reasonIndex = 4; } - reason = parts.slice(reasonIndex).join(' ') || ''; + reason = parts.slice(reasonIndex).join(" ") || ""; } - if (!reason) reason = ''; + if (!reason) reason = ""; for (const protectedRoomId of rooms) { const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]); @@ -57,7 +57,12 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln const target = member.membershipFor; if (kickRule.test(target)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${target} in ${protectedRoomId}`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "KickCommand", + `Removing ${target} in ${protectedRoomId}`, + protectedRoomId, + ); if (!mjolnir.config.noop) { try { @@ -65,14 +70,23 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln return mjolnir.client.kickUser(target, protectedRoomId, reason); }); } catch (e) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${target}: ${e}`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "KickCommand", + `An error happened while trying to kick ${target}: ${e}`, + ); } } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${target} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "KickCommand", + `Tried to kick ${target} in ${protectedRoomId} but the bot is running in no-op mode.`, + protectedRoomId, + ); } } } } - return mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + return mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/MakeRoomAdminCommand.ts b/src/commands/MakeRoomAdminCommand.ts index 2a52c2c4..917a7f62 100644 --- a/src/commands/MakeRoomAdminCommand.ts +++ b/src/commands/MakeRoomAdminCommand.ts @@ -23,20 +23,20 @@ export async function execMakeRoomAdminCommand(roomId: string, event: any, mjoln if (!mjolnir.config.admin?.enableMakeRoomAdminCommand || !isAdmin) { const message = "Either the command is disabled or I am not running as homeserver administrator."; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } let err = await mjolnir.makeUserRoomAdmin(await mjolnir.client.resolveRoom(parts[3]), parts[4]); - if (err instanceof Error || typeof (err) === "string") { + if (err instanceof Error || typeof err === "string") { const errMsg = "Failed to process command:"; - const message = typeof (err) === "string" ? `${errMsg}: ${err}` : `${errMsg}: ${err.message}`; + const message = typeof err === "string" ? `${errMsg}: ${err}` : `${errMsg}: ${err.message}`; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } else { - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } } diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts index 4262d7e3..90002d67 100644 --- a/src/commands/ProtectionsCommands.ts +++ b/src/commands/ProtectionsCommands.ts @@ -23,7 +23,7 @@ import { isListSetting } from "../protections/ProtectionSettings"; 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'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } catch (e) { LogService.error("ProtectionsCommands", extractRequestError(e)); @@ -37,7 +37,7 @@ export async function execEnableProtection(roomId: string, event: any, mjolnir: enum ConfigAction { Set, Add, - Remove + Remove, } /* @@ -55,7 +55,7 @@ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], ac return `Unknown protection ${protectionName}`; } - const defaultSettings = protection.settings + const defaultSettings = protection.settings; const settingName = settingParts[0]; const stringValue = parts[1]; @@ -186,8 +186,7 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni html += "
        "; - if (!anySettings) - html = text = "No settings found"; + if (!anySettings) html = text = "No settings found"; const reply = RichReply.createFor(roomId, event, text, html); reply["msgtype"] = "m.notice"; @@ -197,18 +196,18 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni // !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'], '✅'); + 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); + 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)'; + const emoji = enabledProtections.includes(protectionName) ? "🟢 (enabled)" : "🔴 (disabled)"; html += `
        • ${emoji} ${protectionName} - ${protection.description}
        • `; text += `* ${emoji} ${protectionName} - ${protection.description}\n`; } diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index 4601c6c9..c6a12a6c 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -21,7 +21,7 @@ import { Permalinks } from "@vector-im/matrix-bot-sdk"; // !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 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]); @@ -33,21 +33,25 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo // 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'); + const processingReactionId = await mjolnir.client.unstableApis.addReactionToEvent( + roomId, + event["event_id"], + "In Progress", + ); - if (userId[0] !== '@') { + if (userId[0] !== "@") { // Assume it's a permalink const parsed = Permalinks.parseUrl(parts[2]); 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'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); + await mjolnir.client.redactEvent(roomId, processingReactionId, "done processing command"); return; } const targetRoomIds = roomAlias ? [roomAlias] : mjolnir.protectedRoomsTracker.getProtectedRooms(); await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, limit); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); - await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); + await mjolnir.client.redactEvent(roomId, processingReactionId, "done processing"); } diff --git a/src/commands/SetDefaultBanListCommand.ts b/src/commands/SetDefaultBanListCommand.ts index f1bf8f71..cddd2f0f 100644 --- a/src/commands/SetDefaultBanListCommand.ts +++ b/src/commands/SetDefaultBanListCommand.ts @@ -22,7 +22,7 @@ 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); + 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); @@ -32,5 +32,5 @@ export async function execSetDefaultListCommand(roomId: string, event: any, mjol } await mjolnir.client.setAccountData(DEFAULT_LIST_EVENT_TYPE, { shortcode }); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/SetPowerLevelCommand.ts b/src/commands/SetPowerLevelCommand.ts index 3c8099c0..7a76552f 100644 --- a/src/commands/SetPowerLevelCommand.ts +++ b/src/commands/SetPowerLevelCommand.ts @@ -23,17 +23,24 @@ export async function execSetPowerLevelCommand(roomId: string, event: any, mjoln const level = Math.round(Number(parts[3])); const inRoom = parts[4]; - let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : mjolnir.protectedRoomsTracker.getProtectedRooms(); + let targetRooms = inRoom + ? [await mjolnir.client.resolveRoom(inRoom)] + : mjolnir.protectedRoomsTracker.getProtectedRooms(); for (const targetRoomId of targetRooms) { try { await mjolnir.client.setUserPowerLevel(target, targetRoomId, level); } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${target} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); + const message = e.message || (e.body ? e.body.error : ""); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "SetPowerLevelCommand", + `Failed to set power level of ${target} to ${level} in ${targetRoomId}: ${message}`, + targetRoomId, + ); LogService.error("SetPowerLevelCommand", extractRequestError(e)); } } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/SetupDecentralizedReportingCommand.ts b/src/commands/SetupDecentralizedReportingCommand.ts index e710e4dd..cc7d371f 100644 --- a/src/commands/SetupDecentralizedReportingCommand.ts +++ b/src/commands/SetupDecentralizedReportingCommand.ts @@ -7,9 +7,12 @@ const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; // !mjolnir rooms setup reporting export async function execSetupProtectedRoom(commandRoomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { // For the moment, we only accept a subcommand `reporting`. - if (parts[4] !== 'reporting') { - await mjolnir.client.sendNotice(commandRoomId, "Invalid subcommand for `rooms setup subcommand`, expected one of \"reporting\""); - await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '❌'); + if (parts[4] !== "reporting") { + await mjolnir.client.sendNotice( + commandRoomId, + 'Invalid subcommand for `rooms setup subcommand`, expected one of "reporting"', + ); + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event["event_id"], "❌"); return; } const protectedRoomId = await mjolnir.client.joinRoom(parts[3]); @@ -20,7 +23,11 @@ export async function execSetupProtectedRoom(commandRoomId: string, event: any, // A backup of the previous state in case we need to rollback. let previousState: /* previous content */ any | /* there was no previous content */ null; try { - previousState = await mjolnir.client.getRoomStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + previousState = await mjolnir.client.getRoomStateEvent( + protectedRoomId, + EVENT_MODERATED_BY, + EVENT_MODERATED_BY, + ); } catch (ex) { previousState = null; } @@ -43,7 +50,12 @@ export async function execSetupProtectedRoom(commandRoomId: string, event: any, // is bad. Attempt to rollback. try { if (previousState) { - await mjolnir.client.sendStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY, previousState); + await mjolnir.client.sendStateEvent( + protectedRoomId, + EVENT_MODERATED_BY, + EVENT_MODERATED_BY, + previousState, + ); } else { await mjolnir.client.redactEvent(protectedRoomId, eventId, "Rolling back incomplete MSC3215 setup"); } @@ -52,10 +64,9 @@ export async function execSetupProtectedRoom(commandRoomId: string, event: any, throw ex; } } - } catch (ex) { mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "execSetupProtectedRoom", ex.message); - await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '❌'); + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event["event_id"], "❌"); } - await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event["event_id"], "✅"); } diff --git a/src/commands/ShutdownRoomCommand.ts b/src/commands/ShutdownRoomCommand.ts index f24c4c83..7e7b0ebf 100644 --- a/src/commands/ShutdownRoomCommand.ts +++ b/src/commands/ShutdownRoomCommand.ts @@ -26,11 +26,11 @@ export async function execShutdownRoomCommand(roomId: string, event: any, mjolni if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } await mjolnir.shutdownSynapseRoom(await mjolnir.client.resolveRoom(target), reason); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 572ce7b0..2b947773 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -29,13 +29,13 @@ enum Action { Ban = "ban", Mute = "mute", Unmute = "unmute", - Show = "show" + Show = "show", } -type Result = {ok: T} | {error: string}; +type Result = { ok: T } | { error: string }; type userId = string; -type Summary = { succeeded: userId[], failed: userId[] }; +type Summary = { succeeded: userId[]; failed: userId[] }; /** * Attempt to parse a `ParseEntry`, as provided by the shell-style parser, using a parsing function. @@ -50,7 +50,7 @@ type Summary = { succeeded: userId[], failed: userId[] }; */ function parseToken(name: string, token: ParseEntry, parser: (source: string) => Result): Result { if (!token) { - return { error: `Missing ${name}`}; + return { error: `Missing ${name}` }; } if (typeof token === "object") { if ("pattern" in token) { @@ -65,9 +65,9 @@ function parseToken(name: string, token: ParseEntry, parser: (source: string) const result = parser(token); if ("error" in result) { if (result.error) { - return { error: `Invalid ${name} ${htmlEscape(token)}: ${result.error}`}; + return { error: `Invalid ${name} ${htmlEscape(token)}: ${result.error}` }; } else { - return { error: `Invalid ${name} ${htmlEscape(token)}`}; + return { error: `Invalid ${name} ${htmlEscape(token)}` }; } } return result; @@ -81,16 +81,16 @@ function parseToken(name: string, token: ParseEntry, parser: (source: string) * @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} { +function getTokenAsString(name: string, token: ParseEntry): { error: string } | { ok: string } { if (!token) { - return { error: `Missing ${name}`}; + 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 { ok: token }; } return { error: `Invalid ${name}` }; } @@ -99,23 +99,28 @@ function getTokenAsString(name: string, token: ParseEntry): {error: string}|{ok: 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.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'], '✅'); + 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")}`; +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 + text, }; } @@ -126,23 +131,28 @@ function formatResult(action: string, targetRoomId: string, recentJoins: Join[], // - 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> { +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 => { + const minDateResult = parseToken("/", dateOrDurationToken, (source) => { // Attempt to parse `/` as a date. let maybeMinDate = new Date(source); - let maybeMaxAgeMS = Date.now() - maybeMinDate.getTime() as number; + let maybeMaxAgeMS = (Date.now() - maybeMinDate.getTime()) as number; if (!Number.isNaN(maybeMaxAgeMS)) { - return { ok: { minDate: maybeMinDate, maxAgeMS: 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 { ok: { minDate: new Date(Date.now() - maybeMaxAgeMS), maxAgeMS: maybeMaxAgeMS } }; } return { error: "" }; }); @@ -152,7 +162,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni const { minDate, maxAgeMS } = minDateResult.ok!; // Parse max entries. - const maxEntriesResult = parseToken("", maxEntriesToken, source => { + const maxEntriesResult = parseToken("", maxEntriesToken, (source) => { const maybeMaxEntries = Number.parseInt(source, 10); if (Number.isNaN(maybeMaxEntries)) { return { error: "Not a number" }; @@ -166,24 +176,24 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni const maxEntries = maxEntriesResult.ok!; // Attempt to parse `` as Action. - const actionResult = parseToken("", actionToken, source => { + const actionResult = parseToken("", actionToken, (source) => { for (let key in Action) { const maybeAction = Action[key as keyof typeof Action]; if (key === source) { - return { ok: maybeAction } + return { ok: maybeAction }; } else if (maybeAction === source) { - return { ok: maybeAction } + return { ok: maybeAction }; } } - return {error: `Expected one of ${JSON.stringify(Action)}`}; - }) + 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(); + const rooms: Set = new Set(); let reasonParts: string[] | undefined; const protectedRooms = new Set(mjolnir.protectedRoomsTracker.getProtectedRooms()); for (let token of optionalTokens) { @@ -202,7 +212,11 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni } 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)}.`); + return mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "SinceCommand", + `This room is not protected: ${htmlEscape(roomId)}.`, + ); } rooms.add(roomId); continue; @@ -220,12 +234,16 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni }; } - const progressEventId = await mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '⏳'); + 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: []}; + let { html, text } = await (async () => { + let results: Summary = { succeeded: [], failed: [] }; const recentJoins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); switch (action) { @@ -259,7 +277,11 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni return formatResult("ban", targetRoomId, recentJoins, results); } case Action.Mute: { - const powerLevels = await mjolnir.client.getRoomStateEvent(targetRoomId, "m.room.power_levels", "") as {users: Record}; + const powerLevels = (await mjolnir.client.getRoomStateEvent( + targetRoomId, + "m.room.power_levels", + "", + )) as { users: Record }; for (let join of recentJoins) { powerLevels.users[join.userId] = -1; @@ -279,7 +301,11 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni 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}; + 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]; @@ -307,10 +333,17 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni } await mjolnir.client.redactEvent(destinationRoomId, progressEventId); - return {ok: undefined}; + return { ok: undefined }; } -function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: number, minDate: Date, maxAgeMS: number, recentJoins: Join[]): {html: string, text: string} { +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: "", @@ -327,6 +360,6 @@ function makeJoinStatus(mjolnir: Mjolnir, targetRoomId: string, maxEntries: numb } 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")}` - } + text: `${recentJoins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}`, + }; } diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index 11c4edb8..ce0fadbe 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -27,11 +27,11 @@ const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); export async function execStatusCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { switch (parts[0]) { case undefined: - case 'mjolnir': + case "mjolnir": return showMjolnirStatus(roomId, event, mjolnir); - case 'joins': + case "joins": return showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 1)); - case 'protection': + case "protection": return showProtectionStatus(roomId, event, mjolnir, parts.slice(/* ["protection"] */ 1)); default: throw new Error(`Invalid status command: ${htmlEscape(parts[0])}`); @@ -85,10 +85,14 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) { text += "* None\n"; } html += "
        "; - } - const subscribedLists = mjolnir.policyListManager.lists.filter(list => !mjolnir.explicitlyProtectedRooms.includes(list.roomId)); + }; + const subscribedLists = mjolnir.policyListManager.lists.filter( + (list) => !mjolnir.explicitlyProtectedRooms.includes(list.roomId), + ); renderPolicyLists("Subscribed policy lists", subscribedLists); - const subscribedAndProtectedLists = mjolnir.policyListManager.lists.filter(list => mjolnir.explicitlyProtectedRooms.includes(list.roomId)); + const subscribedAndProtectedLists = mjolnir.policyListManager.lists.filter((list) => + mjolnir.explicitlyProtectedRooms.includes(list.roomId), + ); renderPolicyLists("Subscribed and protected policy lists", subscribedAndProtectedLists); const reply = RichReply.createFor(roomId, event, text, html); @@ -124,12 +128,12 @@ async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir 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 maxEntriesArg = (args[2] = "200"); const { html, text } = await (async () => { if (!targetRoomAliasOrId) { return { html: "Missing arg: room id", - text: "Missing arg: `room id`" + text: "Missing arg: `room id`", }; } const maxAgeMS = parseDuration(maxAgeArg); @@ -137,14 +141,14 @@ async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: M 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 = { @@ -160,8 +164,8 @@ async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: M } catch (ex) { return { html: `Cannot resolve room ${htmlEscape(targetRoomAliasOrId)}.`, - text: `Cannot resolve room \`${targetRoomAliasOrId}\`.` - } + text: `Cannot resolve room \`${targetRoomAliasOrId}\`.`, + }; } const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); const htmlFragments = []; @@ -173,11 +177,10 @@ async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: M } 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")}` - } + text: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}`, + }; })(); const reply = RichReply.createFor(destinationRoomId, event, text, html); reply["msgtype"] = "m.notice"; return mjolnir.client.sendMessage(destinationRoomId, reply); } - diff --git a/src/commands/SuspendCommand.ts b/src/commands/SuspendCommand.ts index 68906db0..33bef2d7 100644 --- a/src/commands/SuspendCommand.ts +++ b/src/commands/SuspendCommand.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Mjolnir} from "../Mjolnir"; -import {RichReply} from "@vector-im/matrix-bot-sdk"; +import { Mjolnir } from "../Mjolnir"; +import { RichReply } from "@vector-im/matrix-bot-sdk"; export async function execSuspendCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const target = parts[2]; @@ -24,15 +24,15 @@ export async function execSuspendCommand(roomId: string, event: any, mjolnir: Mj if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; await mjolnir.client.sendMessage(roomId, reply); return; } await mjolnir.suspendSynapseUser(target); - const msg = `User ${target} has been suspended.` + const msg = `User ${target} has been suspended.`; const confirmation = RichReply.createFor(roomId, event, msg, msg); - confirmation['msgtype'] = "m.notice"; - await mjolnir.client.sendMessage(roomId, confirmation) - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} \ No newline at end of file + confirmation["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, confirmation); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); +} diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index abaa9fce..d72ac3b4 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -28,11 +28,16 @@ interface Arguments { } // Exported for tests -export async function parseArguments(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { +export async function parseArguments( + roomId: string, + event: any, + mjolnir: Mjolnir, + parts: string[], +): Promise { let defaultShortcode: string | null = null; try { const data: { shortcode: string } = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE); - defaultShortcode = data['shortcode']; + defaultShortcode = data["shortcode"]; } catch (e) { LogService.warn("UnbanBanCommand", "Non-fatal error getting default ban list"); LogService.warn("UnbanBanCommand", extractRequestError(e)); @@ -49,17 +54,19 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni const arg = parts[argumentIndex++]; if (!arg) break; if (["user", "room", "server"].includes(arg.toLowerCase())) { - if (arg.toLowerCase() === 'user') ruleType = RULE_USER; - if (arg.toLowerCase() === 'room') ruleType = RULE_ROOM; - if (arg.toLowerCase() === 'server') ruleType = RULE_SERVER; - } else if (!entity && (arg[0] === '@' || arg[0] === '!' || arg[0] === '#' || arg.includes("*"))) { + if (arg.toLowerCase() === "user") ruleType = RULE_USER; + if (arg.toLowerCase() === "room") ruleType = RULE_ROOM; + if (arg.toLowerCase() === "server") ruleType = RULE_SERVER; + } else if (!entity && (arg[0] === "@" || arg[0] === "!" || arg[0] === "#" || arg.includes("*"))) { entity = arg; if (arg.startsWith("@") && !ruleType) ruleType = RULE_USER; else if (arg.startsWith("#") && !ruleType) ruleType = RULE_ROOM; else if (arg.startsWith("!") && !ruleType) ruleType = RULE_ROOM; else if (!ruleType) ruleType = RULE_SERVER; } else if (!list) { - const foundList = mjolnir.policyListManager.lists.find(b => b.listShortcode.toLowerCase() === arg.toLowerCase()); + const foundList = mjolnir.policyListManager.lists.find( + (b) => b.listShortcode.toLowerCase() === arg.toLowerCase(), + ); if (foundList !== undefined) { list = foundList; } @@ -86,7 +93,7 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni } if (!list) { - list = mjolnir.policyListManager.lists.find(b => b.listShortcode.toLowerCase() === defaultShortcode) || null; + list = mjolnir.policyListManager.lists.find((b) => b.listShortcode.toLowerCase() === defaultShortcode) || null; } let replyMessage: string | null = null; @@ -119,7 +126,7 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni if (!bits) return; // error already handled await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir unban [apply:t/f] @@ -131,21 +138,39 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol const unbanUserFromRooms = async () => { const rule = new MatrixGlob(bits.entity); - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "UnbanBanCommand", + "Unbanning users that match glob: " + bits.entity, + ); 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, "UnbanBanCommand", `Found ${members.length} banned user(s)`); + const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["ban"], undefined); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "UnbanBanCommand", + `Found ${members.length} banned user(s)`, + ); for (const member of members) { const target = member.membershipFor; - if (member.membership !== 'ban') continue; + if (member.membership !== "ban") continue; if (rule.test(target)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${target} in ${protectedRoomId}`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "UnbanBanCommand", + `Unbanning ${target} in ${protectedRoomId}`, + protectedRoomId, + ); if (!mjolnir.config.noop) { await mjolnir.client.unbanUser(target, protectedRoomId); } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${target} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "UnbanBanCommand", + `Attempted to unban ${target} in ${protectedRoomId} but Mjolnir is running in no-op mode`, + protectedRoomId, + ); } unbannedSomeone = true; @@ -154,19 +179,27 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol } if (unbannedSomeone) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "UnbanBanCommand", + `Syncing lists to ensure no users were accidentally unbanned`, + ); await mjolnir.protectedRoomsTracker.syncLists(); } }; if (USER_RULE_TYPES.includes(bits.ruleType!)) { mjolnir.unlistedUserRedactionHandler.removeUser(bits.entity); - if (bits.reason === 'true') { + if (bits.reason === "true") { await unbanUserFromRooms(); } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", "Running unban without `unban true` will not override existing room level bans"); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "UnbanBanCommand", + "Running unban without `unban true` will not override existing room level bans", + ); } } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/UnsuspendCommand.ts b/src/commands/UnsuspendCommand.ts index b364498f..d4250349 100644 --- a/src/commands/UnsuspendCommand.ts +++ b/src/commands/UnsuspendCommand.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Mjolnir} from "../Mjolnir"; -import {RichReply} from "@vector-im/matrix-bot-sdk"; +import { Mjolnir } from "../Mjolnir"; +import { RichReply } from "@vector-im/matrix-bot-sdk"; export async function execUnsuspendCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const target = parts[2]; @@ -24,15 +24,15 @@ export async function execUnsuspendCommand(roomId: string, event: any, mjolnir: if (!isAdmin) { const message = "I am not a Synapse administrator, or the endpoint is blocked"; const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; + reply["msgtype"] = "m.notice"; mjolnir.client.sendMessage(roomId, reply); return; } await mjolnir.unsuspendSynapseUser(target); - const msg = `User ${target}'s suspension has been reversed.` + const msg = `User ${target}'s suspension has been reversed.`; const confirmation = RichReply.createFor(roomId, event, msg, msg); - confirmation['msgtype'] = "m.notice"; - mjolnir.client.sendMessage(roomId, confirmation) - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} \ No newline at end of file + confirmation["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomId, confirmation); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); +} diff --git a/src/commands/WatchUnwatchCommand.ts b/src/commands/WatchUnwatchCommand.ts index 51459b44..55f38607 100644 --- a/src/commands/WatchUnwatchCommand.ts +++ b/src/commands/WatchUnwatchCommand.ts @@ -27,7 +27,7 @@ export async function execWatchCommand(roomId: string, event: any, mjolnir: Mjol mjolnir.client.sendMessage(roomId, reply); return; } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir unwatch @@ -40,5 +40,5 @@ export async function execUnwatchCommand(roomId: string, event: any, mjolnir: Mj mjolnir.client.sendMessage(roomId, reply); return; } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/config.ts b/src/config.ts index 13b9f1d9..38057a10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -54,8 +54,8 @@ export interface IHealthConfig { * If unspecified, use 0.0.0.0 (accessible by any host). */ address: string; - } - } + }; + }; } /** @@ -112,7 +112,7 @@ export interface IConfig { displayReports: boolean; admin?: { enableMakeRoomAdminCommand?: boolean; - } + }; commands: { allowNoPrefix: boolean; additionalPrefixes: string[]; @@ -166,7 +166,7 @@ export interface IConfig { * If unspecified, use 0.0.0.0 (accessible by any host). */ address: string; - } + }; }; web: { enabled: boolean; @@ -174,12 +174,12 @@ export interface IConfig { address: string; abuseReporting: { enabled: boolean; - } + }; ruleServer?: { enabled: boolean; - } - } - nsfwSensitivity: number + }; + }; + nsfwSensitivity: number; /** * Config options only set at runtime. Try to avoid using the objects @@ -205,7 +205,7 @@ const defaultConfig: IConfig = { password: "", }, dataPath: "/data/storage", - acceptInvitesFromSpace: '!noop:example.org', + acceptInvitesFromSpace: "!noop:example.org", autojoinOnlyIfManager: true, recordIgnoredInvites: false, managementRoom: "!noop:example.org", @@ -229,8 +229,8 @@ const defaultConfig: IConfig = { protections: { wordlist: { words: [], - minutesBeforeTrusting: 20 - } + minutesBeforeTrusting: 20, + }, }, health: { healthz: { @@ -246,7 +246,7 @@ const defaultConfig: IConfig = { port: 9090, address: "0.0.0.0", endpoint: "/metrics", - } + }, }, web: { enabled: false, @@ -259,10 +259,9 @@ const defaultConfig: IConfig = { enabled: false, }, }, - nsfwSensitivity: .6, + nsfwSensitivity: 0.6, // Needed to make the interface happy. - RUNTIME: { - }, + RUNTIME: {}, }; export function getDefaultConfig(): IConfig { @@ -274,8 +273,8 @@ export function getDefaultConfig(): IConfig { * @param argv An arguments vector sourced from `process.argv`. * @returns The path if one was provided or undefined. */ -function configPathFromArguments(argv: string[]): undefined|string { - const configOptionIndex = argv.findIndex(arg => arg === "--mjolnir-config"); +function configPathFromArguments(argv: string[]): undefined | string { + const configOptionIndex = argv.findIndex((arg) => arg === "--mjolnir-config"); if (configOptionIndex > 0) { const configOptionPath = argv.at(configOptionIndex + 1); if (!configOptionPath) { @@ -320,15 +319,19 @@ export function getProvisionedMjolnirConfig(managementRoomId: string): IConfig { "backgroundDelayMS", ]; const configTemplate = read(); // we use the standard bot config as a template for every provisioned mjolnir. - const unusedKeys = Object.keys(configTemplate).filter(key => !allowedKeys.includes(key)); + const unusedKeys = Object.keys(configTemplate).filter((key) => !allowedKeys.includes(key)); if (unusedKeys.length > 0) { - LogService.warn("config", "The config provided for provisioned mjolnirs contains keys which are not used by the appservice.", unusedKeys); + LogService.warn( + "config", + "The config provided for provisioned mjolnirs contains keys which are not used by the appservice.", + unusedKeys, + ); } const config = Config.util.extendDeep( getDefaultConfig(), allowedKeys.reduce((existingConfig: any, key: string) => { - return { ...existingConfig, [key]: configTemplate[key as keyof IConfig] } - }, {}) + return { ...existingConfig, [key]: configTemplate[key as keyof IConfig] }; + }, {}), ); config.managementRoom = managementRoomId; diff --git a/src/health/healthz.ts b/src/health/healthz.ts index 99c3712f..4313de7c 100644 --- a/src/health/healthz.ts +++ b/src/health/healthz.ts @@ -22,7 +22,7 @@ import { IConfig } from "../config"; export class Healthz { private healthCode: number; - constructor(private config: IConfig) { } + constructor(private config: IConfig) {} public set isHealthy(val: boolean) { this.healthCode = val ? this.config.health.healthz.healthyStatus : this.config.health.healthz.unhealthyStatus; @@ -38,7 +38,10 @@ export class Healthz { res.end(`health code: ${this.healthCode}`); }); server.listen(this.config.health.healthz.port, this.config.health.healthz.address, () => { - LogService.info("Healthz", `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}`); + LogService.info( + "Healthz", + `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}`, + ); }); } } diff --git a/src/index.ts b/src/index.ts index 2a558b8b..f63690f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,18 +20,19 @@ import { Healthz } from "./health/healthz"; import { LogLevel, - LogService, MatrixAuth, + LogService, + MatrixAuth, MatrixClient, PantalaimonClient, - RichConsoleLogger, RustSdkCryptoStorageProvider, - SimpleFsStorageProvider + RichConsoleLogger, + RustSdkCryptoStorageProvider, + SimpleFsStorageProvider, } from "@vector-im/matrix-bot-sdk"; import { read as configRead } from "./config"; import { Mjolnir } from "./Mjolnir"; import { initializeSentry, initializeGlobalPerformanceMetrics, patchMatrixClient } from "./utils"; - (async function () { const config = configRead(); @@ -57,33 +58,38 @@ import { initializeSentry, initializeGlobalPerformanceMetrics, patchMatrixClient let bot: Mjolnir | null = null; try { - const storagePath = path.isAbsolute(config.dataPath) ? config.dataPath : path.join(__dirname, '../', config.dataPath); + const storagePath = path.isAbsolute(config.dataPath) + ? config.dataPath + : path.join(__dirname, "../", config.dataPath); const storage = new SimpleFsStorageProvider(path.join(storagePath, "bot.json")); - const cryptoStorage = new RustSdkCryptoStorageProvider(storagePath, 0) + const cryptoStorage = new RustSdkCryptoStorageProvider(storagePath, 0); if (config.encryption.use && config.pantalaimon.use) { - throw Error('Cannot enable both pantalaimon and encryption at the same time. Remove one from the config.'); + throw Error("Cannot enable both pantalaimon and encryption at the same time. Remove one from the config."); } let client: MatrixClient; if (config.pantalaimon.use) { const pantalaimon = new PantalaimonClient(config.homeserverUrl, storage); - client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password); + client = await pantalaimon.createClientWithCredentials( + config.pantalaimon.username, + config.pantalaimon.password, + ); } else if (config.encryption.use) { const accessToken = await Promise.resolve(storage.readValue("access_token")); if (accessToken) { client = new MatrixClient(config.homeserverUrl, accessToken, storage, cryptoStorage); } else { - const auth = new MatrixAuth(config.homeserverUrl) - const tempClient = await auth.passwordLogin(config.encryption.username, config.encryption.password) + const auth = new MatrixAuth(config.homeserverUrl); + const tempClient = await auth.passwordLogin(config.encryption.username, config.encryption.password); client = new MatrixClient(config.homeserverUrl, tempClient.accessToken, storage, cryptoStorage); } try { - LogService.info("index", "Preparing encrypted client...") + LogService.info("index", "Preparing encrypted client..."); await client.crypto.prepare(); } catch (e) { - LogService.error("Index", `Error preparing encrypted client ${e}`) - throw e + LogService.error("Index", `Error preparing encrypted client ${e}`); + throw e; } } else { client = new MatrixClient(config.homeserverUrl, config.accessToken, storage); diff --git a/src/models/AccessControlUnit.ts b/src/models/AccessControlUnit.ts index aebf32bc..6ce479b6 100644 --- a/src/models/AccessControlUnit.ts +++ b/src/models/AccessControlUnit.ts @@ -34,12 +34,15 @@ class ListRuleCache { /** * Glob rules always have to be scanned against every entity. */ - private readonly globRules: Map = new Map(); + 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); + private readonly literalRules: Map< + string /* the string literal */, + ListRule[] /* the rules matching this literal */ + > = new Map(); + private readonly listUpdateListener: (list: PolicyList, changes: ListRuleChange[]) => void; constructor( /** @@ -59,7 +62,7 @@ class ListRuleCache { * @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 { + public getAnyRuleForEntity(entity: string): ListRule | null { const literalRule = this.literalRules.get(entity); if (literalRule !== undefined) { return literalRule[0]; @@ -78,7 +81,7 @@ class ListRuleCache { * @param list A PolicyList. */ public watchList(list: PolicyList): void { - list.on('PolicyList.update', this.listUpdateListener); + list.on("PolicyList.update", this.listUpdateListener); const rules = list.rulesOfKind(this.entityType, this.recommendation); rules.forEach(this.internRule, this); } @@ -89,7 +92,7 @@ class ListRuleCache { * @param list A PolicyList. */ public unwatchList(list: PolicyList): void { - list.removeListener('PolicyList.update', this.listUpdateListener); + list.removeListener("PolicyList.update", this.listUpdateListener); const rules = list.rulesOfKind(this.entityType, this.recommendation); rules.forEach(this.uninternRule, this); } @@ -105,7 +108,7 @@ class ListRuleCache { * 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]); + return [...this.literalRules.values(), ...this.globRules.values()].map((rules) => rules[0]); } /** @@ -121,7 +124,9 @@ class ListRuleCache { 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); + const newEntry = entry.filter( + (internedRule) => internedRule.sourceEvent.event_id !== rule.sourceEvent.event_id, + ); if (newEntry.length === 0) { map.delete(rule.entity); } else { @@ -152,7 +157,7 @@ class ListRuleCache { } else { map.set(rule.entity, [rule]); } - } + }; if (rule.isGlob()) { addRuleToMap(this.globRules); } else { @@ -204,8 +209,8 @@ export enum Access { * 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, + readonly outcome: Access; + readonly rule?: ListRule; } /** @@ -216,7 +221,7 @@ export default class AccessControlUnit { 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] + private readonly caches = [this.userBans, this.serverBans, this.userAllows, this.serverAllows]; constructor(policyLists: PolicyList[]) { policyLists.forEach(this.watchList, this); @@ -268,7 +273,7 @@ export default class AccessControlUnit { // 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 } + return { outcome: Access.NotAllowed }; } // Now check if the entity is banned. const banRule = bannedCache.getAnyRuleForEntity(entity); @@ -289,21 +294,26 @@ export default class AccessControlUnit { const allowedServers = this.serverAllows.allRules; // Allowed servers (allow). if (allowedServers.length === 0) { - acl.allowServer('*'); + 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.`) + 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.' + 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); diff --git a/src/models/ListRule.ts b/src/models/ListRule.ts index 3f52fbdd..768d3183 100644 --- a/src/models/ListRule.ts +++ b/src/models/ListRule.ts @@ -70,7 +70,7 @@ const RECOMMENDATION_BAN_VARIANTS = [ // Stable Recommendation.Ban, // Unstable prefix, for compatibility. - "org.matrix.mjolnir.ban" + "org.matrix.mjolnir.ban", ]; /** @@ -78,22 +78,22 @@ const RECOMMENDATION_BAN_VARIANTS = [ */ const RECOMMENDATION_OPINION_VARIANTS: string[] = [ // Unstable - Recommendation.Opinion + Recommendation.Opinion, ]; const RECOMMENDATION_ALLOW_VARIANTS: string[] = [ // Unstable - Recommendation.Allow -] + Recommendation.Allow, +]; export const OPINION_MIN = -100; export const OPINION_MAX = +100; interface MatrixStateEvent { - type: string, - content: any, - event_id: string, - state_key: string, + type: string; + content: any; + event_id: string; + state_key: string; } /** @@ -125,7 +125,8 @@ export abstract class ListRule { * 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) { + public readonly recommendation: Recommendation | null, + ) { this.glob = new MatrixGlob(entity); } @@ -152,25 +153,25 @@ export abstract class ListRule { public static parse(event: MatrixStateEvent): ListRule | null { // Parse common fields. // If a field is ill-formed, discard the rule. - const content = event['content']; + const content = event["content"]; if (!content || typeof content !== "object") { return null; } - const entity = content['entity']; + const entity = content["entity"]; if (!entity || typeof entity !== "string") { return null; } - const recommendation = content['recommendation']; + const recommendation = content["recommendation"]; if (!recommendation || typeof recommendation !== "string") { return null; } - const reason = content['reason'] || ''; + const reason = content["reason"] || ""; if (typeof reason !== "string") { return null; } - let type = event['type']; + let type = event["type"]; let kind; if (USER_RULE_TYPES.includes(type)) { kind = EntityType.RULE_USER; @@ -186,7 +187,7 @@ export abstract class ListRule { if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) { return new ListRuleBan(event, entity, reason, kind); } else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) { - let opinion = content['opinion']; + let opinion = content["opinion"]; if (!Number.isInteger(opinion)) { return null; } @@ -223,14 +224,14 @@ export class ListRuleBan extends ListRule { */ kind: EntityType, ) { - super(sourceEvent, entity, reason, kind, Recommendation.Ban) + super(sourceEvent, entity, reason, kind, Recommendation.Ban); } } /** * A rule representing an "allow". */ - export class ListRuleAllow extends ListRule { +export class ListRuleAllow extends ListRule { constructor( /** * The event source for the rule. @@ -249,7 +250,7 @@ export class ListRuleBan extends ListRule { */ kind: EntityType, ) { - super(sourceEvent, entity, reason, kind, Recommendation.Allow) + super(sourceEvent, entity, reason, kind, Recommendation.Allow); } } @@ -279,7 +280,7 @@ export class ListRuleOpinion extends ListRule { * 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 + public readonly opinion: number, ) { super(sourceEvent, entity, reason, kind, Recommendation.Opinion); if (!Number.isInteger(opinion)) { diff --git a/src/models/PolicyList.ts b/src/models/PolicyList.ts index 68d60cd5..4326870a 100644 --- a/src/models/PolicyList.ts +++ b/src/models/PolicyList.ts @@ -14,9 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { extractRequestError, LogLevel, LogService, Permalinks, RoomCreateOptions, UserID } from "@vector-im/matrix-bot-sdk"; +import { + extractRequestError, + LogLevel, + LogService, + Permalinks, + RoomCreateOptions, + UserID, +} from "@vector-im/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 { + 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"; @@ -43,38 +61,41 @@ export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode"; export enum ChangeType { Added = "ADDED", Removed = "REMOVED", - Modified = "MODIFIED" + Modified = "MODIFIED", } export interface ListRuleChange { - readonly changeType: ChangeType, + 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, + 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, + 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, + 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, + readonly previousState?: any; } 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 + on( + event: "PolicyList.update", + listener: (list: PolicyList, changes: ListRuleChange[], revision: Revision) => void, + ): this; + emit(event: "PolicyList.update", list: PolicyList, changes: ListRuleChange[], revision: Revision): boolean; } /** @@ -105,7 +126,7 @@ class PolicyList extends EventEmitter { /** 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] + 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. @@ -113,7 +134,7 @@ class PolicyList extends EventEmitter { * 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'; + private static readonly EVENT_RULE_ANNOTATION_KEY = "org.matrix.mjolnir.annotation.rule"; /** * An ID that represents the current version of the list state. @@ -133,7 +154,11 @@ class PolicyList extends EventEmitter { * @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) { + constructor( + public readonly roomId: string, + public readonly roomRef: string, + private client: MatrixSendClient, + ) { super(); this.batcher = new UpdateBatcher(this); } @@ -150,32 +175,32 @@ class PolicyList extends EventEmitter { client: MatrixSendClient, shortcode: string, invite: string[], - createRoomOptions: RoomCreateOptions = {} + createRoomOptions: RoomCreateOptions = {}, ): Promise { const powerLevels: { [key: string]: any } = { - "ban": 50, - "events": { + ban: 50, + events: { "m.room.name": 100, "m.room.power_levels": 100, }, - "events_default": 50, // non-default - "invite": 0, - "kick": 50, - "notifications": { - "room": 20, + events_default: 50, // non-default + invite: 0, + kick: 50, + notifications: { + room: 20, }, - "redact": 50, - "state_default": 50, - "users": { + redact: 50, + state_default: 50, + users: { [await client.getUserId()]: 100, - ...invite.reduce((users, mxid) => ({...users, [mxid]: 50 }), {}), + ...invite.reduce((users, mxid) => ({ ...users, [mxid]: 50 }), {}), }, - "users_default": 0, + users_default: 0, }; const finalRoomCreateOptions: RoomCreateOptions = { // Support for MSC3784. creation_content: { - type: PolicyList.ROOM_TYPE + type: PolicyList.ROOM_TYPE, }, preset: "public_chat", invite, @@ -183,26 +208,28 @@ class PolicyList extends EventEmitter { { type: SHORTCODE_EVENT_TYPE, state_key: "", - content: {shortcode: shortcode} - } + content: { shortcode: shortcode }, + }, ], power_level_content_override: powerLevels, - ...createRoomOptions + ...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.`); + 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 + return listRoomId; } /** * The code that can be used to refer to this banlist in Mjolnir commands. */ public get listShortcode(): string { - return this.shortcode || ''; + return this.shortcode || ""; } /** @@ -239,7 +266,7 @@ class PolicyList extends EventEmitter { * @returns The active ListRules for the ban list of that kind. */ public rulesOfKind(kind: string, recommendation?: Recommendation): ListRule[] { - const rules: ListRule[] = [] + const rules: ListRule[] = []; const stateKeyMap = this.state.get(kind); if (stateKeyMap) { for (const event of stateKeyMap.values()) { @@ -259,10 +286,12 @@ class PolicyList extends EventEmitter { public set listShortcode(newShortcode: string) { const currentShortcode = this.shortcode; this.shortcode = newShortcode; - this.client.sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, '', { shortcode: this.shortcode }).catch(err => { - LogService.error("PolicyList", extractRequestError(err)); - if (this.shortcode === newShortcode) this.shortcode = currentShortcode; - }); + this.client + .sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, "", { shortcode: this.shortcode }) + .catch((err) => { + LogService.error("PolicyList", extractRequestError(err)); + if (this.shortcode === newShortcode) this.shortcode = currentShortcode; + }); } public get serverRules(): ListRule[] { @@ -305,11 +334,11 @@ class PolicyList extends EventEmitter { // 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)) - ] + ...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)); + return this.rulesOfKind(ruleTypeOf(entity)).filter((rule) => rule.isMatch(entity)); } } @@ -321,11 +350,11 @@ class PolicyList extends EventEmitter { */ public async banEntity(ruleType: string, entity: string, reason?: string): Promise { // '@' at the beginning of state keys is reserved. - const stateKey = ruleType === RULE_USER ? '_' + entity.substring(1) : entity; + const stateKey = ruleType === RULE_USER ? "_" + entity.substring(1) : entity; const event_id = await this.client.sendStateEvent(this.roomId, ruleType, stateKey, { entity, recommendation: Recommendation.Ban, - reason: reason || '', + reason: reason || "", }); this.updateForEvent(event_id); } @@ -353,20 +382,25 @@ class PolicyList extends EventEmitter { 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?? + 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))); - } + 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; @@ -393,30 +427,30 @@ class PolicyList extends EventEmitter { * @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[] } { + 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; + 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'])) { + if (event["state_key"] === "" || !ALL_RULE_TYPES.includes(event["type"])) { continue; } let kind: EntityType | null = null; - if (USER_RULE_TYPES.includes(event['type'])) { + if (USER_RULE_TYPES.includes(event["type"])) { kind = RULE_USER; - } else if (ROOM_RULE_TYPES.includes(event['type'])) { + } else if (ROOM_RULE_TYPES.includes(event["type"])) { kind = RULE_ROOM; - } else if (SERVER_RULE_TYPES.includes(event['type'])) { + } else if (SERVER_RULE_TYPES.includes(event["type"])) { kind = RULE_SERVER; } else { continue; // invalid/unknown } - const previousState = this.getState(kind, event['state_key']); + 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). @@ -424,16 +458,28 @@ class PolicyList extends EventEmitter { // 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'])) { + 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'])) { + } 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'])) { + } else if ( + kind === RULE_SERVER && + SERVER_RULE_TYPES.indexOf(event["type"]) > SERVER_RULE_TYPES.indexOf(previousState["type"]) + ) { logObsoleteRule(); continue; } @@ -442,12 +488,12 @@ class PolicyList extends EventEmitter { // 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); + 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']) { + } else if (previousState["event_id"] === event["event_id"]) { + if (event["unsigned"]?.["redacted_because"]) { return ChangeType.Removed; } else { // Nothing has changed. @@ -455,7 +501,7 @@ class PolicyList extends EventEmitter { } } 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) { + if (Object.keys(event["content"]).length === 0) { return ChangeType.Removed; } else { return ChangeType.Modified; @@ -465,16 +511,21 @@ class PolicyList extends EventEmitter { // Clear out any events that we were informed about via updateForEvent. if (changeType !== null) { - this.batchedEvents.delete(event.event_id) + 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; + 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 } : {} + changeType, + event, + sender, + rule: previousState[PolicyList.EVENT_RULE_ANNOTATION_KEY], + ...(previousState ? { previousState } : {}), }); // Event has no content and cannot be parsed as a ListRule. continue; @@ -487,19 +538,28 @@ class PolicyList extends EventEmitter { } event[PolicyList.EVENT_RULE_ANNOTATION_KEY] = rule; if (changeType) { - changes.push({ rule, changeType, event, sender: event.sender, ...previousState ? { previousState } : {} }); + 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); + 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."); + 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 }; } @@ -532,9 +592,7 @@ class UpdateBatcher { 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) { - - } + constructor(private readonly banList: PolicyList) {} /** * Reset the state for the next batch. @@ -553,8 +611,8 @@ class UpdateBatcher { 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) + 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(); @@ -588,7 +646,6 @@ class UpdateBatcher { * We use a ULID to work out whether a revision supersedes another. */ export class Revision { - /** * Ensures that ULIDs are monotonic. */ @@ -663,26 +720,26 @@ export class PolicyListManager { if (permalink.roomIdOrAlias.startsWith("!")) { // if we only have a room id, see if there's an alias we can use to get any possible // via servers - const alias = await this.mjolnir.client.getPublishedAlias(permalink.roomIdOrAlias) + const alias = await this.mjolnir.client.getPublishedAlias(permalink.roomIdOrAlias); if (alias) { - const roomInformation = await this.mjolnir.client.lookupRoomAlias(alias) - roomId = permalink.roomIdOrAlias - viaServers = roomInformation.residentServers + const roomInformation = await this.mjolnir.client.lookupRoomAlias(alias); + roomId = permalink.roomIdOrAlias; + viaServers = roomInformation.residentServers; } else { - roomId = permalink.roomIdOrAlias - viaServers = permalink.viaServers + roomId = permalink.roomIdOrAlias; + viaServers = permalink.viaServers; } } else { - const roomInfo = await this.mjolnir.client.lookupRoomAlias(permalink.roomIdOrAlias) - roomId = roomInfo.roomId - viaServers = roomInfo.residentServers + const roomInfo = await this.mjolnir.client.lookupRoomAlias(permalink.roomIdOrAlias); + roomId = roomInfo.roomId; + viaServers = roomInfo.residentServers; } if (!joinedRooms.includes(roomId)) { await this.mjolnir.client.joinRoom(roomId, viaServers); } - if (this.policyLists.find(b => b.roomId === roomId)) { + if (this.policyLists.find((b) => b.roomId === roomId)) { // 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, @@ -691,7 +748,7 @@ export class PolicyListManager { return null; } - const newRef = Permalinks.forRoom(roomId, viaServers) + const newRef = Permalinks.forRoom(roomId, viaServers); const list = await this.addPolicyList(roomId, newRef); await this.storeWatchedPolicyLists(); @@ -707,7 +764,7 @@ export class PolicyListManager { const roomId = await this.mjolnir.client.resolveRoom(permalink.roomIdOrAlias); this.failedStartupWatchListRefs.delete(roomRef); - const list = this.policyLists.find(b => b.roomId === roomId) || null; + const list = this.policyLists.find((b) => b.roomId === roomId) || null; if (list) { this.policyLists.splice(this.policyLists.indexOf(list), 1); this.mjolnir.ruleServer?.unwatch(list); @@ -730,13 +787,17 @@ export class PolicyListManager { watchedListsEvent = await this.mjolnir.client.getAccountData(WATCHED_LISTS_EVENT_TYPE); } catch (e) { if (e.statusCode === 404) { - LogService.warn('Mjolnir', "Couldn't find account data for Mjolnir's watched lists, assuming first start.", extractRequestError(e)); + LogService.warn( + "Mjolnir", + "Couldn't find account data for Mjolnir's watched lists, assuming first start.", + extractRequestError(e), + ); } else { throw e; } } - for (const roomRef of (watchedListsEvent?.references || [])) { + for (const roomRef of watchedListsEvent?.references || []) { const permalink = Permalinks.parseUrl(roomRef); if (!permalink.roomIdOrAlias) continue; @@ -745,8 +806,16 @@ export class PolicyListManager { roomId = await this.mjolnir.client.resolveRoom(permalink.roomIdOrAlias); } catch (ex) { // Let's not fail startup because of a problem resolving a room id or an alias. - LogService.warn('Mjolnir', 'Could not resolve policy list room, skipping for this run', permalink.roomIdOrAlias) - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir", `Room ${permalink.roomIdOrAlias} could **not** be resolved, perhaps a server is down? Skipping this room. If this is a recurring problem, please consider removing this room.`); + LogService.warn( + "Mjolnir", + "Could not resolve policy list room, skipping for this run", + permalink.roomIdOrAlias, + ); + await this.mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "Mjolnir", + `Room ${permalink.roomIdOrAlias} could **not** be resolved, perhaps a server is down? Skipping this room. If this is a recurring problem, please consider removing this room.`, + ); this.failedStartupWatchListRefs.add(roomRef); continue; } @@ -767,7 +836,7 @@ export class PolicyListManager { * that the user (or someone else) will eventually resolve. */ private async storeWatchedPolicyLists() { - let list = this.policyLists.map(b => b.roomRef); + let list = this.policyLists.map((b) => b.roomRef); for (let entry of this.failedStartupWatchListRefs) { list.push(entry); } @@ -795,7 +864,9 @@ export class PolicyListManager { } try { - const accountData: { warned: boolean } | null = await this.mjolnir.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId); + const accountData: { warned: boolean } | null = await this.mjolnir.client.getAccountData( + WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, + ); if (accountData && accountData.warned) { return; // already warned } @@ -803,7 +874,12 @@ export class PolicyListManager { // 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.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 }); } -} \ No newline at end of file +} diff --git a/src/models/RuleServer.ts b/src/models/RuleServer.ts index d70e7953..ccb111a3 100644 --- a/src/models/RuleServer.ts +++ b/src/models/RuleServer.ts @@ -13,14 +13,14 @@ 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. */ -import BanList, { ChangeType, ListRuleChange } from "./PolicyList" +import BanList, { ChangeType, ListRuleChange } from "./PolicyList"; import * as crypto from "crypto"; import { LogService } from "@vector-im/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'; +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. @@ -31,9 +31,8 @@ class EventRules { readonly roomId: string, readonly ruleServerRules: RuleServerRule[], // The token associated with when the event rules were created. - readonly token: number - ) { - } + readonly token: number, + ) {} } /** @@ -48,9 +47,9 @@ interface Checks { */ interface RuleServerRule { // A unique identifier for this rule. - readonly id: string + readonly id: string; // A description of a property that should be checked. - readonly checks: Checks + readonly checks: Checks; } /** @@ -121,7 +120,9 @@ export default class RuleServer { 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}.`); + 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) { @@ -145,7 +146,7 @@ export default class RuleServer { if (index > -1) { this.ruleStartsByToken[token].splice(index, 1); } - eventRules.ruleServerRules.map(rule => this.ruleStopsByToken[this.currentToken].push(rule.id)); + eventRules.ruleServerRules.map((rule) => this.ruleStopsByToken[this.currentToken].push(rule.id)); } /** @@ -154,25 +155,47 @@ export default class RuleServer { */ 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); + 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); + 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}`); + 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); + 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); + 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}`); + 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); @@ -186,7 +209,7 @@ export default class RuleServer { * @param banList a BanList to watch for rule changes with. */ public watch(banList: PolicyList): void { - banList.on('PolicyList.update', this.banListUpdateListener); + banList.on("PolicyList.update", this.banListUpdateListener); } /** @@ -194,7 +217,7 @@ export default class RuleServer { * @param banList The BanList to unwatch. */ public unwatch(banList: PolicyList): void { - banList.removeListener('PolicyList.update', this.banListUpdateListener); + banList.removeListener("PolicyList.update", this.banListUpdateListener); const listRules = this.rulesByEvent.get(banList.roomId); this.nextToken(); if (listRules) { @@ -222,7 +245,12 @@ export default class RuleServer { * @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 } { + 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. @@ -233,88 +261,98 @@ export default class RuleServer { } else { return policyStore.slice(token).flat(); } - } - const [serverId, since] = sinceToken ? sinceToken.split('::') : [null, null]; + }; + 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(), + start: updatesSince(null, this.ruleStartsByToken) + .map((e: EventRules) => e.ruleServerRules) + .flat(), stop: updatesSince(null, this.ruleStopsByToken), since: this.since, - reset: true - } + 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(), + 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. -*/ + * 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 } + return { literal }; } function makeGlob(glob: string) { - return { glob } + return { glob }; } function makeServerGlob(server: string) { - return { glob: `:${server}` } + return { glob: `:${server}` }; } function makeRule(checks: Checks) { return { id: crypto.randomUUID(), - checks: checks - } + 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) + 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) + 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) + 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 [] + LogService.info("RuleServer", `Ignoring unsupported policy rule type ${policyRule.kind}`); + return []; } } diff --git a/src/models/ServerAcl.ts b/src/models/ServerAcl.ts index 30cf69ef..9989261b 100644 --- a/src/models/ServerAcl.ts +++ b/src/models/ServerAcl.ts @@ -28,9 +28,7 @@ export class ServerAcl { private deniedServers: Set = new Set(); private allowIps = false; - public constructor(public readonly homeserver: string) { - - } + public constructor(public readonly homeserver: string) {} /** * Checks the ACL for any entries that might ban ourself. @@ -40,7 +38,7 @@ export class ServerAcl { // 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[] = [] + const entries: string[] = []; for (const server of this.deniedServers) { const glob = new MatrixGlob(server); if (!glob.test(this.homeserver)) { @@ -103,9 +101,9 @@ export class ServerAcl { public matches(acl: any): boolean { if (!acl) return false; - const allow = acl['allow']; - const deny = acl['deny']; - const ips = acl['allow_ip_literals']; + 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 diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index a76b8171..d6ff7dad 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -24,71 +24,92 @@ 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 }[] } } = {}; + private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number; eventId: string }[] } } = {}; private recentlyBanned: string[] = []; settings = { - maxPerMinute: new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE) + maxPerMinute: new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE), }; public get name(): string { - return 'BasicFloodingProtection'; + 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."; + 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 async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (!this.lastEvents[roomId]) this.lastEvents[roomId] = {}; const forRoom = this.lastEvents[roomId]; - 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'`); - event['origin_server_ts'] = (new Date()).getTime(); + 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'`, + ); + 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; for (const prevEvent of forUser) { - if ((new Date()).getTime() - prevEvent.originServerTs > 60000) continue; // not important + if (new Date().getTime() - prevEvent.originServerTs > 60000) continue; // not important 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); + 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"); + await mjolnir.client.banUser(event["sender"], roomId, "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 mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "BasicFlooding", + `Tried to ban ${event["sender"]} in ${roomId} but Mjolnir is running in no-op mode`, + roomId, + ); } - if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted) - mjolnir.unlistedUserRedactionHandler.addUser(event['sender']); - this.recentlyBanned.push(event['sender']); // flag to reduce spam + if (this.recentlyBanned.includes(event["sender"])) return; // already handled (will be redacted) + mjolnir.unlistedUserRedactionHandler.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)) { + for (const eventId of forUser.map((e) => e.eventId)) { await mjolnir.client.redactEvent(roomId, 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 mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "BasicFlooding", + `Tried to redact messages for ${event["sender"]} in ${roomId} but Mjolnir is running in no-op mode`, + roomId, + ); } // Free up some memory now that we're ready to handle it elsewhere - forUser = forRoom[event['sender']] = []; // reset the user's list + forUser = forRoom[event["sender"]] = []; // reset the user's list } // 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); + forUser.splice(0, forUser.length - this.settings.maxPerMinute.value * 2 - 1); } } } diff --git a/src/protections/DetectFederationLag.ts b/src/protections/DetectFederationLag.ts index da53350d..b0ebe96d 100644 --- a/src/protections/DetectFederationLag.ts +++ b/src/protections/DetectFederationLag.ts @@ -44,10 +44,10 @@ export const LAG_STATE_EVENT = "org.mjolnir.monitoring.lag"; */ type HistogramSettings = { // The width of a bucket, in ms. - bucketDurationMS: number, + bucketDurationMS: number; // The number of buckets. bucketNumber: number; -} +}; /** * A histogram with time as x and some arbitrary value T as y. @@ -71,14 +71,14 @@ class TimedHistogram { */ protected buckets: { start: Date; - events: T[] + events: T[]; }[]; /** * Construct an empty TimedHistogram */ constructor(private settings: HistogramSettings) { - this.buckets = [] + this.buckets = []; } /** @@ -103,7 +103,7 @@ class TimedHistogram { // Otherwise, initialize an entry, then prune columns that are too old. this.buckets.push({ start: now, - events: [event] + events: [event], }); this.trimBuckets(this.settings, now); } @@ -160,10 +160,7 @@ class Stats { } 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.min = this.max = this.mean = this.median = values[0]; this.stddev = 0; return; } @@ -190,15 +187,15 @@ class Stats { } } - public round(): { min: number, max: number, mean: number, median: number, stddev: number, length: number } { + 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 - } + length: this.length, + }; } } @@ -290,14 +287,14 @@ class ServerInfo { * will remain active until the value decreases below `exitWarningZone`. */ type WarningThresholds = { - enterWarningZone: number, - exitWarningZone: number -} + enterWarningZone: number; + exitWarningZone: number; +}; enum AlertDiff { Start, Stop, - NoChange + NoChange, } /** @@ -346,10 +343,13 @@ class RoomInfo { constructor(now: Date) { this.serverLags = new Map(); - this.totalLag = new ServerInfo({ - bucketDurationMS: DEFAULT_BUCKET_DURATION_MS, - bucketNumber: DEFAULT_BUCKET_NUMBER - }, now); + this.totalLag = new ServerInfo( + { + bucketDurationMS: DEFAULT_BUCKET_DURATION_MS, + bucketNumber: DEFAULT_BUCKET_NUMBER, + }, + now, + ); } /** @@ -361,7 +361,13 @@ class RoomInfo { * @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 { + pushLag( + serverId: string, + lag: number, + settings: HistogramSettings, + thresholds: WarningThresholds, + now: Date = new Date(), + ): AlertDiff { this.latestMessage = now; // Update per-server lag. @@ -492,23 +498,41 @@ export class DetectFederationLag extends Protection { // 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), + 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), + 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), + 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), + 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), + 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), + 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), @@ -531,7 +555,7 @@ export class DetectFederationLag extends Protection { this.settings.bucketNumber.removeAllListeners(); } public get name(): string { - return 'DetectFederationLag'; + 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.`; @@ -558,12 +582,12 @@ export class DetectFederationLag extends Protection { // Room is ignored. return; } - const sender = event['sender'] as string; + const sender = event["sender"] as string; if (typeof sender !== "string") { // Ill-formed event. return; } - if (sender === await mjolnir.client.getUserId()) { + if (sender === (await mjolnir.client.getUserId())) { // Let's not create loops. return; } @@ -573,7 +597,7 @@ export class DetectFederationLag extends Protection { return; } - const origin = event['origin_server_ts'] as number; + const origin = event["origin_server_ts"] as number; if (typeof origin !== "number" || isNaN(origin)) { // Ill-formed event. return; @@ -591,18 +615,17 @@ export class DetectFederationLag extends Protection { this.lagPerRoom.set(roomId, roomInfo); } - const localDomain = new UserID(await mjolnir.client.getUserId()).domain + 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 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) { @@ -618,8 +641,10 @@ export class DetectFederationLag extends Protection { // Check whether an alarm needs to be raised! const isLocalDomainOnAlert = roomInfo.isServerOnAlert(localDomain); - if (roomInfo.alerts > this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value - || isLocalDomainOnAlert) { + if ( + roomInfo.alerts > this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value || + isLocalDomainOnAlert + ) { // Raise the alarm! if (!roomInfo.latestAlertStart) { roomInfo.latestAlertStart = now; @@ -627,23 +652,35 @@ export class DetectFederationLag extends Protection { 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)}.`); + /* 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()], + const warnStateEventId = await mjolnir.client.sendStateEvent( + mjolnir.managementRoomId, + LAG_STATE_EVENT, roomId, - // We need to round the stats, as Matrix doesn't support floating-point - // numbers in messages. - stats: stats?.round(), - since: roomInfo.latestAlertStart, - }); + { + 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) { + } 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` + /* 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; @@ -677,19 +714,21 @@ export class DetectFederationLag extends Protection { } private getOldestAcceptableData(now: Date): Date { - return new Date(now.getTime() - this.latestHistogramSettings.bucketDurationMS * this.latestHistogramSettings.bucketNumber) + 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: string[]): Promise<{html: string, text: string} | null> { + public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string; text: string } | null> { const roomId = subcommand[0] || "*"; const localDomain = new UserID(await mjolnir.client.getUserId()).domain; const annotatedStats = (roomInfo: RoomInfo) => { @@ -701,7 +740,9 @@ export class DetectFederationLag extends Protection { const numberOfServersOnAlert = roomInfo.alerts; if (isLocalDomainOnAlert) { (stats as any)["warning"] = "Local homeserver is lagging"; - } else if (numberOfServersOnAlert > this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value) { + } else if ( + numberOfServersOnAlert > this.settings.numberOfLaggingFederatedHomeserversEnterWarningZone.value + ) { (stats as any)["warning"] = `${numberOfServersOnAlert} homeservers are lagging`; } return stats; @@ -713,7 +754,7 @@ export class DetectFederationLag extends Protection { const result: any = {}; for (const [perRoomId, perRoomInfo] of this.lagPerRoom.entries()) { - const key = await mjolnir.client.getPublishedAlias(perRoomId) || perRoomId; + const key = (await mjolnir.client.getPublishedAlias(perRoomId)) || perRoomId; result[key] = annotatedStats(perRoomInfo); } text = JSON.stringify(result, null, 2); @@ -735,8 +776,8 @@ export class DetectFederationLag extends Protection { } } return { - text, - html - } + text, + html, + }; } } diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 31cfe033..026fa5de 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -20,7 +20,6 @@ import { LogLevel, LogService } from "@vector-im/matrix-bot-sdk"; import { isTrueJoinEvent } from "../utils"; export class FirstMessageIsImage extends Protection { - private justJoined: { [roomId: string]: string[] } = {}; private recentlyBanned: string[] = []; @@ -31,54 +30,71 @@ export class FirstMessageIsImage extends Protection { } public get name(): string { - return 'FirstMessageIsImageProtection'; + return "FirstMessageIsImageProtection"; } 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."; + 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." + ); } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (!this.justJoined[roomId]) this.justJoined[roomId] = []; - if (event['type'] === 'm.room.member') { + 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`); + this.justJoined[roomId].push(event["state_key"]); + LogService.info("FirstMessageIsImage", `Tracking ${event["state_key"]} in ${roomId} as just joined`); } return; // stop processing (membership event spam is another problem) } - if (event['type'] === 'm.room.message') { - const content = event['content'] || {}; - const msgtype = content['msgtype'] || 'm.text'; - const formattedBody = 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`); + LogService.info("FirstMessageIsImage", `${event["sender"]} is no longer considered suspect`); this.justJoined[roomId].splice(idx, 1); } } diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index 63a265a7..dd6aa51e 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -25,7 +25,7 @@ import { Consequence } from "./consequence"; * Protections are guaranteed to be run before redaction handlers. */ export abstract class Protection { - abstract readonly name: string + abstract readonly name: string; abstract readonly description: string; enabled = false; readonly requiredStatePermissions: string[] = []; @@ -57,14 +57,20 @@ export abstract class Protection { * 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 { + async handleReport( + mjolnir: Mjolnir, + roomId: string, + reporterId: string, + event: any, + reason?: string, + ): Promise { // By default, do nothing. } /** * Return status information for `!mjolnir status ${protectionName}`. */ - async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{html: string, text: string} | null> { + async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string; text: string } | null> { // By default, protections don't have any status to show. return null; } diff --git a/src/protections/JoinWaveShortCircuit.ts b/src/protections/JoinWaveShortCircuit.ts index 4ad15798..2dab9e20 100644 --- a/src/protections/JoinWaveShortCircuit.ts +++ b/src/protections/JoinWaveShortCircuit.ts @@ -14,28 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Protection} from "./IProtection"; -import {Mjolnir} from "../Mjolnir"; -import {NumberProtectionSetting} from "./ProtectionSettings"; -import {LogLevel} from "@vector-im/matrix-bot-sdk"; +import { Protection } from "./IProtection"; +import { Mjolnir } from "../Mjolnir"; +import { NumberProtectionSetting } from "./ProtectionSettings"; +import { LogLevel } from "@vector-im/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"] + requiredStatePermissions = ["m.room.join_rules"]; private joinBuckets: { [roomId: string]: { - lastBucketStart: Date, - numberOfJoins: number, - } + lastBucketStart: Date; + numberOfJoins: number; + }; } = {}; settings = { maxPer: new NumberProtectionSetting(DEFAULT_MAX_PER_TIMESCALE), - timescaleMinutes: new NumberProtectionSetting(DEFAULT_TIMESCALE_MINUTES) + timescaleMinutes: new NumberProtectionSetting(DEFAULT_TIMESCALE_MINUTES), }; constructor() { @@ -47,11 +47,11 @@ export class JoinWaveShortCircuit extends Protection { } public get description(): string { - return "If X amount of users join in Y time, set the room to invite-only." + 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') { + if (event["type"] !== "m.room.member") { // Not a join/leave event. return; } @@ -61,17 +61,17 @@ export class JoinWaveShortCircuit extends Protection { return; } - const userId = event['state_key']; + const userId = event["state_key"]; if (!userId) { // Ill-formed event. return; } - const newMembership = event['content']['membership']; - const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || null; + 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") { + if (newMembership === "join" && prevMembership !== "join") { // A new join, fallthrough } else { return; @@ -81,30 +81,40 @@ export class JoinWaveShortCircuit extends Protection { if (!this.joinBuckets[roomId] || this.hasExpired(this.joinBuckets[roomId].lastBucketStart)) { this.joinBuckets[roomId] = { lastBucketStart: new Date(), - numberOfJoins: 0 - } + 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); + 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"}) + 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); + 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() + return new Date().getTime() - at.getTime() > this.timescaleMilliseconds(); } private timescaleMilliseconds(): number { - return (this.settings.timescaleMinutes.value * ONE_MINUTE) + return this.settings.timescaleMinutes.value * ONE_MINUTE; } - public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string, text: string }> { + public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string; text: string }> { const withExpired = subcommand.includes("withExpired"); const withStart = subcommand.includes("withStart"); @@ -120,7 +130,9 @@ export class JoinWaveShortCircuit extends Protection { } const startText = withStart ? ` (since ${bucket.lastBucketStart})` : ""; - const expiredText = isExpired ? ` (bucket expired since ${new Date(bucket.lastBucketStart.getTime() + this.timescaleMilliseconds())})` : ""; + 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`; @@ -131,6 +143,6 @@ export class JoinWaveShortCircuit extends Protection { return { html, text, - } + }; } } diff --git a/src/protections/LocalAbuseReports.ts b/src/protections/LocalAbuseReports.ts index d6bd4681..616bb1d6 100644 --- a/src/protections/LocalAbuseReports.ts +++ b/src/protections/LocalAbuseReports.ts @@ -30,9 +30,10 @@ const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; * Setup decentralized abuse reports in protected rooms. */ export class LocalAbuseReports extends Protection { - settings: { }; + settings: {}; public readonly name = "LocalAbuseReports"; - public readonly description = "Enables MSC3215-compliant web clients to send abuse reports to the moderator instead of the homeserver admin"; + public readonly description = + "Enables MSC3215-compliant web clients to send abuse reports to the moderator instead of the homeserver admin"; readonly requiredStatePermissions = [EVENT_MODERATED_BY]; /** @@ -45,7 +46,11 @@ export class LocalAbuseReports extends Protection { // Fetch the previous state of the room, to avoid overwriting any existing setup. let previousState: /* previous content */ any | /* there was no previous content */ null; try { - previousState = await mjolnir.client.getRoomStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + previousState = await mjolnir.client.getRoomStateEvent( + protectedRoomId, + EVENT_MODERATED_BY, + EVENT_MODERATED_BY, + ); } catch (ex) { previousState = null; } @@ -55,8 +60,13 @@ export class LocalAbuseReports extends Protection { return; } else { // There is a setup already, but it's not for us. Don't overwrite it. - let protectedRoomAliasOrId = await mjolnir.client.getPublishedAlias(protectedRoomId) || protectedRoomId; - mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "LocalAbuseReports", `Room ${protectedRoomAliasOrId} is already setup for decentralized abuse reports with bot ${previousState["user_id"]} and room ${previousState["room_id"]}, not overwriting automatically. To overwrite, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``); + let protectedRoomAliasOrId = + (await mjolnir.client.getPublishedAlias(protectedRoomId)) || protectedRoomId; + mjolnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "LocalAbuseReports", + `Room ${protectedRoomAliasOrId} is already setup for decentralized abuse reports with bot ${previousState["user_id"]} and room ${previousState["room_id"]}, not overwriting automatically. To overwrite, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``, + ); return; } } @@ -71,7 +81,11 @@ export class LocalAbuseReports extends Protection { user_id: userId, }); } catch (ex) { - mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", `Could not autoset protected room -> moderation room link: ${ex.message}. To set it manually, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``); + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "LocalAbuseReports", + `Could not autoset protected room -> moderation room link: ${ex.message}. To set it manually, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``, + ); return; } @@ -83,7 +97,11 @@ export class LocalAbuseReports extends Protection { } catch (ex) { // If the second `sendStateEvent` fails, we could end up with a room half setup, which // is bad. Attempt to rollback. - mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", `Could not autoset moderation room -> protected room link: ${ex.message}. To set it manually, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``); + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "LocalAbuseReports", + `Could not autoset moderation room -> protected room link: ${ex.message}. To set it manually, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``, + ); try { await mjolnir.client.redactEvent(protectedRoomId, eventId, "Rolling back incomplete MSC3215 setup"); } finally { @@ -95,4 +113,4 @@ export class LocalAbuseReports extends Protection { mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", ex.message); } } -} \ No newline at end of file +} diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index a14fa14e..7fdace4b 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -23,7 +23,6 @@ import { LRUCache } from "lru-cache"; export const DEFAULT_MAX_MENTIONS = 10; export class MentionSpam extends Protection { - private roomDisplaynameCache = new LRUCache({ ttl: 1000 * 60 * 24, // 24 minutes ttlAutopurge: true, @@ -38,7 +37,7 @@ export class MentionSpam extends Protection { } public get name(): string { - return 'MentionSpam'; + return "MentionSpam"; } public get description(): string { return "If a user posts many mentions, that message is redacted. No bans are issued."; @@ -50,46 +49,56 @@ export class MentionSpam extends Protection { return existing; } const profiles = await mjolnir.client.getJoinedRoomMembersWithProfiles(roomId); - const displaynames = (Object.values(profiles) - .map(v => v.display_name?.toLowerCase()) - .filter(v => typeof v === "string") as string[]) + const displaynames = ( + Object.values(profiles) + .map((v) => v.display_name?.toLowerCase()) + .filter((v) => typeof v === "string") as string[] + ) // Limit to displaynames with more than a few characters. - .filter(displayname => displayname.length > 2); + .filter((displayname) => displayname.length > 2); this.roomDisplaynameCache.set(roomId, displaynames); return displaynames; } - public checkMentions(body: unknown|undefined, htmlBody: unknown|undefined, mentionArray: unknown|undefined): boolean { + public checkMentions( + body: unknown | undefined, + htmlBody: unknown | undefined, + mentionArray: unknown | undefined, + ): boolean { const max = this.settings.maxMentions.value; if (Array.isArray(mentionArray) && mentionArray.length > max) { return true; } - if (typeof body === "string" && body.split('@').length - 1 > max) { + if (typeof body === "string" && body.split("@").length - 1 > max) { return true; } - if (typeof htmlBody === "string" && htmlBody.split('%40').length - 1 > max) { + if (typeof htmlBody === "string" && htmlBody.split("%40").length - 1 > max) { return true; } return false; } - public checkDisplaynameMentions(body: unknown|undefined, htmlBody: unknown|undefined, displaynames: string[]): boolean { + public checkDisplaynameMentions( + body: unknown | undefined, + htmlBody: unknown | undefined, + displaynames: string[], + ): boolean { const max = this.settings.maxMentions.value; const bodyWords = ((typeof body === "string" && body) || "").toLowerCase(); - if (displaynames.filter(s => bodyWords.includes(s.toLowerCase())).length > max) { + if (displaynames.filter((s) => bodyWords.includes(s.toLowerCase())).length > max) { return true; } const htmlBodyWords = decodeURIComponent((typeof htmlBody === "string" && htmlBody) || "").toLowerCase(); - if (displaynames.filter(s => htmlBodyWords.includes(s)).length > max) { + if (displaynames.filter((s) => htmlBodyWords.includes(s)).length > max) { return true; } return false; } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] === 'm.room.message') { - let content = event['content'] || {}; + if (event["type"] === "m.room.message") { + let content = event["content"] || {}; const explicitMentions = content["m.mentions"]?.user_ids; let hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions); @@ -100,13 +109,22 @@ export class MentionSpam extends Protection { } if (hitLimit) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MentionSpam", `Redacting event from ${event['sender']} for spamming mentions. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "MentionSpam", + `Redacting event from ${event["sender"]} for spamming mentions. ${Permalinks.forEvent(roomId, event["event_id"], [new UserID(await mjolnir.client.getUserId()).domain])}`, + ); // Redact the event if (!mjolnir.config.noop) { - await mjolnir.client.redactEvent(roomId, event['event_id'], "Message was detected as spam."); - mjolnir.unlistedUserRedactionHandler.addUser(event['sender']); + await mjolnir.client.redactEvent(roomId, event["event_id"], "Message was detected as spam."); + mjolnir.unlistedUserRedactionHandler.addUser(event["sender"]); } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MentionSpam", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "MentionSpam", + `Tried to redact ${event["event_id"]} in ${roomId} but Mjolnir is running in no-op mode`, + roomId, + ); } } } diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 68d1737e..622ce645 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -19,7 +19,6 @@ import { Mjolnir } from "../Mjolnir"; import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; export class MessageIsMedia extends Protection { - settings = {}; constructor() { @@ -27,29 +26,42 @@ export class MessageIsMedia extends Protection { } public get name(): string { - return 'MessageIsMediaProtection'; + return "MessageIsMediaProtection"; } public get description(): string { return "If a user posts an image or video, that message will be redacted. No bans are issued."; } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] === 'm.room.message') { - let content = event['content'] || {}; - const relation = content["m.relates_to"] + if (event["type"] === "m.room.message") { + let content = event["content"] || {}; + const relation = content["m.relates_to"]; if (relation?.["rel_type"] === "m.replace") { content = content?.["m.new_content"] ?? content; } - const msgtype = content['msgtype'] || 'm.text'; - const formattedBody = content['formatted_body'] || ''; - const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || msgtype === 'm.sticker' || formattedBody.toLowerCase().includes(' { - 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])}`); + 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])}`, + ); // Redact the event if (!mjolnir.config.noop) { - await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here"); + await mjolnir.client.redactEvent(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 mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "MessageIsVoice", + `Tried to redact ${event["event_id"]} in ${roomId} but Mjolnir is running in no-op mode`, + roomId, + ); } } } diff --git a/src/protections/NsfwProtection.ts b/src/protections/NsfwProtection.ts index 7bfaf8c9..a6f45b20 100644 --- a/src/protections/NsfwProtection.ts +++ b/src/protections/NsfwProtection.ts @@ -16,10 +16,9 @@ limitations under the License. import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; -import * as nsfw from 'nsfwjs'; -import {LogLevel, LogService} from "@vector-im/matrix-bot-sdk"; -import { node } from '@tensorflow/tfjs-node'; - +import * as nsfw from "nsfwjs"; +import { LogLevel, LogService } from "@vector-im/matrix-bot-sdk"; +import { node } from "@tensorflow/tfjs-node"; export class NsfwProtection extends Protection { settings = {}; @@ -35,28 +34,34 @@ export class NsfwProtection extends Protection { } public get name(): string { - return 'NsfwProtection'; + return "NsfwProtection"; } public get description(): string { - return "Scans all images sent into a protected room to determine if the image is " + - "NSFW. If it is, the image will automatically be redacted."; + return ( + "Scans all images sent into a protected room to determine if the image is " + + "NSFW. If it is, the image will automatically be redacted." + ); } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] === 'm.room.message') { - let content = JSON.stringify(event['content']); + if (event["type"] === "m.room.message") { + let content = JSON.stringify(event["content"]); if (!content.toLowerCase().includes("mxc")) { return; - } + } // try and grab a human-readable alias for more helpful management room output - const maybeAlias = await mjolnir.client.getPublishedAlias(roomId) - const room = maybeAlias ? maybeAlias : roomId + const maybeAlias = await mjolnir.client.getPublishedAlias(roomId); + const room = maybeAlias ? maybeAlias : roomId; const mxcs = content.match(/(mxc?:\/\/[^\s'"]+)/gim); if (!mxcs) { //something's gone wrong with the regex - await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "NSFWProtection", `Unable to find any mxcs in ${event["event_id"]} in ${room}`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "NSFWProtection", + `Unable to find any mxcs in ${event["event_id"]} in ${room}`, + ); return; } @@ -74,29 +79,32 @@ export class NsfwProtection extends Protection { const predictions = await this.model.classify(decodedImage); - for (const prediction of predictions) { if (["Hentai", "Porn"].includes(prediction["className"])) { if (prediction["probability"] > mjolnir.config.nsfwSensitivity) { try { await mjolnir.client.redactEvent(roomId, event["event_id"]); } catch (err) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "NSFWProtection", `There was an error redacting ${event["event_id"]} in ${room}: ${err}`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "NSFWProtection", + `There was an error redacting ${event["event_id"]} in ${room}: ${err}`, + ); } - let eventId = event["event_id"] - let body = `Redacted an image in ${room} ${eventId}` + let eventId = event["event_id"]; + let body = `Redacted an image in ${room} ${eventId}`; let formatted_body = `
        Redacted an image in ${room}
        ${eventId}
        ${room}
        -
        ` + `; const msg = { msgtype: "m.notice", body: body, format: "org.matrix.custom.html", - formatted_body: formatted_body + formatted_body: formatted_body, }; await mjolnir.client.sendMessage(mjolnir.managementRoomId, msg); - break + break; } } } @@ -104,4 +112,4 @@ export class NsfwProtection extends Protection { } } } -} \ No newline at end of file +} diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 9b843186..fda1bdbd 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -31,7 +31,7 @@ import { htmlEscape } from "../utils"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { LocalAbuseReports } from "./LocalAbuseReports"; -import {NsfwProtection} from "./NsfwProtection"; +import { NsfwProtection } from "./NsfwProtection"; import { MentionSpam } from "./MentionSpam"; const PROTECTIONS: Protection[] = [ @@ -45,7 +45,7 @@ const PROTECTIONS: Protection[] = [ new JoinWaveShortCircuit(), new LocalAbuseReports(), new NsfwProtection(), - new MentionSpam() + new MentionSpam(), ]; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; @@ -60,9 +60,7 @@ export class ProtectionManager { return this._protections; } - - constructor(private readonly mjolnir: Mjolnir) { - } + constructor(private readonly mjolnir: Mjolnir) {} /* * Take all the builtin protections, register them to set their enabled (or not) state and @@ -75,7 +73,11 @@ export class ProtectionManager { try { await this.registerProtection(protection); } catch (e) { - this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ProtectionManager", extractRequestError(e)); + this.mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "ProtectionManager", + extractRequestError(e), + ); } } } @@ -87,7 +89,7 @@ export class ProtectionManager { * @param protection The protection object we want to register */ public async registerProtection(protection: Protection) { - this._protections.set(protection.name, protection) + this._protections.set(protection.name, protection); let enabledProtections: { enabled: string[] } | null = null; try { @@ -139,7 +141,10 @@ export class ProtectionManager { * @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 { + public async setProtectionSettings( + protectionName: string, + changedSettings: { [setting: string]: any }, + ): Promise { const protection = this._protections.get(protectionName); if (protection === undefined) { return; @@ -151,8 +156,10 @@ export class ProtectionManager { 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 (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})`); @@ -161,7 +168,10 @@ export class ProtectionManager { } await this.mjolnir.client.sendStateEvent( - this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings + this.mjolnir.managementRoomId, + "org.matrix.mjolnir.setting", + protectionName, + validatedSettings, ); } @@ -169,7 +179,7 @@ export class ProtectionManager { * Make a list of the names of enabled protections and save them in a state event */ private async saveEnabledProtections() { - const protections = this.enabledProtections.map(p => p.name); + const protections = this.enabledProtections.map((p) => p.name); await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); } /* @@ -190,7 +200,7 @@ export class ProtectionManager { } public get enabledProtections(): Protection[] { - return [...this._protections.values()].filter(p => p.enabled); + return [...this._protections.values()].filter((p) => p.enabled); } /** @@ -203,7 +213,6 @@ export class ProtectionManager { return this._protections.get(protectionName) ?? null; } - /* * Disable a protection by name and remove it from the persistent list of enabled protections * @@ -230,10 +239,12 @@ export class ProtectionManager { * @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 } = {} + let savedSettings: { [setting: string]: any } = {}; try { savedSettings = await this.mjolnir.client.getRoomStateEvent( - this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName + this.mjolnir.managementRoomId, + "org.matrix.mjolnir.setting", + protectionName, ); } catch { // setting does not exist, return empty object @@ -241,29 +252,35 @@ export class ProtectionManager { } const settingDefinitions = this._protections.get(protectionName)?.settings ?? {}; - const validatedSettings: { [setting: string]: any } = {} + 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 + key in settingDefinitions && // is the datatype of this setting's value what we expect? - && typeof (settingDefinitions[key].value) === typeof (value) + typeof settingDefinitions[key].value === typeof value && // is this setting's value valid for the setting? - && settingDefinitions[key].validate(value) + 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}` + `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[]) { + private async handleConsequences( + protection: Protection, + roomId: string, + eventId: string, + sender: string, + consequences: Consequence[], + ) { for (const consequence of consequences) { try { if (consequence.name === "alert") { @@ -276,11 +293,12 @@ export class ProtectionManager { 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)})`; + 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, @@ -288,17 +306,21 @@ export class ProtectionManager { who: sender, room: roomId, types: [consequence.name], - } + }, }); } catch (e) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${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 + if (event["sender"] === (await this.mjolnir.client.getUserId())) return; // Ignore ourselves // Iterate all the enabled protections for (const protection of this.enabledProtections) { @@ -306,11 +328,14 @@ export class ProtectionManager { try { consequences = await protection.handleEvent(this.mjolnir, roomId, event); } catch (e) { - const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); + 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", extractRequestError(e)); - await this.mjolnir.client.sendNotice(this.mjolnir.managementRoomId, `There was an error processing an event through a protection (${protection.name}) - see log for details. Event: ${eventPermalink}`); + await this.mjolnir.client.sendNotice( + this.mjolnir.managementRoomId, + `There was an error processing an event through a protection (${protection.name}) - see log for details. Event: ${eventPermalink}`, + ); continue; } @@ -325,9 +350,8 @@ export class ProtectionManager { } } - private requiredProtectionPermissions(): Set { - return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) + return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()); } public async verifyPermissionsIn(roomId: string): Promise { @@ -348,13 +372,13 @@ export class ProtectionManager { 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 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); @@ -409,7 +433,7 @@ export class ProtectionManager { LogService.error("Mjolnir", extractRequestError(e)); errors.push({ roomId, - errorMessage: e.message || (e.body ? e.body.error : ''), + errorMessage: e.message || (e.body ? e.body.error : ""), errorKind: ERROR_KIND_FATAL, }); } @@ -417,7 +441,17 @@ export class ProtectionManager { return errors; } - private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { + private async handleReport({ + roomId, + reporterId, + event, + reason, + }: { + roomId: string; + reporterId: string; + event: any; + reason?: string; + }) { for (const protection of this.enabledProtections) { await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason); } diff --git a/src/protections/ProtectionSettings.ts b/src/protections/ProtectionSettings.ts index f8f2f37c..f95e7697 100644 --- a/src/protections/ProtectionSettings.ts +++ b/src/protections/ProtectionSettings.ts @@ -25,7 +25,7 @@ parseDuration["weeks"] = parseDuration["week"] = parseDuration["wk"]; parseDuration["months"] = parseDuration["month"]; parseDuration["years"] = parseDuration["year"]; -export class ProtectionSettingValidationError extends Error {}; +export class ProtectionSettingValidationError extends Error {} /* * @param TChange Type for individual pieces of data (e.g. `string`) @@ -33,7 +33,7 @@ export class ProtectionSettingValidationError extends Error {}; */ export class AbstractProtectionSetting extends EventEmitter { // the current value of this setting - value: TValue + value: TValue; /* * Deserialise a value for this setting type from a string @@ -89,7 +89,6 @@ export function isListSetting(object: any): object is AbstractProtectionListSett return object instanceof AbstractProtectionListSetting; } - export class StringProtectionSetting extends AbstractProtectionSetting { value = ""; fromString = (data: string): string => data; @@ -106,7 +105,7 @@ export class StringListProtectionSetting extends AbstractProtectionListSetting i !== data); + this.value = this.value.filter((i) => i !== data); return this.value; } } @@ -134,14 +133,10 @@ export class MXIDListProtectionSetting extends StringListProtectionSetting { } export class NumberProtectionSetting extends AbstractProtectionSetting { - min: number|undefined; - max: number|undefined; + min: number | undefined; + max: number | undefined; - constructor( - defaultValue: number, - min: number|undefined = undefined, - max: number|undefined = undefined - ) { + constructor(defaultValue: number, min: number | undefined = undefined, max: number | undefined = undefined) { super(); this.setValue(defaultValue); this.min = min; @@ -153,9 +148,9 @@ export class NumberProtectionSetting extends AbstractProtectionSetting { constructor( - defaultValue: number, - public readonly minMS: number|undefined = undefined, - public readonly maxMS: number|undefined = undefined + defaultValue: number, + public readonly minMS: number | undefined = undefined, + public readonly maxMS: number | undefined = undefined, ) { super(); this.setValue(defaultValue); @@ -179,8 +174,10 @@ export class DurationMSProtectionSetting extends AbstractProtectionSetting { + public async handleReport( + mjolnir: Mjolnir, + roomId: string, + reporterId: string, + event: any, + reason?: string, + ): Promise { if (!this.settings.mxids.value.includes(reporterId)) { // not a trusted user, we're not interested - return + return; } let reporters = this.recentReported.get(event.id); @@ -80,12 +86,10 @@ export class TrustedReporters extends Protection { await mjolnir.client.banUser(event.userId, roomId, "abuse detected"); } - if (met.length > 0) { await mjolnir.client.sendMessage(mjolnir.config.managementRoom, { msgtype: "m.notice", - body: `message ${event.id} reported by ${[...reporters].join(', ')}. ` - + `actions: ${met.join(', ')}` + body: `message ${event.id} reported by ${[...reporters].join(", ")}. ` + `actions: ${met.join(", ")}`, }); } } diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index d03976bc..1d54e887 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -21,10 +21,9 @@ import { LogLevel, LogService } from "@vector-im/matrix-bot-sdk"; import { isTrueJoinEvent } from "../utils"; export class WordList extends Protection { - settings = {}; - private justJoined: { [roomId: string]: { [username: string]: Date} } = {}; + private justJoined: { [roomId: string]: { [username: string]: Date } } = {}; private badWords?: RegExp; constructor() { @@ -32,16 +31,17 @@ export class WordList extends Protection { } public get name(): string { - return 'WordList'; + 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."; + return ( + "If a user posts a monitored word a set amount of time after joining, they " + + "will be banned from that room. This will not publish the ban to a ban list." + ); } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - - const content = event['content'] || {}; + const content = event["content"] || {}; const minsBeforeTrusting = mjolnir.config.protections.wordlist.minutesBeforeTrusting; if (minsBeforeTrusting > 0) { @@ -49,54 +49,60 @@ export class WordList extends Protection { // 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 (event["type"] === "m.room.member") { if (isTrueJoinEvent(event)) { 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][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"]]; } return; } } - if (event['type'] === 'm.room.message') { - const message = content['formatted_body'] || content['body'] || null; + if (event["type"] === "m.room.message") { + const message = content["formatted_body"] || content["body"] || null; if (!message) { return; } // Check conditions first if (minsBeforeTrusting > 0) { - const joinTime = this.justJoined[roomId][event['sender']] - if (joinTime) { // Disregard if the user isn't recently joined + 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 + LogService.info("WordList", `${event["sender"]} is no longer considered suspect`); + return; } - } else { // The user isn't in the recently joined users list, no need to keep // looking - return + return; } } if (!this.badWords) { // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping const escapeRegExp = (string: string) => { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; // Create a mega-regex from all the tiny words. - const words = mjolnir.config.protections.wordlist.words.filter(word => word.length !== 0).map(escapeRegExp); + 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.`); + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "WordList", + `Someone turned on the word list protection without configuring any words. Disabling.`, + ); this.enabled = false; return; } diff --git a/src/protections/consequence.ts b/src/protections/consequence.ts index 26c11c18..98889533 100644 --- a/src/protections/consequence.ts +++ b/src/protections/consequence.ts @@ -6,7 +6,10 @@ export class Consequence { * @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) { } + constructor( + public name: string, + public reason: string, + ) {} } export class ConsequenceAlert extends Consequence { diff --git a/src/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index effd00b2..0ef7926c 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -13,7 +13,7 @@ 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. */ -import { LogLevel, MatrixClient } from "@vector-im/matrix-bot-sdk" +import { LogLevel, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { ERROR_KIND_FATAL } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { redactUserMessagesIn } from "../utils"; @@ -28,12 +28,12 @@ export interface QueuedRedaction { * Called by the EventRedactionQueue. * @param client A MatrixClient to use to carry out the redaction. */ - redact(client: MatrixSendClient, managementRoom: ManagementRoomOutput): Promise + redact(client: MatrixSendClient, managementRoom: ManagementRoomOutput): Promise; /** * Used to test whether the redaction is the equivalent to another redaction. * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. */ - redactionEqual(redaction: QueuedRedaction): boolean + redactionEqual(redaction: QueuedRedaction): boolean; } /** @@ -49,7 +49,11 @@ export class RedactUserInRoom implements QueuedRedaction { } public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) { - await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`); + await managementRoom.logMessage( + LogLevel.DEBUG, + "Mjolnir", + `Redacting events from ${this.userId} in room ${this.roomId}.`, + ); await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]); } @@ -76,7 +80,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)); } /** @@ -108,7 +112,11 @@ 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 { + public async process( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput, + limitToRoomId?: string, + ): Promise { const errors: RoomUpdateError[] = []; const redact = async (currentBatch: QueuedRedaction[]) => { for (const redaction of currentBatch) { @@ -119,7 +127,7 @@ export class EventRedactionQueue { if (e.roomId && e.errorMessage && e.errorKind) { roomError = e; } else { - const message = e.message || (e.body ? e.body.error : ''); + const message = e.message || (e.body ? e.body.error : ""); roomError = { roomId: redaction.roomId, errorMessage: message, @@ -129,7 +137,7 @@ export class EventRedactionQueue { errors.push(roomError); } } - } + }; if (limitToRoomId) { // There might not actually be any queued redactions for this room. let queuedRedactions = this.toRedact.get(limitToRoomId); diff --git a/src/queues/ProtectedRoomActivityTracker.ts b/src/queues/ProtectedRoomActivityTracker.ts index 56e3a21d..88607b47 100644 --- a/src/queues/ProtectedRoomActivityTracker.ts +++ b/src/queues/ProtectedRoomActivityTracker.ts @@ -23,11 +23,11 @@ limitations under the License. * */ export class ProtectedRoomActivityTracker { - private protectedRoomActivities = new Map(); + private protectedRoomActivities = new Map(); /** * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first. */ - private activeRoomsCache: null|string[] = null + private activeRoomsCache: null | string[] = null; /** * Inform the tracker that a new room is being protected by Mjolnir. @@ -69,10 +69,9 @@ export class ProtectedRoomActivityTracker { public protectedRoomsByActivity(): string[] { if (!this.activeRoomsCache) { this.activeRoomsCache = [...this.protectedRoomActivities] - .sort((a, b) => b[1] - a[1]) - .map(pair => pair[0]); + .sort((a, b) => b[1] - a[1]) + .map((pair) => pair[0]); } return this.activeRoomsCache; } } - diff --git a/src/queues/ThrottlingQueue.ts b/src/queues/ThrottlingQueue.ts index 171ae082..95883ad9 100644 --- a/src/queues/ThrottlingQueue.ts +++ b/src/queues/ThrottlingQueue.ts @@ -47,7 +47,10 @@ export class ThrottlingQueue { * * @param delayMS The default delay between executing two tasks, in ms. */ - constructor(private mjolnir: Mjolnir, delayMS: number) { + constructor( + private mjolnir: Mjolnir, + delayMS: number, + ) { this.timeout = null; this.delayMS = delayMS; this._tasks = []; @@ -84,7 +87,7 @@ export class ThrottlingQueue { resolve(result); } catch (ex) { reject(ex); - }; + } }; this.tasks.push(wrapper); this.start(); @@ -180,8 +183,8 @@ export class ThrottlingQueue { } catch (ex) { await this.mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, - 'Error while executing task', - extractRequestError(ex) + "Error while executing task", + extractRequestError(ex), ); } finally { this.stop(); diff --git a/src/queues/UnlistedUserRedactionQueue.ts b/src/queues/UnlistedUserRedactionQueue.ts index 90fa50f0..bc690356 100644 --- a/src/queues/UnlistedUserRedactionQueue.ts +++ b/src/queues/UnlistedUserRedactionQueue.ts @@ -26,8 +26,7 @@ import { Mjolnir } from "../Mjolnir"; export class UnlistedUserRedactionQueue { private usersToRedact: Set = new Set(); - constructor() { - } + constructor() {} public addUser(userId: string) { this.usersToRedact.add(userId); @@ -42,17 +41,28 @@ export class UnlistedUserRedactionQueue { } public async handleEvent(roomId: string, event: any, mjolnir: Mjolnir) { - if (this.isUserQueued(event['sender'])) { - const permalink = Permalinks.forEvent(roomId, event['event_id']); + if (this.isUserQueued(event["sender"])) { + const permalink = Permalinks.forEvent(roomId, event["event_id"]); try { - LogService.info("AutomaticRedactionQueue", `Redacting event because the user is listed as bad: ${permalink}`) + 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']); + await mjolnir.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 mjolnir.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}`); + mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "AutomaticRedactionQueue", + `Unable to redact message: ${permalink}`, + ); LogService.warn("AutomaticRedactionQueue", extractRequestError(e)); } } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index d00c3bbc..e9d6d51e 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -18,8 +18,8 @@ import { PowerLevelAction } from "@vector-im/matrix-bot-sdk/lib/models/PowerLeve import { LogService, UserID } from "@vector-im/matrix-bot-sdk"; import { htmlToText } from "html-to-text"; import { htmlEscape } from "../utils"; -import { JSDOM } from 'jsdom'; -import { EventEmitter } from 'events'; +import { JSDOM } from "jsdom"; +import { EventEmitter } from "events"; import { Mjolnir } from "../Mjolnir"; /// Regexp, used to extract the action label from an action reaction @@ -50,14 +50,23 @@ const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; const NATURE_DESCRIPTIONS_LIST: [string, string][] = [ ["org.matrix.msc3215.abuse.nature.disagreement", "disagreement"], ["org.matrix.msc3215.abuse.nature.harassment", "harassment/bullying"], - ["org.matrix.msc3215.abuse.nature.csam", "child sexual abuse material [likely illegal, consider warning authorities]"], + [ + "org.matrix.msc3215.abuse.nature.csam", + "child sexual abuse material [likely illegal, consider warning authorities]", + ], ["org.matrix.msc3215.abuse.nature.hate_speech", "hate speech"], ["org.matrix.msc3215.abuse.nature.spam", "spam"], ["org.matrix.msc3215.abuse.nature.impersonation", "impersonation"], - ["org.matrix.msc3215.abuse.nature.doxxing", "non-consensual sharing of identifiable private information of a third party (doxxing)"], + [ + "org.matrix.msc3215.abuse.nature.doxxing", + "non-consensual sharing of identifiable private information of a third party (doxxing)", + ], ["org.matrix.msc3215.abuse.nature.violence", "threats of violence or death, either to self or others"], ["org.matrix.msc3215.abuse.nature.terrorism", "terrorism [likely illegal, consider warning authorities]"], - ["org.matrix.msc3215.abuse.nature.unwanted_sexual_advances", "unwanted sexual advances, sextortion, ... [possibly illegal, consider warning authorities]"], + [ + "org.matrix.msc3215.abuse.nature.unwanted_sexual_advances", + "unwanted sexual advances, sextortion, ... [possibly illegal, consider warning authorities]", + ], ["org.matrix.msc3215.abuse.nature.ncii", "non consensual intimate imagery, including revenge porn"], ["org.matrix.msc3215.abuse.nature.nsfw", "NSFW content (pornography, gore...) in a SFW room"], ["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"], @@ -137,10 +146,26 @@ export class ReportManager extends EventEmitter { * @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 }) { + 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 }); + return this.displayManager.displayReportAndUI({ + kind: Kind.SERVER_ABUSE_REPORT, + event, + reporterId, + reason, + moderationRoomId: this.mjolnir.managementRoomId, + }); } } @@ -154,13 +179,20 @@ export class ReportManager extends EventEmitter { } // Performance note: we should cache this event, see https://github.com/matrix-org/mjolnir/pull/379. - let eventModeratorOf = await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + let eventModeratorOf = await this.mjolnir.client.getRoomStateEvent( + this.mjolnir.managementRoomId, + EVENT_MODERATOR_OF, + roomId, + ); if (!eventModeratorOf) { LogService.warn("ReportManager", "Received a moderation request but we are not moderating that room"); return; } - if (eventModeratorOf["user_id"] !== await this.mjolnir.client.getUserId()) { - LogService.warn("ReportManager", "Received a moderation request but we are not the moderator bot for this room"); + if (eventModeratorOf["user_id"] !== (await this.mjolnir.client.getUserId())) { + LogService.warn( + "ReportManager", + "Received a moderation request but we are not the moderator bot for this room", + ); return; } @@ -177,14 +209,27 @@ export class ReportManager extends EventEmitter { // Fetch the report and act upon it. let event; try { - event = await this.mjolnir.client.getEvent(roomId, eventId) + event = await this.mjolnir.client.getEvent(roomId, eventId); } catch (ex) { - LogService.warn("ReportManager", "Received a moderation request with an event that we cannot read", roomId, eventId, ex); + LogService.warn( + "ReportManager", + "Received a moderation request with an event that we cannot read", + roomId, + eventId, + ex, + ); return; } this.emit("report.new", { roomId, reporterId, event: event, reason: comment }); if (this.mjolnir.config.displayReports) { - await this.displayManager.displayReportAndUI({ kind: Kind.MODERATION_REQUEST, nature, event, reporterId, reason: comment, moderationRoomId: this.mjolnir.managementRoomId }); + await this.displayManager.displayReportAndUI({ + kind: Kind.MODERATION_REQUEST, + nature, + event, + reporterId, + reason: comment, + moderationRoomId: this.mjolnir.managementRoomId, + }); } await this.mjolnir.client.sendNotice(dmRoomId, "Thank you for your report, it has been sent to the moderators"); @@ -196,8 +241,8 @@ export class ReportManager extends EventEmitter { * @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: string; event: any }) { + if (event.sender === (await this.mjolnir.client.getUserId())) { // Let's not react to our own reactions. return; } @@ -217,7 +262,7 @@ export class ReportManager extends EventEmitter { 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()) { + if (originalEvent.sender !== (await this.mjolnir.client.getUserId())) { // 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; @@ -269,17 +314,29 @@ export class ReportManager extends EventEmitter { return; } if (decision) { - LogService.info("ReportManager::handleReaction", "User", event["sender"], "confirmed action", matches[1]); + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "confirmed action", + matches[1], + ); await this.executeAction({ label: matches[1], report: confirmationReport, successEventId: confirmationReport.notification_event_id, failureEventId: relation.event_id, onSuccessRemoveEventId: relation.event_id, - moderationRoomId: roomId - }) + moderationRoomId: roomId, + }); } else { - LogService.info("ReportManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]); + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "cancelled action", + matches[1], + ); this.mjolnir.client.redactEvent(this.mjolnir.managementRoomId, relation.event_id, "Action cancelled"); } @@ -299,65 +356,94 @@ export class ReportManager extends EventEmitter { confirmationReport = { action: label, notification_event_id: relation.event_id, - ...initialNoticeReport + ...initialNoticeReport, }; - LogService.info("ReportManager::handleReaction", "User", event["sender"], "picked action", label, initialNoticeReport); + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "picked action", + label, + initialNoticeReport, + ); if (action.needsConfirmation) { // Send a confirmation request. let confirmation = { - msgtype: "m.notice", - body: `${action.emoji} ${await action.title(this, initialNoticeReport)}?`, + "msgtype": "m.notice", + "body": `${action.emoji} ${await action.title(this, initialNoticeReport)}?`, "m.relationship": { - "rel_type": "m.reference", - "event_id": relation.event_id, + rel_type: "m.reference", + event_id: relation.event_id, }, - [ABUSE_ACTION_CONFIRMATION_KEY]: confirmationReport + [ABUSE_ACTION_CONFIRMATION_KEY]: confirmationReport, }; - let requestConfirmationEventId = await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, confirmation); + let requestConfirmationEventId = await this.mjolnir.client.sendMessage( + this.mjolnir.managementRoomId, + confirmation, + ); await this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { "m.relates_to": { - "rel_type": "m.annotation", - "event_id": requestConfirmationEventId, - "key": `🆗 ${action.emoji} ${await action.title(this, initialNoticeReport)} [${action.label}][${CONFIRM}]` - } + 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", { "m.relates_to": { - "rel_type": "m.annotation", - "event_id": requestConfirmationEventId, - "key": `⬛ Cancel [${action.label}][${CANCEL}]` - } + rel_type: "m.annotation", + event_id: requestConfirmationEventId, + key: `⬛ Cancel [${action.label}][${CANCEL}]`, + }, }); } else { // Execute immediately. - LogService.info("ReportManager::handleReaction", "User", event["sender"], "executed (no confirmation needed) action", matches[1]); + LogService.info( + "ReportManager::handleReaction", + "User", + event["sender"], + "executed (no confirmation needed) action", + matches[1], + ); this.executeAction({ label, report: confirmationReport, successEventId: relation.event_id, failureEventId: relation.eventId, - moderationRoomId: roomId - }) + moderationRoomId: roomId, + }); } } } - /** - * Execute a report-specific action. - * - * This is executed when the user clicks on an action to execute (if the action - * does not need confirmation) or when the user clicks on "confirm" in a confirmation - * (otherwise). - * - * @param label The type of action to execute, e.g. `kick-user`. - * @param report The abuse report on which to take action. - * @param successEventId The event to annotate with a "OK" in case of success. - * @param failureEventId The event to annotate with a "FAIL" in case of failure. - * @param onSuccessRemoveEventId Optionally, an event to remove in case of success (e.g. the confirmation dialog). - */ - private async executeAction({ label, report, successEventId, failureEventId, onSuccessRemoveEventId, moderationRoomId }: { label: string, report: IReportWithAction, successEventId: string, failureEventId: string, onSuccessRemoveEventId?: string, moderationRoomId: string }) { + * Execute a report-specific action. + * + * This is executed when the user clicks on an action to execute (if the action + * does not need confirmation) or when the user clicks on "confirm" in a confirmation + * (otherwise). + * + * @param label The type of action to execute, e.g. `kick-user`. + * @param report The abuse report on which to take action. + * @param successEventId The event to annotate with a "OK" in case of success. + * @param failureEventId The event to annotate with a "FAIL" in case of failure. + * @param onSuccessRemoveEventId Optionally, an event to remove in case of success (e.g. the confirmation dialog). + */ + private async executeAction({ + label, + report, + successEventId, + failureEventId, + onSuccessRemoveEventId, + moderationRoomId, + }: { + label: string; + report: IReportWithAction; + successEventId: string; + failureEventId: string; + onSuccessRemoveEventId?: string; + moderationRoomId: string; + }) { let action: IUIAction | undefined = ACTIONS.get(label); if (!action) { return; @@ -378,40 +464,44 @@ export class ReportManager extends EventEmitter { if (error) { this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { "m.relates_to": { - "rel_type": "m.annotation", - "event_id": failureEventId, - "key": `${action.emoji} ❌` - } + rel_type: "m.annotation", + event_id: failureEventId, + key: `${action.emoji} ❌`, + }, }); this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.notice", { "body": error.message || "", "m.relationship": { - "rel_type": "m.reference", - "event_id": failureEventId, - } - }) + rel_type: "m.reference", + event_id: failureEventId, + }, + }); } else { this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", { "m.relates_to": { - "rel_type": "m.annotation", - "event_id": successEventId, - "key": `${action.emoji} ✅` - } + rel_type: "m.annotation", + event_id: successEventId, + key: `${action.emoji} ✅`, + }, }); if (onSuccessRemoveEventId) { - this.mjolnir.client.redactEvent(this.mjolnir.managementRoomId, onSuccessRemoveEventId, "Action complete"); + this.mjolnir.client.redactEvent( + this.mjolnir.managementRoomId, + onSuccessRemoveEventId, + "Action complete", + ); } if (response) { this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { - msgtype: "m.notice", + "msgtype": "m.notice", "formatted_body": response, - format: "org.matrix.custom.html", + "format": "org.matrix.custom.html", "body": htmlToText(response), "m.relationship": { - "rel_type": "m.reference", - "event_id": successEventId - } - }) + rel_type: "m.reference", + event_id: successEventId, + }, + }); } } } @@ -427,23 +517,23 @@ interface IReport { /** * The user who sent the abuse report. */ - readonly accused_id: string, + readonly accused_id: string; /** * The user who sent the message reported as abuse. */ - readonly reporter_id: string, + readonly reporter_id: string; /** * The room in which `eventId` took place. */ - readonly room_id: string, - readonly room_alias_or_id: string, + readonly room_id: string; + readonly room_alias_or_id: string; /** * The event reported as abuse. */ - readonly event_id: string, + readonly event_id: string; } /** @@ -451,17 +541,17 @@ interface IReport { * * Note: These reports end up embedded in Matrix messages, behind key `ABUSE_ACTION_CONFIRMATION_KEY`, * so we're using Matrix naming conventions rather than JS/TS naming conventions. -*/ + */ interface IReportWithAction extends IReport { /** * The label of the action we're confirming, e.g. `kick-user`. */ - readonly action: string, + readonly action: string; /** * The event in which we originally notified of the abuse. */ - readonly notification_event_id: string, + readonly notification_event_id: string; } /** @@ -519,7 +609,12 @@ 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; } /** @@ -539,20 +634,18 @@ 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", - { - msgtype: "m.notice", - body: "Report classified as invalid", - "m.new_content": { - "body": `Report by user ${report.reporter_id} has been classified as invalid`, - "msgtype": "m.text" - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": report.notification_event_id - } - } - ); + await manager.mjolnir.client.sendEvent(manager.mjolnir.managementRoomId, "m.room.message", { + "msgtype": "m.notice", + "body": "Report classified as invalid", + "m.new_content": { + body: `Report by user ${report.reporter_id} has been classified as invalid`, + msgtype: "m.text", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: report.notification_event_id, + }, + }); return; } } @@ -566,7 +659,11 @@ 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.mjolnir.client.userHasPowerLevelForAction( + await manager.mjolnir.client.getUserId(), + report.room_id, + PowerLevelAction.RedactEvents, + ); } catch (ex) { return false; } @@ -577,7 +674,11 @@ 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 { + public async execute( + manager: ReportManager, + report: IReport, + _moderationRoomId: string, + ): Promise { await manager.mjolnir.client.redactEvent(report.room_id, report.event_id); return; } @@ -592,7 +693,11 @@ 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.mjolnir.client.userHasPowerLevelForAction( + await manager.mjolnir.client.getUserId(), + report.room_id, + PowerLevelAction.Kick, + ); } catch (ex) { return false; } @@ -618,7 +723,12 @@ 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.mjolnir.client.userHasPowerLevelFor( + await manager.mjolnir.client.getUserId(), + report.room_id, + "m.room.power_levels", + true, + ); } catch (ex) { return false; } @@ -644,7 +754,11 @@ 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.mjolnir.client.userHasPowerLevelForAction( + await manager.mjolnir.client.getUserId(), + report.room_id, + PowerLevelAction.Ban, + ); } catch (ex) { return false; } @@ -677,7 +791,11 @@ 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) { @@ -685,8 +803,10 @@ class Help implements IUIAction { list.push(`
      • ${action.emoji} ${await action.help(manager, report)}
      • `); } } - 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)}
      • `) + 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")}
        `; return body; @@ -719,7 +839,12 @@ class EscalateToServerModerationRoom implements IUIAction { public async help(manager: ReportManager, _report: IReport): Promise { return `Escalate report to ${getHomeserver(await manager.mjolnir.client.getUserId())} server moderators`; } - public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string, displayManager: DisplayManager): Promise { + 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); // Display the report and UI directly in the management room, as if it had been @@ -730,16 +855,18 @@ class EscalateToServerModerationRoom implements IUIAction { // - `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.mjolnir.managementRoomId, + event, + }); return; } } class DisplayManager { - - constructor(private owner: ReportManager) { - - } + constructor(private owner: ReportManager) {} /** * Display the report and any UI button. @@ -755,7 +882,15 @@ class DisplayManager { * @param reason A user-provided comment. Low-security. * @param moderationRoomId The room in which the report and ui will be displayed. MUST be checked. */ - public async displayReportAndUI(args: { kind: Kind, event: any, reporterId: string, reason?: string, nature?: string, moderationRoomId: string, error?: string }) { + 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"]!; @@ -763,12 +898,12 @@ class DisplayManager { let roomAliasOrId = roomId; try { - roomAliasOrId = await this.owner.mjolnir.client.getPublishedAlias(roomId) || roomId; + roomAliasOrId = (await this.owner.mjolnir.client.getPublishedAlias(roomId)) || roomId; } catch (ex) { // Ignore. } - let eventContent: { msg: string} | { html: string } | { text: string }; + let eventContent: { msg: string } | { html: string } | { text: string }; try { if (event["type"] === "m.room.encrypted") { eventContent = { msg: "" }; @@ -776,29 +911,43 @@ class DisplayManager { const MAX_EVENT_CONTENT_LENGTH = 2048; const MAX_NEWLINES = 64; if ("formatted_body" in event.content) { - eventContent = { html: this.limitLength(event.content.formatted_body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + eventContent = { + html: this.limitLength(event.content.formatted_body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES), + }; } else if ("body" in event.content) { - eventContent = { text: this.limitLength(event.content.body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + eventContent = { + text: this.limitLength(event.content.body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES), + }; } else { - eventContent = { text: this.limitLength(JSON.stringify(event["content"], null, 2), MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + eventContent = { + text: this.limitLength( + JSON.stringify(event["content"], null, 2), + MAX_EVENT_CONTENT_LENGTH, + MAX_NEWLINES, + ), + }; } } else { eventContent = { msg: "Malformed event, cannot read content." }; } } catch (ex) { - eventContent = { msg: `.` }; + eventContent = { + msg: `.`, + }; } let accusedId = event["sender"]; let reporterDisplayName: string, accusedDisplayName: string; try { - reporterDisplayName = (await this.owner.mjolnir.client.getUserProfile(reporterId))["displayname"] || reporterId; + reporterDisplayName = + (await this.owner.mjolnir.client.getUserProfile(reporterId))["displayname"] || reporterId; } catch (ex) { reporterDisplayName = ""; } try { - accusedDisplayName = (await this.owner.mjolnir.client.getUserProfile(accusedId))["displayname"] || accusedId; + accusedDisplayName = + (await this.owner.mjolnir.client.getUserProfile(accusedId))["displayname"] || accusedId; } catch (ex) { accusedDisplayName = ""; } @@ -879,18 +1028,18 @@ class DisplayManager { // ...insert text content for (let [key, value] of [ - ['title', title], - ['reporter-display-name', reporterDisplayName], - ['reporter-id', reporterId], - ['accused-display-name', accusedDisplayName], - ['accused-id', accusedId], - ['event-id', eventId], - ['room-alias-or-id', roomAliasOrId], - ['reason-content', reason || ""], - ['nature-display', readableNature], - ['nature-source', nature || ""], - ['event-timestamp', eventTimestamp], - ['details-or-error', kind === Kind.ERROR ? error : null] + ["title", title], + ["reporter-display-name", reporterDisplayName], + ["reporter-id", reporterId], + ["accused-display-name", accusedDisplayName], + ["accused-id", accusedId], + ["event-id", eventId], + ["room-alias-or-id", roomAliasOrId], + ["reason-content", reason || ""], + ["nature-display", readableNature], + ["nature-source", nature || ""], + ["event-timestamp", eventTimestamp], + ["details-or-error", kind === Kind.ERROR ? error : null], ]) { let node = document.getElementById(key); if (node && value) { @@ -899,8 +1048,8 @@ class DisplayManager { } // ...insert links for (let [key, value] of [ - ['event-shortcut', eventShortcut], - ['room-shortcut', roomShortcut], + ["event-shortcut", eventShortcut], + ["room-shortcut", roomShortcut], ]) { let node = document.getElementById(key) as HTMLAnchorElement; if (node) { @@ -909,9 +1058,7 @@ class DisplayManager { } // ...insert HTML content - for (let {key, value} of [ - { key: 'event-content', value: eventContent }, - ]) { + for (let { key, value } of [{ key: "event-content", value: eventContent }]) { let node = document.getElementById(key); if (node) { if ("msg" in value) { @@ -919,7 +1066,7 @@ class DisplayManager { } else if ("text" in value) { node.textContent = value.text; } else if ("html" in value) { - node.innerHTML = value.html + node.innerHTML = value.html; } } } @@ -927,8 +1074,7 @@ class DisplayManager { // ...set presentation if (!("msg" in eventContent)) { // If there's some event content, mark it as a spoiler. - document.getElementById('event-container')!. - setAttribute("data-mx-spoiler", ""); + document.getElementById("event-container")!.setAttribute("data-mx-spoiler", ""); } // Embed additional information in the notice, for use by the @@ -945,7 +1091,7 @@ class DisplayManager { body: htmlToText(document.body.outerHTML, { wordwrap: false }), format: "org.matrix.custom.html", formatted_body: document.body.outerHTML, - [ABUSE_REPORT_KEY]: report + [ABUSE_REPORT_KEY]: report, }; let noticeEventId = await this.owner.mjolnir.client.sendMessage(this.owner.mjolnir.managementRoomId, notice); @@ -953,22 +1099,22 @@ class DisplayManager { // 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", { "m.relates_to": { - "rel_type": "m.annotation", - "event_id": noticeEventId, - "key": `${action.emoji} ${await action.title(this.owner, report)} [${label}]` - } + rel_type: "m.annotation", + event_id: noticeEventId, + key: `${action.emoji} ${await action.title(this.owner, report)} [${label}]`, + }, }); } } } private limitLength(text: string, maxLength: number, maxNewlines: number): string { - let originalLength = text.length + let originalLength = text.length; // Shorten text if it is too long. if (text.length > maxLength) { text = text.substring(0, maxLength); @@ -988,7 +1134,7 @@ class DisplayManager { text = text.substring(0, index); break; } - }; + } if (text.length < originalLength) { return `${text}... [total: ${originalLength} characters]`; } else { @@ -1009,15 +1155,15 @@ const ACTION_LIST = [ new BanAccused(), new EscalateToServerModerationRoom(), new IgnoreBadReport(), - new Help() + new Help(), ]; /** * The actions we may be able to undertake in reaction to a report. * * As a map of labels => actions. */ -const ACTIONS = new Map(ACTION_LIST.map(action => [action.label, action])); +const ACTIONS = new Map(ACTION_LIST.map((action) => [action.label, action])); function getHomeserver(userId: string): string { - return new UserID(userId).domain + return new UserID(userId).domain; } diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 913aeb49..71559e41 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -15,10 +15,10 @@ limitations under the License. */ import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir"; -import { ReportManager } from './ReportManager'; +import { ReportManager } from "./ReportManager"; import { LogLevel } from "@vector-im/matrix-bot-sdk"; -class InvalidStateError extends Error { } +class InvalidStateError extends Error {} /** * A class to poll synapse's report endpoint, so we can act on new reports @@ -40,7 +40,7 @@ export class ReportPoller { constructor( private mjolnir: Mjolnir, private manager: ReportManager, - ) { } + ) {} private schedulePoll() { if (this.timeout === null) { @@ -52,7 +52,7 @@ export class ReportPoller { */ this.timeout = setTimeout( this.tryGetAbuseReports.bind(this), - 30_000 // a minute in milliseconds + 30_000, // a minute in milliseconds ); } else { throw new InvalidStateError("poll already scheduled"); @@ -60,22 +60,24 @@ export class ReportPoller { } private async getAbuseReports() { - let response_: { - event_reports: { room_id: string, event_id: string, sender: string, reason: string }[], - next_token: number | undefined - } | undefined; + let response_: + | { + event_reports: { room_id: string; event_id: string; sender: string; reason: string }[]; + next_token: number | undefined; + } + | undefined; try { - response_ = await this.mjolnir.client.doRequest( - "GET", - "/_synapse/admin/v1/event_reports", - { - // short for direction: forward; i.e. show newest last - dir: "f", - from: this.from.toString() - } - ); + response_ = await this.mjolnir.client.doRequest("GET", "/_synapse/admin/v1/event_reports", { + // short for direction: forward; i.e. show newest last + dir: "f", + from: this.from.toString(), + }); } catch (ex) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); + await this.mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "getAbuseReports", + `failed to poll events: ${ex}`, + ); return; } @@ -87,12 +89,18 @@ export class ReportPoller { let event: any; // `any` because `handleServerAbuseReport` uses `any` try { - event = (await this.mjolnir.client.doRequest( - "GET", - `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` - )).event; + event = ( + await this.mjolnir.client.doRequest( + "GET", + `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1`, + ) + ).event; } catch (ex) { - this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); + this.mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "getAbuseReports", + `failed to get context: ${ex}`, + ); continue; } @@ -114,7 +122,11 @@ export class ReportPoller { try { await this.mjolnir.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.mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "getAbuseReports", + `failed to update progress: ${ex}`, + ); } } } @@ -123,9 +135,13 @@ export class ReportPoller { this.timeout = null; try { - await this.getAbuseReports() + await this.getAbuseReports(); } catch (ex) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); + await this.mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "tryGetAbuseReports", + `failed to get abuse reports: ${ex}`, + ); } this.schedulePoll(); diff --git a/src/utils.ts b/src/utils.ts index 8eae4ecb..82a03159 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,17 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - LogLevel, - LogService, - MatrixGlob, - getRequestFn, - setRequestFn, -} from "@vector-im/matrix-bot-sdk"; +import { LogLevel, LogService, MatrixGlob, getRequestFn, setRequestFn } from "@vector-im/matrix-bot-sdk"; import { ClientRequest, IncomingMessage } from "http"; import { default as parseDuration } from "parse-duration"; -import * as Sentry from '@sentry/node'; -import * as _ from '@sentry/tracing'; // Performing the import activates tracing. +import * as Sentry from "@sentry/node"; +import * as _ from "@sentry/tracing"; // Performing the import activates tracing. import { collectDefaultMetrics, Counter, Histogram, register } from "prom-client"; import ManagementRoomOutput from "./ManagementRoomOutput"; @@ -41,14 +35,17 @@ parseDuration["years"] = parseDuration["year"]; // ... and reexport it export { parseDuration }; - export function htmlEscape(input: string): string { - return input.replace(/["&<>]/g, (char: string) => ({ - ['"'.charCodeAt(0)]: """, - ["&".charCodeAt(0)]: "&", - ["<".charCodeAt(0)]: "<", - [">".charCodeAt(0)]: ">" - })[char.charCodeAt(0)]); + return input.replace( + /["&<>]/g, + (char: string) => + ({ + ['"'.charCodeAt(0)]: """, + ["&".charCodeAt(0)]: "&", + ["<".charCodeAt(0)]: "<", + [">".charCodeAt(0)]: ">", + })[char.charCodeAt(0)], + ); } export function setToArray(set: Set): T[] { @@ -60,14 +57,14 @@ export function setToArray(set: Set): T[] { } export function isTrueJoinEvent(event: any): boolean { - const membership = event['content']['membership'] || 'join'; + const membership = event["content"]["membership"] || "join"; let prevMembership = "leave"; - if (event['unsigned'] && event['unsigned']['prev_content']) { - prevMembership = event['unsigned']['prev_content']['membership'] || 'leave'; + if (event["unsigned"] && event["unsigned"]["prev_content"]) { + prevMembership = event["unsigned"]["prev_content"]["membership"] || "leave"; } // We look at the previous membership to filter out profile changes - return membership === 'join' && prevMembership !== "join"; + return membership === "join" && prevMembership !== "join"; } /** @@ -82,23 +79,50 @@ export function isTrueJoinEvent(event: any): boolean { * @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted. * @param noop Whether to operate in noop mode. */ -export async function redactUserMessagesIn(client: MatrixSendClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) { +export async function redactUserMessagesIn( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput, + userIdOrGlob: string, + targetRoomIds: string[], + limit = 1000, + noop = false, +) { for (const targetRoomId of targetRoomIds) { - await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); + await managementRoom.logMessage( + LogLevel.DEBUG, + "utils#redactUserMessagesIn", + `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, + targetRoomId, + ); try { await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { for (const targetEvent of eventsToRedact) { - await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${targetEvent['event_id']} in ${targetRoomId}`, targetRoomId); + await managementRoom.logMessage( + LogLevel.DEBUG, + "utils#redactUserMessagesIn", + `Redacting ${targetEvent["event_id"]} in ${targetRoomId}`, + targetRoomId, + ); if (!noop) { - await client.redactEvent(targetRoomId, targetEvent['event_id']); + await client.redactEvent(targetRoomId, targetEvent["event_id"]); } else { - await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${targetEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId); + await managementRoom.logMessage( + LogLevel.WARN, + "utils#redactUserMessagesIn", + `Tried to redact ${targetEvent["event_id"]} in ${targetRoomId} but Mjolnir is running in no-op mode`, + targetRoomId, + ); } } }); } catch (error) { - await managementRoom.logMessage(LogLevel.ERROR, "utils#redactUserMessagesIn", `Caught an error while trying to redact messages for ${userIdOrGlob} in ${targetRoomId}: ${error}`, targetRoomId); + await managementRoom.logMessage( + LogLevel.ERROR, + "utils#redactUserMessagesIn", + `Caught an error while trying to redact messages for ${userIdOrGlob} in ${targetRoomId}: ${error}`, + targetRoomId, + ); } } } @@ -119,11 +143,17 @@ export async function redactUserMessagesIn(client: MatrixSendClient, managementR * The callback will only be called if there are any relevant events. * @returns {Promise} Resolves when either: the limit has been reached, no relevant events could be found or there is no more timeline to paginate. */ -export async function getMessagesByUserIn(client: MatrixSendClient, sender: string, roomId: string, limit: number, cb: (events: any[]) => void): Promise { +export async function getMessagesByUserIn( + client: MatrixSendClient, + sender: string, + roomId: string, + limit: number, + cb: (events: any[]) => void, +): Promise { const isGlob = sender.includes("*"); const roomEventFilter = { rooms: [roomId], - ... isGlob ? {} : {senders: [sender]} + ...(isGlob ? {} : { senders: [sender] }), }; const matcher = new MatrixGlob(sender); @@ -145,9 +175,9 @@ export async function getMessagesByUserIn(client: MatrixSendClient, sender: stri * The `start` is a token for the beginning of the `chunk` (where the most recent events are). */ interface BackfillResponse { - chunk?: any[], - end?: string, - start: string + chunk?: any[]; + end?: string; + start: string; } /** @@ -156,11 +186,11 @@ export async function getMessagesByUserIn(client: MatrixSendClient, sender: stri * if `null`, start from the most recent point in the timeline. * @returns The response part of the `/messages` API, see `BackfillResponse`. */ - async function backfill(from: string|null): Promise { + async function backfill(from: string | null): Promise { const qs = { filter: JSON.stringify(roomEventFilter), dir: "b", - ... from ? { from } : {} + ...(from ? { from } : {}), }; LogService.info("utils", "Backfilling with token: " + from); return client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages`, qs); @@ -178,18 +208,18 @@ export async function getMessagesByUserIn(client: MatrixSendClient, sender: stri if (processed >= limit) return messages; // we have provided enough events. processed++; - if (testUser(event['sender'])) messages.push(event); + if (testUser(event["sender"])) messages.push(event); } return messages; } // We check that we have the token because rooms/messages is not required to provide one // and will not provide one when there is no more history to paginate. - let token: string|null = null; + let token: string | null = null; do { const bfMessages: BackfillResponse = await backfill(token); - const previousToken: string|null = token; - token = bfMessages['end'] ?? null; - const events = filterEvents(bfMessages['chunk'] || []); + const previousToken: string | null = token; + token = bfMessages["end"] ?? null; + const events = filterEvents(bfMessages["chunk"] || []); // If we are using a glob, there may be no relevant events in this chunk. if (events.length > 0) { await cb(events); @@ -202,7 +232,7 @@ export async function getMessagesByUserIn(client: MatrixSendClient, sender: stri LogService.debug("utils", "Backfill returned same end token - returning early."); return; } - } while (token && processed < limit) + } while (token && processed < limit); } let isMatrixClientPatchedForConciseExceptions = false; @@ -229,92 +259,97 @@ function patchMatrixClientForConciseExceptions() { // Store an error early, to maintain *some* semblance of stack. // We'll only throw the error if there is one. let error = new Error("STACK CAPTURE"); - originalRequestFn(params, function conciseExceptionRequestFn( - err: { [key: string]: any }, response: { [key: string]: any }, resBody: string - ) { - if (!err && (response?.statusCode < 200 || response?.statusCode >= 300)) { - // Normally, converting HTTP Errors into rejections is done by the caller - // of `requestFn` within matrix-bot-sdk. However, this always ends up rejecting - // with an `IncomingMessage` - exactly what we wish to avoid here. - err = response; - - // Safety note: In the calling code within matrix-bot-sdk, if we return - // an IncomingMessage as an error, we end up logging an unredacted response, - // which may include tokens, passwords, etc. This could be a grave privacy - // leak. The matrix-bot-sdk typically handles this by sanitizing the data - // before logging it but, by converting the HTTP Error into a rejection - // earlier than expected by the matrix-bot-sdk, we skip this step of - // sanitization. - // - // However, since the error we're creating is an `IncomingMessage`, we - // rewrite it into an `Error` ourselves in this function. Our `Error` - // is even more sanitized (we only include the URL, HTTP method and - // the error response) so we are NOT causing a privacy leak. + originalRequestFn( + params, + function conciseExceptionRequestFn( + err: { [key: string]: any }, + response: { [key: string]: any }, + resBody: string, + ) { + if (!err && (response?.statusCode < 200 || response?.statusCode >= 300)) { + // Normally, converting HTTP Errors into rejections is done by the caller + // of `requestFn` within matrix-bot-sdk. However, this always ends up rejecting + // with an `IncomingMessage` - exactly what we wish to avoid here. + err = response; + + // Safety note: In the calling code within matrix-bot-sdk, if we return + // an IncomingMessage as an error, we end up logging an unredacted response, + // which may include tokens, passwords, etc. This could be a grave privacy + // leak. The matrix-bot-sdk typically handles this by sanitizing the data + // before logging it but, by converting the HTTP Error into a rejection + // earlier than expected by the matrix-bot-sdk, we skip this step of + // sanitization. + // + // However, since the error we're creating is an `IncomingMessage`, we + // rewrite it into an `Error` ourselves in this function. Our `Error` + // is even more sanitized (we only include the URL, HTTP method and + // the error response) so we are NOT causing a privacy leak. + if (!(err instanceof IncomingMessage)) { + // Safety check. + throw new TypeError("Internal error: at this stage, the error should be an IncomingMessage"); + } + } if (!(err instanceof IncomingMessage)) { - // Safety check. - throw new TypeError("Internal error: at this stage, the error should be an IncomingMessage"); + // In most cases, we're happy with the result. + return cb(err, response, resBody); } - } - if (!(err instanceof IncomingMessage)) { - // In most cases, we're happy with the result. - return cb(err, response, resBody); - } - // However, MatrixClient has a tendency of throwing - // instances of `IncomingMessage` instead of instances - // of `Error`. The former take ~800 lines of log and - // provide no stack trace, which makes them typically - // useless. - let method: string | null = null; - let path = ''; - let body: string | null = null; - if (err.method) { - method = err.method; - } - if (err.url) { - path = err.url; - } - if ("req" in err && (err as any).req instanceof ClientRequest) { - if (!method) { - method = (err as any).req.method; + // However, MatrixClient has a tendency of throwing + // instances of `IncomingMessage` instead of instances + // of `Error`. The former take ~800 lines of log and + // provide no stack trace, which makes them typically + // useless. + let method: string | null = null; + let path = ""; + let body: string | null = null; + if (err.method) { + method = err.method; } - if (!path) { - path = (err as any).req.path; + if (err.url) { + path = err.url; } - } - if ("body" in err) { - body = (err as any).body; - } - let message = `Error during MatrixClient request ${method} ${path}: ${err.statusCode} ${err.statusMessage} -- ${body}`; - error.message = message; - if (body) { - // Calling code may use `body` to check for errors, so let's + if ("req" in err && (err as any).req instanceof ClientRequest) { + if (!method) { + method = (err as any).req.method; + } + if (!path) { + path = (err as any).req.path; + } + } + if ("body" in err) { + body = (err as any).body; + } + let message = `Error during MatrixClient request ${method} ${path}: ${err.statusCode} ${err.statusMessage} -- ${body}`; + error.message = message; + if (body) { + // Calling code may use `body` to check for errors, so let's + // make sure that we're providing it. + try { + body = JSON.parse(body); + } catch (ex) { + // Not JSON. + } + // Define the property but don't make it visible during logging. + Object.defineProperty(error, "body", { + value: body, + enumerable: false, + }); + } + // Calling code may use `statusCode` to check for errors, so let's // make sure that we're providing it. - try { - body = JSON.parse(body); - } catch (ex) { - // Not JSON. + if ("statusCode" in err) { + // Define the property but don't make it visible during logging. + Object.defineProperty(error, "statusCode", { + value: err.statusCode, + enumerable: false, + }); } - // Define the property but don't make it visible during logging. - Object.defineProperty(error, "body", { - value: body, - enumerable: false, - }); - } - // Calling code may use `statusCode` to check for errors, so let's - // make sure that we're providing it. - if ("statusCode" in err) { - // Define the property but don't make it visible during logging. - Object.defineProperty(error, "statusCode", { - value: err.statusCode, - enumerable: false, - }); - } - if (!LogService.level.includes(LogLevel.TRACE)) { - // Remove stack trace to reduce impact on logs. - error.stack = ""; - } - return cb(error, response, resBody); - }) + if (!LogService.level.includes(LogLevel.TRACE)) { + // Remove stack trace to reduce impact on logs. + error.stack = ""; + } + return cb(error, response, resBody); + }, + ); }); isMatrixClientPatchedForConciseExceptions = true; } @@ -348,24 +383,32 @@ function patchMatrixClientForRetry() { while (true) { try { let result: any[] = await new Promise((resolve, reject) => { - originalRequestFn(params, function requestFnWithRetry( - err: { [key: string]: any }, response: { [key: string]: any }, resBody: string - ) { - // Note: There is no data race on `attempt` as we `await` before continuing - // to the next iteration of the loop. - if (attempt < MAX_REQUEST_ATTEMPTS && err?.body?.errcode === 'M_LIMIT_EXCEEDED') { - // We need to retry. - reject(err); - } else { - if (attempt >= MAX_REQUEST_ATTEMPTS) { - LogService.warn('Mjolnir.client', `Retried request ${params.method} ${params.uri} ${attempt} times, giving up.`); + originalRequestFn( + params, + function requestFnWithRetry( + err: { [key: string]: any }, + response: { [key: string]: any }, + resBody: string, + ) { + // Note: There is no data race on `attempt` as we `await` before continuing + // to the next iteration of the loop. + if (attempt < MAX_REQUEST_ATTEMPTS && err?.body?.errcode === "M_LIMIT_EXCEEDED") { + // We need to retry. + reject(err); + } else { + if (attempt >= MAX_REQUEST_ATTEMPTS) { + LogService.warn( + "Mjolnir.client", + `Retried request ${params.method} ${params.uri} ${attempt} times, giving up.`, + ); + } + // No need-to-retry error? Lucky us! + // Note that this may very well be an error, just not + // one we need to retry. + resolve([err, response, resBody]); } - // No need-to-retry error? Lucky us! - // Note that this may very well be an error, just not - // one we need to retry. - resolve([err, response, resBody]); - } - }); + }, + ); }); // This is our final result. // Pass result, whether success or error. @@ -380,8 +423,11 @@ function patchMatrixClientForRetry() { // Use default value. } } - LogService.debug("Mjolnir.client", `Waiting ${retryAfterMs}ms before retrying ${params.method} ${params.uri}`); - await new Promise(resolve => setTimeout(resolve, retryAfterMs)); + LogService.debug( + "Mjolnir.client", + `Waiting ${retryAfterMs}ms before retrying ${params.method} ${params.uri}`, + ); + await new Promise((resolve) => setTimeout(resolve, retryAfterMs)); attempt += 1; } } @@ -436,7 +482,7 @@ export function initializeGlobalPerformanceMetrics(config: IHealthConfig) { }); setRequestFn(async (params: { [k: string]: any }, cb: any) => { let timer = perfHistogram.startTimer(); - return await originalRequestFn(params, function(error: object, response: any, body: string) { + return await originalRequestFn(params, function (error: object, response: any, body: string) { // Stop timer before calling callback. timer(); if (error) { diff --git a/src/webapis/OpenMetrics.ts b/src/webapis/OpenMetrics.ts index fc0b273e..cf1bf3d4 100644 --- a/src/webapis/OpenMetrics.ts +++ b/src/webapis/OpenMetrics.ts @@ -61,7 +61,10 @@ export class OpenMetrics { } LogService.info("Starting OpenMetrics server."); - this.httpServer = this.webController.listen(this.config.health.openMetrics!.port, this.config.health.openMetrics!.address); + this.httpServer = this.webController.listen( + this.config.health.openMetrics!.port, + this.config.health.openMetrics!.address, + ); this.webController.options(this.config.health.openMetrics!.address, async (_request, response) => { // reply with CORS options response.header("Access-Control-Allow-Origin", "*"); @@ -78,7 +81,7 @@ export class OpenMetrics { response.header("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization, Date"); response.header("Access-Control-Allow-Methods", "POST, OPTIONS"); try { - response.set('Content-Type', register.contentType); + response.set("Content-Type", register.contentType); response.end(await register.metrics()); } catch (ex) { response.status(500).end(ex); @@ -97,6 +100,6 @@ export class OpenMetrics { } public get isEnabled(): boolean { - return !!this.httpServer + return !!this.httpServer; } } diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index ddedc462..c6436420 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -21,7 +21,6 @@ import RuleServer from "../models/RuleServer"; import { ReportManager } from "../report/ReportManager"; import { IConfig } from "../config"; - /** * A common prefix for all web-exposed APIs. */ @@ -33,7 +32,11 @@ 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, + private readonly ruleServer: RuleServer | null, + ) { // Setup JSON parsing. this.webController.use(express.json()); } @@ -64,7 +67,12 @@ 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, + eventId: request.params.event_id, + }); }); LogService.info(`configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`); } @@ -80,7 +88,11 @@ export class 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}); + await this.handleRuleServerUpdate(ruleServer, { + request, + response, + since: request.query.since as string, + }); }); LogService.info("WebAPIs", `configuring ${updatesUrl}... DONE`); } @@ -104,7 +116,17 @@ 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: string; + eventId: string; + request: express.Request; + response: express.Response; + }) { // To display any kind of useful information, we need // // 1. The reporter id; @@ -120,11 +142,11 @@ export class WebAPIs { let accessToken: string | undefined = undefined; // Authentication mechanism 1: Request header. - let authorization = request.get('Authorization'); + let authorization = request.get("Authorization"); if (authorization) { [, accessToken] = AUTHORIZATION.exec(authorization)!; - } else if (typeof(request.query["access_token"]) === 'string') { + } else if (typeof request.query["access_token"] === "string") { // Authentication mechanism 2: Access token as query parameter. accessToken = request.query["access_token"]; } else { @@ -195,7 +217,10 @@ export class WebAPIs { } } - async handleRuleServerUpdate(ruleServer: RuleServer, { since, request, response }: { since: string, request: express.Request, response: express.Response }) { + 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. diff --git a/test/appservice/integration/provisionTest.ts b/test/appservice/integration/provisionTest.ts index 97f54585..f0d93029 100644 --- a/test/appservice/integration/provisionTest.ts +++ b/test/appservice/integration/provisionTest.ts @@ -3,23 +3,23 @@ import { newTestUser } from "../../integration/clientHelper"; import { getFirstReply } from "../../integration/commands/commandUtils"; import { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { MjolnirAppService } from "../../../src/appservice/AppService"; -import dns from 'node:dns'; +import dns from "node:dns"; interface Context extends Mocha.Context { - moderator?: MatrixClient, - appservice?: MjolnirAppService + moderator?: MatrixClient; + appservice?: MjolnirAppService; } // Necessary for CI: Node 17+ defaults to using ipv6 first, but Github Actions does not support ipv6 -dns.setDefaultResultOrder('ipv4first'); +dns.setDefaultResultOrder("ipv4first"); describe("Test that the app service can provision a mjolnir on invite of the appservice bot", function () { - afterEach(function(this: Context) { + afterEach(function (this: Context) { this.moderator?.stop(); if (this.appservice) { return this.appservice.close(); } else { - console.warn("Missing Appservice in this context, so cannot stop it.") + console.warn("Missing Appservice in this context, so cannot stop it."); return Promise.resolve(); // TS7030: Not all code paths return a value. } }); @@ -32,9 +32,9 @@ describe("Test that the app service can provision a mjolnir on invite of the app // have the moderator invite the appservice bot in order to request a new mjolnir this.moderator = moderator; const roomsInvitedTo: string[] = []; - await new Promise(async resolve => { - moderator.on('room.invite', (roomId: string) => { - roomsInvitedTo.push(roomId) + await new Promise(async (resolve) => { + moderator.on("room.invite", (roomId: string) => { + roomsInvitedTo.push(roomId); // the appservice should invite the moderator to a policy room and a management room. if (roomsInvitedTo.length === 2) { resolve(null); @@ -43,11 +43,11 @@ describe("Test that the app service can provision a mjolnir on invite of the app await moderator.start(); await moderator.inviteUser(this.appservice!.bridge.getBot().getUserId(), roomWeWantProtecting); }); - await Promise.all(roomsInvitedTo.map(roomId => moderator.joinRoom(roomId))); - const managementRoomId = roomsInvitedTo.filter(async roomId => !(await isPolicyRoom(moderator, roomId)))[0]; + await Promise.all(roomsInvitedTo.map((roomId) => moderator.joinRoom(roomId))); + 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: `!mjolnir status`, msgtype: "m.text" }); + }); + }); +}); diff --git a/test/appservice/integration/webAPITest.ts b/test/appservice/integration/webAPITest.ts index 5070e927..e3bae32c 100644 --- a/test/appservice/integration/webAPITest.ts +++ b/test/appservice/integration/webAPITest.ts @@ -5,24 +5,23 @@ import { CreateMjolnirResponse, MjolnirWebAPIClient } from "../utils/webAPIClien import { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { getFirstReply } from "../../integration/commands/commandUtils"; import expect from "expect"; -import dns from 'node:dns'; - +import dns from "node:dns"; interface Context extends Mocha.Context { - appservice?: MjolnirAppService - moderator?: MatrixClient + appservice?: MjolnirAppService; + moderator?: MatrixClient; } // Necessary for CI: Node 17+ defaults to using ipv6 first, but Github Actions does not support ipv6 -dns.setDefaultResultOrder('ipv4first'); +dns.setDefaultResultOrder("ipv4first"); describe("Test that the app service can provision a mjolnir when requested from the web API", function () { - afterEach(function(this: Context) { + afterEach(function (this: Context) { this.moderator?.stop(); if (this.appservice) { return this.appservice.close(); } else { - console.warn("Missing Appservice in this context, so cannot stop it.") + console.warn("Missing Appservice in this context, so cannot stop it."); return Promise.resolve(); // TS7030: Not all code paths return a value. } }); @@ -37,10 +36,10 @@ describe("Test that the app service can provision a mjolnir when requested from // have the moderator invite the appservice bot in order to request a new mjolnir this.moderator = moderator; const roomsInvitedTo: string[] = []; - const mjolnirDetails: CreateMjolnirResponse = await new Promise(async resolve => { + const mjolnirDetails: CreateMjolnirResponse = await new Promise(async (resolve) => { const mjolnirDetailsPromise = apiClient.createMjolnir(roomToProtectId); - moderator.on('room.invite', (roomId: string) => { - roomsInvitedTo.push(roomId) + moderator.on("room.invite", (roomId: string) => { + roomsInvitedTo.push(roomId); // the appservice should invite it to a policy room and a management room. if (roomsInvitedTo.length === 2) { mjolnirDetailsPromise.then(resolve); @@ -48,13 +47,13 @@ describe("Test that the app service can provision a mjolnir when requested from }); await moderator.start(); }); - await Promise.all(roomsInvitedTo.map(roomId => moderator.joinRoom(roomId))); - const managementRoomId = roomsInvitedTo.filter(async roomId => !(await isPolicyRoom(moderator, roomId)))[0]; + await Promise.all(roomsInvitedTo.map((roomId) => moderator.joinRoom(roomId))); + const managementRoomId = roomsInvitedTo.filter(async (roomId) => !(await isPolicyRoom(moderator, roomId)))[0]; 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: `!mjolnir status`, msgtype: "m.text" }); + }); expect(event.sender).toBe(mjolnirDetails.mjolnirUserId); - }) -}) + }); +}); diff --git a/test/appservice/utils/harness.ts b/test/appservice/utils/harness.ts index e923714e..e93831ed 100644 --- a/test/appservice/utils/harness.ts +++ b/test/appservice/utils/harness.ts @@ -12,7 +12,7 @@ export function readTestConfig(): IConfig { export async function setupHarness(): Promise { const config = readTestConfig(); - const utilityUser = await newTestUser(config.homeserver.url, { name: { contains: "utility" }}); + const utilityUser = await newTestUser(config.homeserver.url, { name: { contains: "utility" } }); await ensureAliasedRoomExists(utilityUser, config.accessControlList); return await MjolnirAppService.run(9000, config, "mjolnir-registration.yaml"); } @@ -21,4 +21,3 @@ 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); } - diff --git a/test/appservice/utils/webAPIClient.ts b/test/appservice/utils/webAPIClient.ts index 16e91911..7f3041fe 100644 --- a/test/appservice/utils/webAPIClient.ts +++ b/test/appservice/utils/webAPIClient.ts @@ -2,30 +2,32 @@ import * as request from "request"; import { MatrixClient } from "@vector-im/matrix-bot-sdk"; interface OpenIDTokenInfo { - access_token: string, - expires_in: number, - matrix_server_name: string, - token_type: string + access_token: string; + expires_in: number; + matrix_server_name: string; + token_type: string; } async function getOpenIDToken(client: MatrixClient): Promise { - const tokenInfo: OpenIDTokenInfo = await client.doRequest("POST", `/_matrix/client/v3/user/${await client.getUserId()}/openid/request_token`, undefined, {}); + const tokenInfo: OpenIDTokenInfo = await client.doRequest( + "POST", + `/_matrix/client/v3/user/${await client.getUserId()}/openid/request_token`, + undefined, + {}, + ); return tokenInfo.access_token; } export interface CreateMjolnirResponse { - mjolnirUserId: string, - managementRoomId: string, + mjolnirUserId: string; + managementRoomId: string; } export class MjolnirWebAPIClient { - private constructor( private readonly openIDToken: string, private readonly baseURL: string, - ) { - - } + ) {} public static async makeClient(client: MatrixClient, baseUrl: string): Promise { const token = await getOpenIDToken(client); @@ -33,17 +35,21 @@ export class MjolnirWebAPIClient { } public async createMjolnir(roomToProtectId: string): Promise { - const body: { mxid: string, roomId: string } = await new Promise((resolve, reject) => { - request.post(`${this.baseURL}/create`, { - json: { - openId: this.openIDToken, - roomId: roomToProtectId, + const body: { mxid: string; roomId: string } = await new Promise((resolve, reject) => { + request.post( + `${this.baseURL}/create`, + { + json: { + openId: this.openIDToken, + roomId: roomToProtectId, + }, }, - }, (error, response) => error ? reject(error) : resolve(response.body)) + (error, response) => (error ? reject(error) : resolve(response.body)), + ); }); return { mjolnirUserId: body.mxid, - managementRoomId: body.roomId - } + managementRoomId: body.roomId, + }; } } diff --git a/test/commands/UnbanBanCommandTest.ts b/test/commands/UnbanBanCommandTest.ts index 21b09a5f..52a36ac2 100644 --- a/test/commands/UnbanBanCommandTest.ts +++ b/test/commands/UnbanBanCommandTest.ts @@ -21,13 +21,13 @@ import { parseArguments } from "../../src/commands/UnbanBanCommand"; import { read as configRead } from "../../src/config"; import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../../src/models/ListRule"; -function createTestMjolnir(defaultShortcode: string|null = null): Mjolnir { +function createTestMjolnir(defaultShortcode: string | null = null): Mjolnir { const config = configRead(); const client = { // Mock `MatrixClient.getAccountData` . getAccountData: (eventType: string): Promise => { if (eventType === DEFAULT_LIST_EVENT_TYPE || defaultShortcode) { - return Promise.resolve({shortcode: defaultShortcode}); + return Promise.resolve({ shortcode: defaultShortcode }); } throw new Error(`Unknown event type ${eventType}, expected ${DEFAULT_LIST_EVENT_TYPE}`); }, @@ -35,7 +35,7 @@ function createTestMjolnir(defaultShortcode: string|null = null): Mjolnir { return { client, config, - policyListManager: {} + policyListManager: {}, }; } @@ -54,13 +54,13 @@ describe("UnbanBanCommand", () => { describe("parseArguments", () => { it("should be able to detect servers", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_SERVER); @@ -71,13 +71,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect servers with ban reasons", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test example.org reason here"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBe("reason here"); expect(bits!.ruleType).toBe(RULE_SERVER); @@ -88,13 +88,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect servers with globs", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test *.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_SERVER); @@ -105,13 +105,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect servers with the type specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test server @*.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_SERVER); @@ -122,13 +122,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect room IDs", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test !example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -139,13 +139,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect room IDs with ban reasons", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test !example.org reason here"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBe("reason here"); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -156,13 +156,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect room IDs with globs", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test !*.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -173,13 +173,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect room aliases", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test #example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -190,13 +190,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect room aliases with ban reasons", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test #example.org reason here"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBe("reason here"); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -207,13 +207,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect room aliases with globs", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test #*.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -224,13 +224,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect rooms with the type specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test room @*.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_ROOM); @@ -241,13 +241,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect user IDs", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test @example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -258,13 +258,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect user IDs with ban reasons", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test @example.org reason here"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBe("reason here"); expect(bits!.ruleType).toBe(RULE_USER); @@ -275,13 +275,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect user IDs with globs", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test @*.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -292,13 +292,13 @@ describe("UnbanBanCommand", () => { it("should be able to detect user IDs with the type specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test user #*.example.org --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -309,27 +309,27 @@ describe("UnbanBanCommand", () => { it("should error if wildcards used without --force", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { expect(content).toBeDefined(); - expect(content['body']).toContain("Wildcard bans require an additional `--force` argument to confirm"); + expect(content["body"]).toContain("Wildcard bans require an additional `--force` argument to confirm"); return Promise.resolve("$fake"); }; const command = "!mjolnir ban test *.example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeFalsy(); }); it("should have correct ban reason with --force after", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test user #*.example.org reason here --force"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBe("reason here"); expect(bits!.ruleType).toBe(RULE_USER); @@ -341,41 +341,41 @@ describe("UnbanBanCommand", () => { describe("[without default list]", () => { it("should error if no list (with type) is specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { expect(content).toBeDefined(); - expect(content['body']).toContain("No ban list matching that shortcode was found"); + expect(content["body"]).toContain("No ban list matching that shortcode was found"); return Promise.resolve("$fake"); }; const command = "!mjolnir ban user @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeFalsy(); }); it("should error if no list (without type) is specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { expect(content).toBeDefined(); - expect(content['body']).toContain("No ban list matching that shortcode was found"); + expect(content["body"]).toContain("No ban list matching that shortcode was found"); return Promise.resolve("$fake"); }; const command = "!mjolnir ban @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeFalsy(); }); it("should not error if a list (with type) is specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban user test @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -386,13 +386,13 @@ describe("UnbanBanCommand", () => { it("should not error if a list (without type) is specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -403,13 +403,13 @@ describe("UnbanBanCommand", () => { it("should not error if a list (with type reversed) is specified", async () => { const mjolnir = createTestMjolnir(); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban test user @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -422,13 +422,13 @@ describe("UnbanBanCommand", () => { describe("[with default list]", () => { it("should use the default list if no list (with type) is specified", async () => { const mjolnir = createTestMjolnir("test"); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}, {listShortcode: "other"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }, { listShortcode: "other" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban user @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -439,13 +439,13 @@ describe("UnbanBanCommand", () => { it("should use the default list if no list (without type) is specified", async () => { const mjolnir = createTestMjolnir("test"); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}, {listShortcode: "other"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }, { listShortcode: "other" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -456,13 +456,13 @@ describe("UnbanBanCommand", () => { it("should use the specified list if a list (with type) is specified", async () => { const mjolnir = createTestMjolnir("test"); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}, {listShortcode: "other"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }, { listShortcode: "other" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban user other @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -473,13 +473,13 @@ describe("UnbanBanCommand", () => { it("should use the specified list if a list (without type) is specified", async () => { const mjolnir = createTestMjolnir("test"); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}, {listShortcode: "other"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }, { listShortcode: "other" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban other @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); @@ -490,13 +490,13 @@ describe("UnbanBanCommand", () => { it("should not error if a list (with type reversed) is specified", async () => { const mjolnir = createTestMjolnir("test"); - (mjolnir).policyListManager.lists = [{listShortcode: "test"}, {listShortcode: "other"}]; + (mjolnir).policyListManager.lists = [{ listShortcode: "test" }, { listShortcode: "other" }]; mjolnir.client.sendMessage = (roomId: string, content: any): Promise => { throw new Error("sendMessage should not have been called: " + JSON.stringify(content)); }; const command = "!mjolnir ban other user @example:example.org"; - const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); + const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(" ")); expect(bits).toBeTruthy(); expect(bits!.reason).toBeFalsy(); expect(bits!.ruleType).toBe(RULE_USER); diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index a9215d82..001a05a1 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -13,10 +13,9 @@ const REPORT_NOTICE_REGEXPS = { room: /Room (?[^ ]*)/, event: /Event (?[^ ]*) Go to event/, content: /Content (?.*)/, - comments: /Comments Comments (?.*)/ + comments: /Comments Comments (?.*)/, }; - describe("Test: Reporting abuse", async () => { // Testing with successive versions of the API. // @@ -24,22 +23,26 @@ describe("Test: Reporting abuse", async () => { // both versions are still in use in the wild. // Note that this version change only affects the actual URL at which reports // are sent. - for (let endpoint of ['v3', 'r0']) { - it(`Mjölnir intercepts abuse reports with endpoint ${endpoint}`, async function() { + for (let endpoint of ["v3", "r0"]) { + it(`Mjölnir intercepts abuse reports with endpoint ${endpoint}`, async function () { this.timeout(90000); // Listen for any notices that show up. - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); let notices: any[] = []; this.mjolnir.client.on("room.event", (roomId: string, event: any) => { - if (roomId = this.mjolnir.managementRoomId) { + if ((roomId = this.mjolnir.managementRoomId)) { notices.push(event); } }); // Create a few users and a room. - let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); + let goodUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-good-user" }, + }); + let badUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-bad-user" }, + }); let goodUserId = await goodUser.getUserId(); let badUserId = await badUser.getUserId(); @@ -50,11 +53,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 badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse. + 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. - let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long. - let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines. + let badText4 = [...Array(1024)].map((_) => `${Math.random()}`).join(""); // Text is too long. + let badText5 = [...Array(1024)].map((_) => "ABC").join("\n"); // Text has too many lines. let badEventId = await badUser.sendText(roomId, badText); let badEventId2 = await badUser.sendText(roomId, badText2); let badEventId3 = await badUser.sendText(roomId, badText3); @@ -63,11 +66,14 @@ describe("Test: Reporting abuse", async () => { let badEvent2Comment = `COMMENT: ${Math.random()}`; console.log("Test: Reporting abuse - send reports"); - let reportsToFind: any[] = [] + let reportsToFind: any[] = []; // Time to report, first without a comment, then with one. try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`, + ); reportsToFind.push({ reporterId: goodUserId, accusedId: badUserId, @@ -81,9 +87,14 @@ describe("Test: Reporting abuse", async () => { } try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`, "", { - reason: badEvent2Comment - }); + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`, + "", + { + reason: badEvent2Comment, + }, + ); reportsToFind.push({ reporterId: goodUserId, accusedId: badUserId, @@ -97,7 +108,11 @@ describe("Test: Reporting abuse", async () => { } try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, ""); + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, + "", + ); reportsToFind.push({ reporterId: goodUserId, accusedId: badUserId, @@ -111,7 +126,11 @@ describe("Test: Reporting abuse", async () => { } try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, ""); + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, + "", + ); reportsToFind.push({ reporterId: goodUserId, accusedId: badUserId, @@ -126,7 +145,11 @@ describe("Test: Reporting abuse", async () => { } try { - await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, ""); + await goodUser.doRequest( + "POST", + `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, + "", + ); reportsToFind.push({ reporterId: goodUserId, accusedId: badUserId, @@ -141,12 +164,15 @@ describe("Test: Reporting abuse", async () => { } console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); let found: any[] = []; 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; } @@ -173,26 +199,67 @@ describe("Test: Reporting abuse", async () => { assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); - assert.equal(matches.get("event")!.groups!.eventId, toFind.eventId, "The report should specify the correct event id");; - - assert.equal(matches.get("reporter")!.groups!.reporterId, toFind.reporterId, "The report should specify the correct reporter"); - assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter"); - assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups!.reporterDisplay), "The report should display the correct reporter"); - - assert.equal(matches.get("accused")!.groups!.accusedId, toFind.accusedId, "The report should specify the correct accused"); - assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused"); - assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups!.accusedDisplay), "The report should display the correct reporter"); + assert.equal( + matches.get("event")!.groups!.eventId, + toFind.eventId, + "The report should specify the correct event id", + ); + + assert.equal( + matches.get("reporter")!.groups!.reporterId, + toFind.reporterId, + "The report should specify the correct reporter", + ); + assert.equal( + report.reporter_id, + toFind.reporterId, + "The embedded report should specify the correct reporter", + ); + assert.ok( + toFind.reporterId.includes(matches.get("reporter")!.groups!.reporterDisplay), + "The report should display the correct reporter", + ); + + assert.equal( + matches.get("accused")!.groups!.accusedId, + toFind.accusedId, + "The report should specify the correct accused", + ); + assert.equal( + report.accused_id, + toFind.accusedId, + "The embedded report should specify the correct accused", + ); + assert.ok( + toFind.accusedId.includes(matches.get("accused")!.groups!.accusedDisplay), + "The report should display the correct reporter", + ); if (toFind.text) { - assert.equal(matches.get("content")!.groups!.eventContent, toFind.text, "The report should contain the text we inserted in the event"); + assert.equal( + matches.get("content")!.groups!.eventContent, + toFind.text, + "The report should contain the text we inserted in the event", + ); } if (toFind.textPrefix) { - assert.ok(matches.get("content")!.groups!.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups!.eventContent}`); + assert.ok( + matches.get("content")!.groups!.eventContent.startsWith(toFind.textPrefix), + `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups!.eventContent}`, + ); } if (toFind.comment) { - assert.equal(matches.get("comments")!.groups!.comments, toFind.comment, "The report should contain the comment we added"); + assert.equal( + matches.get("comments")!.groups!.comments, + toFind.comment, + "The report should contain the comment we added", + ); } - assert.equal(matches.get("room")!.groups!.roomAliasOrId, roomId, "The report should specify the correct room"); + assert.equal( + matches.get("room")!.groups!.roomAliasOrId, + roomId, + "The report should specify the correct room", + ); assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); found.push(toFind); break; @@ -221,26 +288,28 @@ describe("Test: Reporting abuse", async () => { } }); } - it('The redact action works', async function() { + it("The redact action works", async function () { this.timeout(60000); // Listen for any notices that show up. - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); let notices: any[] = []; this.mjolnir.client.on("room.event", (roomId: string, event: any) => { - if (roomId = this.mjolnir.managementRoomId) { + if ((roomId = this.mjolnir.managementRoomId)) { notices.push(event); } }); // Create a moderator. - let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }}); + 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); // 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 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(); @@ -257,7 +326,7 @@ 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 badText = `BAD: ${Math.random()}`; // Will be reported as abuse. await goodUser.sendText(roomId, goodText); let badEventId = await badUser.sendText(roomId, badText); await goodUser.sendText(roomId, goodText); @@ -266,14 +335,17 @@ describe("Test: Reporting abuse", async () => { // Time to report. try { - await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); + await goodUser.doRequest( + "POST", + `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`, + ); } catch (e) { console.error("Could not send first report", e.body || e); throw e; } console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); let mjolnirRooms = new Set(await this.mjolnir.client.getJoinedRooms()); assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room"); @@ -318,7 +390,7 @@ describe("Test: Reporting abuse", async () => { } assert.ok(redactButtonId, "We should have found the redact button"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); // This should have triggered a confirmation request, with more buttons! let confirmEventId = null; @@ -344,10 +416,14 @@ describe("Test: Reporting abuse", async () => { } assert.ok(confirmEventId, "We should have found the confirm button"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); // This should have redacted the message. let newBadEvent = await this.mjolnir.client.getEvent(roomId, badEventId); - assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event"); + assert.deepEqual( + Object.keys(newBadEvent.content), + [], + "Redaction should have removed the content of the offending event", + ); }); }); diff --git a/test/integration/acceptInvitesFromSpaceTest.ts b/test/integration/acceptInvitesFromSpaceTest.ts index a538e8b0..8bb78f5a 100644 --- a/test/integration/acceptInvitesFromSpaceTest.ts +++ b/test/integration/acceptInvitesFromSpaceTest.ts @@ -1,17 +1,17 @@ import { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { Mjolnir } from "../../src/Mjolnir" +import { Mjolnir } from "../../src/Mjolnir"; import { newTestUser } from "./clientHelper"; -describe("Test: Accept Invites From Space", function() { - let client: MatrixClient|undefined; +describe("Test: Accept Invites From Space", function () { + let client: MatrixClient | undefined; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "spacee" }}); + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "spacee" } }); await client.start(); - }) + }); this.afterEach(async function () { await 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.timeout(20000); const mjolnir: Mjolnir = this.mjolnir!; @@ -20,7 +20,7 @@ describe("Test: Accept Invites From Space", function() { const space = await client.createSpace({ name: "mjolnir space invite test", invites: [mjolnirUserId], - isPublic: false + isPublic: false, }); await this.mjolnir.client.joinRoom(space.roomId); @@ -29,14 +29,14 @@ describe("Test: Accept Invites From Space", function() { mjolnir.config.autojoinOnlyIfManager = false; mjolnir.config.acceptInvitesFromSpace = space.roomId; - const promise = new Promise(async resolve => { + const promise = new Promise(async (resolve) => { const newRoomId = await client.createRoom({ invite: [mjolnirUserId] }); client.on("room.event", (roomId, event) => { if ( - roomId === newRoomId - && event.type === "m.room.member" - && event.sender === mjolnirUserId - && event.content?.membership === "join" + roomId === newRoomId && + event.type === "m.room.member" && + event.sender === mjolnirUserId && + event.content?.membership === "join" ) { resolve(null); } @@ -45,4 +45,3 @@ describe("Test: Accept Invites From Space", function() { await promise; }); }); - diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts index e0ca76ea..6ec056cf 100644 --- a/test/integration/banListTest.ts +++ b/test/integration/banListTest.ts @@ -21,7 +21,15 @@ import { MatrixSendClient } from "../../src/MatrixEmitter"; * @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}`) { +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, @@ -38,14 +46,20 @@ async function createPolicyRule(client: MatrixSendClient, policyRoomId: string, * @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}`) { +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() { +describe("Test: Updating the PolicyList", function () { + it("Calculates what has changed correctly.", async function () { this.timeout(10000); - const mjolnir: Mjolnir = this.mjolnir! + 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); @@ -54,189 +68,315 @@ describe("Test: Updating the PolicyList", function() { assert.equal(banList.allRules.length, 0); // Test adding a new rule - await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@added:localhost:9999', ''); + 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.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', ''); + 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'); + 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'); + 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'); + 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', ''); + 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); + 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.'); + 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, ''); + 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); + 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'); + 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'); + 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', ''); + 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'); + 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'); + 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() { + 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 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, ''); + 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...') + 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! + 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, ''); + 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, ''); + 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.'); + 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}`, {}); + 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.'); + 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! + 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}*`, ''); + 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 +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, '*', ''); + 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)); + 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 +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(); @@ -253,11 +393,13 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun // 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.'); - })); + 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] }); @@ -270,7 +412,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun 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, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); } // 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... @@ -282,30 +424,40 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun 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 30, but it was too low, 70 seems better for CI. - assert.equal(aclEventCount < 70 && aclEventCount > 2, true, 'We should have sent less than 70 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" } }); + 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 30, but it was too low, 70 seems better for CI. + assert.equal( + aclEventCount < 70 && aclEventCount > 2, + true, + "We should have sent less than 70 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(); @@ -320,23 +472,35 @@ describe('Test: unbaning entities via the PolicyList.', function() { 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.'); + 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 moderator.sendStateEvent(banListId, "org.matrix.mjolnir.shortcode", "", { shortcode: "unban-test" }); await mjolnir.client.joinRoom(banListId); await mjolnir.policyListManager.watchList(Permalinks.forRoom(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"); + 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(); @@ -345,7 +509,11 @@ describe('Test: unbaning entities via the PolicyList.', function() { 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 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)}`); @@ -355,8 +523,11 @@ describe('Test: unbaning entities via the PolicyList.', function() { 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 unban-test server ${server}` }); + await getFirstReaction(moderator, mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unban unban-test server ${server}`, + }); }); } } finally { @@ -369,15 +540,15 @@ describe('Test: unbaning entities via the PolicyList.', function() { 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 + 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(); @@ -394,10 +565,14 @@ describe('Test: should apply bans to the most recently active rooms first', func // 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.'); - })); + 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] }); @@ -413,8 +588,8 @@ describe('Test: should apply bans to the most recently active rooms first', func } // 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, 500)); + await moderator.sendMessage(roomId, { body: `activity`, msgtype: "m.text" }); + await new Promise((resolve) => setTimeout(resolve, 500)); } // check the rooms are in the expected order @@ -426,19 +601,19 @@ describe('Test: should apply bans to the most recently active rooms first', func 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 aclRooms: any[] = await new Promise(async (resolve) => { const rooms: any[] = []; - this.mjolnir.client.on('room.event', (room: string, event: 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) + 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(); @@ -452,16 +627,20 @@ describe('Test: should apply bans to the most recently active rooms first', func 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]); + 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.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`); + 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. @@ -473,37 +652,57 @@ 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! +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.'); + 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 }); + 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 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")); + 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.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 }); + 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")); + 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(policyList.roomRef); @@ -511,7 +710,7 @@ describe('Test: AccessControlUnit interaction with policy lists.', function() { 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.'); + 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); } @@ -519,64 +718,68 @@ describe('Test: AccessControlUnit interaction with policy lists.', function() { 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_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() + 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")); + 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.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'; + }); + 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())); + 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())); + 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())); + 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() { +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 + 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); @@ -584,13 +787,10 @@ describe('Test: Creating policy lists.', function() { 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 - ); - }) -}) + await assert.rejects(async () => { + await PolicyList.createList(mjolnir.client, randomUUID(), [], { + creation_content: {}, + }); + }, TypeError); + }); +}); diff --git a/test/integration/clientHelper.ts b/test/integration/clientHelper.ts index 3dcbb62a..64c6cc51 100644 --- a/test/integration/clientHelper.ts +++ b/test/integration/clientHelper.ts @@ -1,10 +1,6 @@ import { HmacSHA1 } from "crypto-js"; -import { - MatrixClient, - MemoryStorageProvider, - RustSdkCryptoStorageProvider -} from "@vector-im/matrix-bot-sdk"; -import { PathLike, promises as fs} from "fs"; +import { MatrixClient, MemoryStorageProvider, RustSdkCryptoStorageProvider } from "@vector-im/matrix-bot-sdk"; +import { PathLike, promises as fs } from "fs"; import axios from "axios"; const REGISTRATION_ATTEMPTS = 10; @@ -23,57 +19,65 @@ let CryptoStorePaths: any = []; * @param admin True to make the user an admin, false otherwise. * @returns The access token from logging in. */ -export async function registerUser(homeserver: string, username: string, displayname: string, password: string, admin: boolean): Promise { - let registerUrl = `${homeserver}/_synapse/admin/v1/register` - const response = await axios({method: 'get', url: registerUrl, timeout: 60000}) - const nonce = response.data.nonce - let mac = HmacSHA1(`${nonce}\0${username}\0${password}\0${admin ? 'admin' : 'notadmin'}`, 'REGISTRATION_SHARED_SECRET'); +export async function registerUser( + homeserver: string, + username: string, + displayname: string, + password: string, + admin: boolean, +): Promise { + let registerUrl = `${homeserver}/_synapse/admin/v1/register`; + const response = await axios({ method: "get", url: registerUrl, timeout: 60000 }); + const nonce = response.data.nonce; + let mac = HmacSHA1( + `${nonce}\0${username}\0${password}\0${admin ? "admin" : "notadmin"}`, + "REGISTRATION_SHARED_SECRET", + ); for (let i = 1; i <= REGISTRATION_ATTEMPTS; ++i) { const registerConfig = { url: registerUrl, method: "post", - headers: {"Content-Type": "application/json"}, + headers: { "Content-Type": "application/json" }, data: JSON.stringify({ nonce, username, displayname, password, admin, - mac: mac.toString() + mac: mac.toString(), }), - timeout: 60000 - } + timeout: 60000, + }; try { - let resp = await axios(registerConfig) - return resp.data?.access_token + let resp = await axios(registerConfig); + return resp.data?.access_token; } catch (ex) { - const code = ex.response.data.errcode + const code = ex.response.data.errcode; // In case of timeout or throttling, backoff and retry. - if (code === 'ESOCKETTIMEDOUT' || code === 'ETIMEDOUT' - || code === 'M_LIMIT_EXCEEDED') { - await new Promise(resolve => setTimeout(resolve, REGISTRATION_RETRY_BASE_DELAY_MS * i * i)); + if (code === "ESOCKETTIMEDOUT" || code === "ETIMEDOUT" || code === "M_LIMIT_EXCEEDED") { + await new Promise((resolve) => setTimeout(resolve, REGISTRATION_RETRY_BASE_DELAY_MS * i * i)); continue; } - if (code === 'M_USER_IN_USE') { - const loginUrl = `${homeserver}/_matrix/client/r0/login` + if (code === "M_USER_IN_USE") { + const loginUrl = `${homeserver}/_matrix/client/r0/login`; const loginConfig = { url: loginUrl, method: "post", - headers: {"Content-Type": "application/json"}, + headers: { "Content-Type": "application/json" }, data: JSON.stringify({ - "type": "m.login.password", - "identifier": { - "type": "m.id.user", - "user": username + type: "m.login.password", + identifier: { + type: "m.id.user", + user: username, }, - "password": password + password: password, }), - timeout: 60000 - } - let resp2 = await axios(loginConfig) - return resp2.data?.access_token + timeout: 60000, + }; + let resp2 = await axios(loginConfig); + return resp2.data?.access_token; } throw ex; } @@ -85,7 +89,7 @@ export type RegistrationOptions = { /** * If specified and true, make the user an admin. */ - isAdmin?: boolean, + isAdmin?: boolean; /** * If `exact`, use the account with this exact name, attempting to reuse * an existing account if possible. @@ -93,12 +97,12 @@ export type RegistrationOptions = { * If `contains` create a new account with a name that contains this * specific string. */ - name: { exact: string } | { contains: string }, + name: { exact: string } | { contains: string }; /** * If specified and true, throttle this user. */ - isThrottled?: boolean -} + isThrottled?: boolean; +}; /** * Register a new test user. @@ -112,7 +116,7 @@ async function registerNewTestUser(homeserver: string, options: RegistrationOpti if ("exact" in options.name) { username = options.name.exact; } else { - username = `mjolnir-test-user-${options.name.contains}${Math.floor(Math.random() * 100000)}` + username = `mjolnir-test-user-${options.name.contains}${Math.floor(Math.random() * 100000)}`; } try { accessToken = await registerUser(homeserver, username, username, username, Boolean(options.isAdmin)); @@ -120,7 +124,6 @@ async function registerNewTestUser(homeserver: string, options: RegistrationOpti } catch (e) { console.error(`failed to register user ${e}`); throw e; - } } while (true); } @@ -130,15 +133,19 @@ async function registerNewTestUser(homeserver: string, options: RegistrationOpti * * @returns A new `MatrixClient` session for a unique test user. */ -export async function newTestUser(homeserver: string, options: RegistrationOptions, encrypted = false): Promise { +export async function newTestUser( + homeserver: string, + options: RegistrationOptions, + encrypted = false, +): Promise { const accessToken = await registerNewTestUser(homeserver, options); let client; if (encrypted) { const cStore = await getTempCryptoStore(); client = new MatrixClient(homeserver, accessToken, new MemoryStorageProvider(), cStore); - await client.crypto.prepare() + await client.crypto.prepare(); } else { - client = new MatrixClient(homeserver, accessToken, new MemoryStorageProvider()) + client = new MatrixClient(homeserver, accessToken, new MemoryStorageProvider()); } if (!options.isThrottled) { @@ -160,8 +167,8 @@ async function getGlobalAdminUser(homeserver: string): Promise { let accessToken: string; const USERNAME = "mjolnir-test-internal-admin-user"; accessToken = await registerUser(homeserver, USERNAME, USERNAME, USERNAME, true); - _globalAdminUser = await new MatrixClient(homeserver, accessToken, new MemoryStorageProvider()) - await _globalAdminUser + _globalAdminUser = await new MatrixClient(homeserver, accessToken, new MemoryStorageProvider()); + await _globalAdminUser; } return _globalAdminUser; } @@ -171,9 +178,11 @@ async function getGlobalAdminUser(homeserver: string): Promise { * @param userId The user to disable ratelimiting for, has to include both the server part and local part. */ export async function overrideRatelimitForUser(homeserver: string, userId: string) { - await (await getGlobalAdminUser(homeserver)).doRequest("POST", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null, { - "messages_per_second": 0, - "burst_count": 0 + await ( + await getGlobalAdminUser(homeserver) + ).doRequest("POST", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null, { + messages_per_second: 0, + burst_count: 0, }); } @@ -182,10 +191,11 @@ export async function overrideRatelimitForUser(homeserver: string, userId: strin * @param userId The user to use default ratelimiting for, has to include both the server part and local part. */ export async function resetRatelimitForUser(homeserver: string, userId: string) { - await (await getGlobalAdminUser(homeserver)).doRequest("DELETE", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null); + await ( + await getGlobalAdminUser(homeserver) + ).doRequest("DELETE", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null); } - /** * Utility to create an event listener for m.notice msgtype m.room.messages. * @param targetRoomdId The roomId to listen into. @@ -197,14 +207,14 @@ export function noticeListener(targetRoomdId: string, cb: (event: any) => void) if (roomId !== targetRoomdId) return; if (event?.content?.msgtype !== "m.notice") return; cb(event); - } + }; } /** * Tears down temporary crypto stores created for testing */ export async function teardownCryptoStores() { - await Promise.all(CryptoStorePaths.map((p: PathLike) => fs.rm(p, { force: true, recursive: true}))); + await Promise.all(CryptoStorePaths.map((p: PathLike) => fs.rm(p, { force: true, recursive: true }))); CryptoStorePaths = []; } @@ -212,7 +222,7 @@ export async function teardownCryptoStores() { * Helper function to create temp crypto store for testing */ export async function getTempCryptoStore() { - const cryptoDir = await fs.mkdtemp('mjolnir-integration-test'); + const cryptoDir = await fs.mkdtemp("mjolnir-integration-test"); CryptoStorePaths.push(cryptoDir); return new RustSdkCryptoStorageProvider(cryptoDir, 0); } diff --git a/test/integration/commands/commandUtils.ts b/test/integration/commands/commandUtils.ts index d1c053bc..d7b727d2 100644 --- a/test/integration/commands/commandUtils.ts +++ b/test/integration/commands/commandUtils.ts @@ -11,7 +11,11 @@ import { MatrixEmitter } from "../../../src/MatrixEmitter"; * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @returns The replying event. */ -export async function getFirstReply(matrix: MatrixEmitter, targetRoom: string, targetEventThunk: () => Promise): Promise { +export async function getFirstReply( + matrix: MatrixEmitter, + targetRoom: string, + targetEventThunk: () => Promise, +): Promise { return getNthReply(matrix, targetRoom, 1, targetEventThunk); } @@ -24,25 +28,30 @@ export async function getFirstReply(matrix: MatrixEmitter, targetRoom: string, t * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @returns The replying event. */ -export async function getNthReply(matrix: MatrixEmitter, targetRoom: string, n: number, targetEventThunk: () => Promise): Promise { +export async function getNthReply( + matrix: MatrixEmitter, + targetRoom: string, + n: number, + targetEventThunk: () => Promise, +): Promise { if (Number.isNaN(n) || !Number.isInteger(n) || n <= 0) { throw new TypeError(`Invalid number of events ${n}`); } let reactionEvents: any[] = []; const addEvent = function (roomId: string, event: any) { if (roomId !== targetRoom) return; - if (event.type !== 'm.room.message') return; + if (event.type !== "m.room.message") return; reactionEvents.push(event); }; let targetCb; try { - matrix.on('room.event', addEvent) + matrix.on("room.event", addEvent); const targetEventId = await targetEventThunk(); - if (typeof targetEventId !== 'string') { + if (typeof targetEventId !== "string") { throw new TypeError(); } for (let event of reactionEvents) { - const in_reply_to = event.content['m.relates_to']?.['m.in_reply_to']; + const in_reply_to = event.content["m.relates_to"]?.["m.in_reply_to"]; if (in_reply_to?.event_id === targetEventId) { n -= 1; if (n === 0) { @@ -50,29 +59,28 @@ export async function getNthReply(matrix: MatrixEmitter, targetRoom: string, n: } } } - return await new Promise(resolve => { - targetCb = function(roomId: string, event: any) { + return await new Promise((resolve) => { + targetCb = function (roomId: string, event: any) { if (roomId !== targetRoom) return; - if (event.type !== 'm.room.message') return; - const in_reply_to = event.content['m.relates_to']?.['m.in_reply_to']; + if (event.type !== "m.room.message") return; + const in_reply_to = event.content["m.relates_to"]?.["m.in_reply_to"]; if (in_reply_to?.event_id === targetEventId) { n -= 1; if (n === 0) { resolve(event); } } - } - matrix.on('room.event', targetCb); + }; + matrix.on("room.event", targetCb); }); } finally { - matrix.removeListener('room.event', addEvent); + matrix.removeListener("room.event", addEvent); if (targetCb) { - matrix.removeListener('room.event', targetCb); + matrix.removeListener("room.event", targetCb); } } } - /** * Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk. * @param matrix A MatrixEmitter for a MatrixClient that is already in the targetRoom that can be started to listen for the event produced by targetEventThunk. @@ -82,38 +90,43 @@ export async function getNthReply(matrix: MatrixEmitter, targetRoom: string, n: * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction. * @returns The reaction event. */ -export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise): Promise { +export async function getFirstReaction( + matrix: MatrixEmitter, + targetRoom: string, + reactionKey: string, + targetEventThunk: () => Promise, +): Promise { let reactionEvents: any[] = []; const addEvent = function (roomId: string, event: any) { if (roomId !== targetRoom) return; - if (event.type !== 'm.reaction') return; + if (event.type !== "m.reaction") return; reactionEvents.push(event); }; let targetCb; try { - matrix.on('room.event', addEvent) + matrix.on("room.event", addEvent); const targetEventId = await targetEventThunk(); for (let event of reactionEvents) { - const relates_to = event.content['m.relates_to']; + const relates_to = event.content["m.relates_to"]; if (relates_to?.event_id === targetEventId && relates_to?.key === reactionKey) { return event; } } return await new Promise((resolve, reject) => { - targetCb = function(roomId: string, event: any) { + targetCb = function (roomId: string, event: any) { if (roomId !== targetRoom) return; - if (event.type !== 'm.reaction') return; - const relates_to = event.content['m.relates_to']; + if (event.type !== "m.reaction") return; + const relates_to = event.content["m.relates_to"]; if (relates_to?.event_id === targetEventId && relates_to?.key === reactionKey) { - resolve(event) + resolve(event); } - } - matrix.on('room.event', targetCb); + }; + matrix.on("room.event", targetCb); }); } finally { - matrix.removeListener('room.event', addEvent); + matrix.removeListener("room.event", addEvent); if (targetCb) { - matrix.removeListener('room.event', targetCb); + matrix.removeListener("room.event", targetCb); } } } @@ -125,11 +138,22 @@ export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string * @param client A client that isn't mjolnir to send the message with, as you will be invited to the room. * @returns The shortcode for the list that can be used to refer to the list in future commands. */ -export async function createBanList(managementRoom: string, mjolnir: MatrixEmitter, client: MatrixClient): Promise { +export async function createBanList( + managementRoom: string, + mjolnir: MatrixEmitter, + client: MatrixClient, +): Promise { const listName = crypto.randomUUID(); const listCreationResponse = await getFirstReply(mjolnir, managementRoom, async () => { - return await client.sendMessage(managementRoom, { msgtype: 'm.text', body: `!mjolnir list create ${listName} ${listName}`}); + return await client.sendMessage(managementRoom, { + msgtype: "m.text", + body: `!mjolnir list create ${listName} ${listName}`, + }); }); - assert.equal(listCreationResponse.content.body.includes('This list is now being watched.'), true, 'could not create a list to test with.'); + assert.equal( + listCreationResponse.content.body.includes("This list is now being watched."), + true, + "could not create a list to test with.", + ); return listName; } diff --git a/test/integration/commands/makedminCommandTest.ts b/test/integration/commands/makedminCommandTest.ts index d447d352..22addec3 100644 --- a/test/integration/commands/makedminCommandTest.ts +++ b/test/integration/commands/makedminCommandTest.ts @@ -13,7 +13,7 @@ describe("Test: The make admin command", function () { this.userC?.stop(); }); - it('Mjölnir make the bot self room administrator', async function () { + it("Mjölnir make the bot self room administrator", async function () { this.timeout(90000); if (!this.config.admin?.enableMakeRoomAdminCommand) { done(); @@ -30,20 +30,33 @@ describe("Test: The make admin command", function () { await moderator.joinRoom(this.config.managementRoom); LogService.debug("makeadminTest", `Joining managementRoom: ${this.config.managementRoom}`); let targetRoom = await moderator.createRoom({ invite: [mjolnirUserId], preset: "public_chat" }); - LogService.debug("makeadminTest", `moderator creating targetRoom: ${targetRoom}; and inviting ${mjolnirUserId}`); - await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}` }); + LogService.debug( + "makeadminTest", + `moderator creating targetRoom: ${targetRoom}; and inviting ${mjolnirUserId}`, + ); + await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir rooms add ${targetRoom}`, + }); LogService.debug("makeadminTest", `Adding targetRoom: ${targetRoom}`); // allow bot time to join room - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); try { await moderator.start(); await userA.start(); await userA.joinRoom(targetRoom); powerLevels = await mjolnir.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); - assert.notEqual(powerLevels["users"][mjolnirUserId], 100, `Bot should not yet be an admin of ${targetRoom}`); - await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => { + assert.notEqual( + powerLevels["users"][mjolnirUserId], + 100, + `Bot should not yet be an admin of ${targetRoom}`, + ); + await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, "✅", async () => { LogService.debug("makeadminTest", `Sending: !mjolnir make admin ${targetRoom}`); - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir make admin ${targetRoom}` }); + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir make admin ${targetRoom}`, + }); }); } finally { await moderator.stop(); @@ -53,10 +66,10 @@ describe("Test: The make admin command", function () { powerLevels = await mjolnir.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); assert.equal(powerLevels["users"][mjolnirUserId], 100, "Bot should be a room admin."); - assert.equal(powerLevels["users"][userAId], (0 || undefined), "User A is not supposed to be a room admin."); + assert.equal(powerLevels["users"][userAId], 0 || undefined, "User A is not supposed to be a room admin."); }); - it('Mjölnir make the tester room administrator', async function () { + it("Mjölnir make the tester room administrator", async function () { this.timeout(90000); if (!this.config.admin?.enableMakeRoomAdminCommand) { done(); @@ -77,7 +90,10 @@ describe("Test: The make admin command", function () { await moderator.joinRoom(this.mjolnir.managementRoomId); LogService.debug("makeadminTest", `Joining managementRoom: ${this.mjolnir.managementRoomId}`); let targetRoom = await userA.createRoom({ invite: [userBId, userCId] }); - LogService.debug("makeadminTest", `User A creating targetRoom: ${targetRoom}; and inviting ${userBId} and ${userCId}`); + LogService.debug( + "makeadminTest", + `User A creating targetRoom: ${targetRoom}; and inviting ${userBId} and ${userCId}`, + ); try { await userB.start(); await userC.start(); @@ -92,9 +108,12 @@ describe("Test: The make admin command", function () { await moderator.start(); powerLevels = await userA.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); assert.notEqual(powerLevels["users"][userBId], 100, `User B should not yet be an admin of ${targetRoom}`); - await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => { + await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, "✅", async () => { LogService.debug("makeadminTest", `Sending: !mjolnir make admin ${targetRoom} ${userBId}`); - return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir make admin ${targetRoom} ${userBId}` }); + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir make admin ${targetRoom} ${userBId}`, + }); }); } finally { await moderator.stop(); @@ -103,6 +122,6 @@ describe("Test: The make admin command", function () { powerLevels = await userA.getRoomStateEvent(targetRoom, "m.room.power_levels", ""); assert.equal(powerLevels["users"][userBId], 100, "User B should be a room admin."); - assert.equal(powerLevels["users"][userCId], (0 || undefined), "User C is not supposed to be a room admin."); + assert.equal(powerLevels["users"][userCId], 0 || undefined, "User C is not supposed to be a room admin."); }); }); diff --git a/test/integration/commands/redactCommandTest.ts b/test/integration/commands/redactCommandTest.ts index a824b406..0c22417b 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -5,123 +5,167 @@ import { getMessagesByUserIn } from "../../../src/utils"; import { LogService } from "@vector-im/matrix-bot-sdk"; import { getFirstReaction } from "./commandUtils"; - describe("Test: The redaction command", function () { +describe("Test: The redaction command", function () { // If a test has a timeout while awaitng on a promise then we never get given control back. - afterEach(function() { this.moderator?.stop(); }); + afterEach(function () { + this.moderator?.stop(); + }); - it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.', async function() { + it("Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.", async function () { this.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! + const mjolnir = this.config.RUNTIME.client!; let mjolnirUserId = await mjolnir.getUserId(); 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]}); + 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}`}); + moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir rooms add ${targetRoom}`, + }); LogService.debug("redactionTest", `targetRoom: ${targetRoom}, managementRoom: ${this.config.managementRoom}`); // Sandwich irrelevant messages in bad messages. - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); - await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); for (let i = 0; i < 5; i++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + 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 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 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.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir redact ${badUserId} ${targetRoom}`, + }); }); } finally { moderator.stop(); } - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) { - events.map(e => { - if (e.type === 'm.room.member') { - assert.equal(Object.keys(e.content).length, 1, "Only membership should be left on the membership even when it has been redacted.") + await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function (events) { + events.map((e) => { + if (e.type === "m.room.member") { + assert.equal( + Object.keys(e.content).length, + 1, + "Only membership should be left on the membership even when it has been redacted.", + ); } else if (Object.keys(e.content).length !== 0) { - throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`) + throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`); } - }) + }); }); - }) + }); - 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.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! + const mjolnir = this.config.RUNTIME.client!; let mjolnirUserId = await mjolnir.getUserId(); let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = moderator; await moderator.joinRoom(this.config.managementRoom); let targetRooms: string[] = []; for (let i = 0; i < 5; i++) { - let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]}); + 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(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir 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 badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + await Promise.all( + [...Array(50).keys()].map((j) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${j}` }), + ), + ); for (let j = 0; j < 5; j++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); } - await Promise.all([...Array(50).keys()].map((j) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${j}`}))); - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await 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, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir redact ${badUserId}`, + }); }); } finally { moderator.stop(); } - targetRooms.map(async targetRoom => { - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) { - events.map(e => { - if (e.type === 'm.room.member') { - assert.equal(Object.keys(e.content).length, 1, "Only membership should be left on the membership even when it has been redacted.") + targetRooms.map(async (targetRoom) => { + await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function (events) { + events.map((e) => { + if (e.type === "m.room.member") { + assert.equal( + Object.keys(e.content).length, + 1, + "Only membership should be left on the membership even when it has been redacted.", + ); } else if (Object.keys(e.content).length !== 0) { - throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`) + throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`); } - }) - }) + }); + }); }); }); it("Redacts a single event when instructed to.", async function () { 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! + const mjolnir = this.config.RUNTIME.client!; let mjolnirUserId = await mjolnir.getUserId(); 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]}); + 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}`}); - let eventToRedact = await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir 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, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`, + }); }); } finally { moderator.stop(); @@ -129,5 +173,5 @@ import { getFirstReaction } from "./commandUtils"; let redactedEvent = await moderator.getEvent(targetRoom, eventToRedact); assert.equal(Object.keys(redactedEvent.content).length, 0, "This event should have been redacted"); - }) + }); }); diff --git a/test/integration/commands/shutdownCommandTest.ts b/test/integration/commands/shutdownCommandTest.ts index 0ff5ea8c..7c79234f 100644 --- a/test/integration/commands/shutdownCommandTest.ts +++ b/test/integration/commands/shutdownCommandTest.ts @@ -2,28 +2,31 @@ import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; -describe("Test: shutdown command", function() { +describe("Test: shutdown command", function () { let client; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "shutdown-command" }}); + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "shutdown-command" } }); await client.start(); - }) + }); this.afterEach(async function () { await 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.timeout(20000); const badRoom = await client.createRoom(); await client.joinRoom(this.mjolnir.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`}); - client.on('room.event', (roomId, event) => { + const msgid = await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir shutdown room ${badRoom} closure test`, + }); + client.on("room.event", (roomId, event) => { if ( - roomId === this.mjolnir.managementRoomId - && event?.type === "m.reaction" - && event.sender === this.mjolnir.client.userId - && event.content?.["m.relates_to"]?.event_id === msgid + roomId === this.mjolnir.managementRoomId && + event?.type === "m.reaction" && + event.sender === this.mjolnir.client.userId && + event.content?.["m.relates_to"]?.event_id === msgid ) { resolve(event); } @@ -31,25 +34,24 @@ describe("Test: shutdown command", function() { }); const reply2 = new Promise((resolve, reject) => { - this.mjolnir.client.on('room.event', (roomId, event) => { + this.mjolnir.client.on("room.event", (roomId, event) => { if ( - roomId !== this.mjolnir.managementRoomId - && roomId !== badRoom - && event?.type === "m.room.message" - && event.sender === this.mjolnir.client.userId - && event.content?.body === "closure test" + roomId !== this.mjolnir.managementRoomId && + roomId !== badRoom && + event?.type === "m.room.message" && + event.sender === this.mjolnir.client.userId && + event.content?.body === "closure test" ) { resolve(event); } }); }); - await reply1 - await reply2 + await reply1; + await reply2; - await assert.rejects(client.joinRoom(badRoom), e => { + await assert.rejects(client.joinRoom(badRoom), (e) => { return e.message.endsWith('{"errcode":"M_UNKNOWN","error":"This room has been blocked on this server"}'); }); }); }); - diff --git a/test/integration/commands/suspendCommandTest.ts b/test/integration/commands/suspendCommandTest.ts index 1223197e..bc882304 100644 --- a/test/integration/commands/suspendCommandTest.ts +++ b/test/integration/commands/suspendCommandTest.ts @@ -14,78 +14,82 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {newTestUser} from "../clientHelper"; -import {strict as assert} from "assert"; +import { newTestUser } from "../clientHelper"; +import { strict as assert } from "assert"; import { MatrixClient, RoomCreateOptions } from "@vector-im/matrix-bot-sdk"; import { read as configRead } from "../../../src/config"; describe("Test: suspend/unsuspend command", function () { let admin: MatrixClient; let badUser: MatrixClient; - const config = configRead() + const config = configRead(); this.beforeEach(async () => { - admin = await newTestUser(config.homeserverUrl, { name: { contains: "suspend-command" }}); + admin = await newTestUser(config.homeserverUrl, { name: { contains: "suspend-command" } }); await admin.start(); - badUser = await newTestUser(config.homeserverUrl, {name: { contains: "bad-user"}}) + badUser = await newTestUser(config.homeserverUrl, { name: { contains: "bad-user" } }); await badUser.start(); - }) + }); this.afterEach(async function () { admin.stop(); badUser.stop(); - }) + }); - it("Mjolnir asks synapse to suspend and unsuspend a user", async function() { + it("Mjolnir asks synapse to suspend and unsuspend a user", async function () { this.timeout(20000); await admin.joinRoom(this.mjolnir.managementRoomId); - const roomOption: RoomCreateOptions = {preset: "public_chat"} + const roomOption: RoomCreateOptions = { preset: "public_chat" }; const room = await admin.createRoom(roomOption); - await badUser.joinRoom(room) - await admin.joinRoom(room) - const badUserID = await badUser.getUserId() + await badUser.joinRoom(room); + await admin.joinRoom(room); + const badUserID = await badUser.getUserId(); let reply = new Promise(async (resolve, reject) => { - await admin.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: `!mjolnir suspend ${badUserID}`}); - admin.on('room.event', (roomId, event) => { + await admin.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir suspend ${badUserID}`, + }); + admin.on("room.event", (roomId, event) => { if ( - roomId === this.mjolnir.managementRoomId - && event?.type === "m.room.message" - && event.sender === this.mjolnir.client.userId - && event.content?.body.endsWith(`User ${badUserID} has been suspended.`) + roomId === this.mjolnir.managementRoomId && + event?.type === "m.room.message" && + event.sender === this.mjolnir.client.userId && + event.content?.body.endsWith(`User ${badUserID} has been suspended.`) ) { resolve(event); } }); }); - await reply + await reply; try { - await badUser.sendMessage(room, {msgtype: "m.text", body: `testing`}) - assert.fail("Bad user successfully sent message.") - } - catch (error) { - assert.match(error.message, /ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED/i, ) + await badUser.sendMessage(room, { msgtype: "m.text", body: `testing` }); + assert.fail("Bad user successfully sent message."); + } catch (error) { + assert.match(error.message, /ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED/i); } let reply2 = new Promise(async (resolve, reject) => { - await admin.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: `!mjolnir unsuspend ${badUserID}`}); - admin.on('room.event', (roomId, event) => { + await admin.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unsuspend ${badUserID}`, + }); + admin.on("room.event", (roomId, event) => { if ( - roomId === this.mjolnir.managementRoomId - && event?.type === "m.room.message" - && event.sender === this.mjolnir.client.userId - && event.content?.body.endsWith(`User ${badUserID}'s suspension has been reversed.`) + roomId === this.mjolnir.managementRoomId && + event?.type === "m.room.message" && + event.sender === this.mjolnir.client.userId && + event.content?.body.endsWith(`User ${badUserID}'s suspension has been reversed.`) ) { resolve(event); } }); }); - await reply2 + await reply2; try { - await badUser.sendMessage(room, {msgtype: "m.text", body: `testing`}); - } - catch (error) { - assert.fail("Unable to send message, account not successfully unsuspended.") + await badUser.sendMessage(room, { msgtype: "m.text", body: `testing` }); + } catch (error) { + assert.fail("Unable to send message, account not successfully unsuspended."); } }); }); diff --git a/test/integration/detectFederationLagTest.ts b/test/integration/detectFederationLagTest.ts index 35e4192b..6c7126c3 100644 --- a/test/integration/detectFederationLagTest.ts +++ b/test/integration/detectFederationLagTest.ts @@ -17,11 +17,11 @@ const NUMBER_OF_LAGGING_FEDERATED_HOMESERVERS_ENTER_WARNING_ZONE = 2; const RE_STATS = /(\{(:?.|\n)*\})[^}]*$/m; -describe("Test: DetectFederationLag protection", function() { +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() { + beforeEach(async function () { // Setup an instance of DetectFederationLag this.detector = new DetectFederationLag(); await this.mjolnir.protectionManager.registerProtection(this.detector); @@ -37,7 +37,8 @@ describe("Test: DetectFederationLag protection", function() { // 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, + 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, @@ -57,20 +58,27 @@ describe("Test: DetectFederationLag protection", function() { 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, - }, + 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) + simulateDate(start, i), ); } }; this.getAlertEvent = async () => { try { - let event = await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, LAG_STATE_EVENT, this.protectedRoomId); + 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; @@ -85,31 +93,31 @@ describe("Test: DetectFederationLag protection", function() { 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 }); + 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 }); + 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() { + 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); + new Date(start.getTime() + (2 * progress * BUCKET_DURATION_MS) / SAMPLE_SIZE); - it('DetectFederationLag doesn\'t detect lag when there isn\'t any', async function() { + it("DetectFederationLag doesn't detect lag when there isn't any", async function () { this.timeout(60000); const MULTIPLIERS = [0, 0.5, 0.9]; @@ -124,22 +132,26 @@ describe("Test: DetectFederationLag protection", function() { 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`); + 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", - ]; + 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`); + 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(); + 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); @@ -147,12 +159,15 @@ describe("Test: DetectFederationLag protection", function() { 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 >= + 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() { + 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(); @@ -169,13 +184,17 @@ describe("Test: DetectFederationLag protection", function() { 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( + 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(); + 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); @@ -184,7 +203,7 @@ describe("Test: DetectFederationLag protection", function() { 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 }) + assert.deepEqual(globalStats, { [this.protectedRoomId]: protectedRoomStats }); } // Simulate non-lagging events from the local homeserver. After a while, this should rescind the alarm. @@ -196,7 +215,7 @@ describe("Test: DetectFederationLag protection", function() { assert.equal(await this.getAlertEvent(), null, "The alert should now be rescinded"); { - const {protectedRoomStats, globalStats} = await this.getCommandStatus(); + 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); @@ -206,11 +225,11 @@ describe("Test: DetectFederationLag protection", function() { 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 }) + assert.deepEqual(globalStats, { [this.protectedRoomId]: protectedRoomStats }); } }); - it('DetectFederationLag doesn\'t report lag when only one federated homeserver lags', async function() { + 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(); @@ -226,7 +245,7 @@ describe("Test: DetectFederationLag protection", function() { 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() { + 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(); @@ -236,28 +255,24 @@ describe("Test: DetectFederationLag protection", function() { 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", - ]; + 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( + 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 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); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index e572d507..9f3db296 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -1,10 +1,10 @@ import { read as configRead } from "../../src/config"; import { makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils"; -import dns from 'node:dns'; -import {teardownCryptoStores} from "./clientHelper"; +import dns from "node:dns"; +import { teardownCryptoStores } from "./clientHelper"; // Necessary for CI: Node 17+ defaults to using ipv6 first, but Github Actions does not support ipv6 -dns.setDefaultResultOrder('ipv4first'); +dns.setDefaultResultOrder("ipv4first"); // When Mjolnir starts (src/index.ts) it clobbers the config by resolving the management room // alias specified in the config (config.managementRoom) and overwriting that with the room ID. @@ -13,35 +13,35 @@ dns.setDefaultResultOrder('ipv4first'); // So there is some code in here to "undo" the mutation after we stop Mjolnir syncing. export const mochaHooks = { beforeEach: [ - async function() { + async function () { 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(); + const config = (this.config = configRead()); this.managementRoomAlias = config.managementRoom; this.mjolnir = await makeMjolnir(config); config.RUNTIME.client = this.mjolnir.client; await Promise.all([ - this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }), - this.mjolnir.client.setAccountData('org.matrix.mjolnir.watched_lists', { references: [] }), + this.mjolnir.client.setAccountData("org.matrix.mjolnir.protected_rooms", { rooms: [] }), + this.mjolnir.client.setAccountData("org.matrix.mjolnir.watched_lists", { references: [] }), ]); await this.mjolnir.start(); console.log("mochaHooks.beforeEach DONE"); - } + }, ], afterEach: [ - async function() { - this.timeout(10000) + async function () { + this.timeout(10000); await this.mjolnir.stop(); await Promise.all([ - this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }), - this.mjolnir.client.setAccountData('org.matrix.mjolnir.watched_lists', { references: [] }), + this.mjolnir.client.setAccountData("org.matrix.mjolnir.protected_rooms", { rooms: [] }), + this.mjolnir.client.setAccountData("org.matrix.mjolnir.watched_lists", { references: [] }), ]); // remove alias from management room and leave it. await teardownManagementRoom(this.mjolnir.client, this.mjolnir.managementRoomId, this.managementRoomAlias); - await teardownCryptoStores() + await teardownCryptoStores(); 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 3de2bb24..0ebc050a 100644 --- a/test/integration/helloTest.ts +++ b/test/integration/helloTest.ts @@ -1,29 +1,33 @@ import config from "../../src/config"; -import { newTestUser, noticeListener } from "./clientHelper" +import { newTestUser, noticeListener } from "./clientHelper"; -describe("Test: !help command", function() { +describe("Test: !help command", function () { let client; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "-" }});; + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "-" } }); await client.start(); - }) + }); this.afterEach(async function () { await client.stop(); - }) - it('Mjolnir responded to !mjolnir help', async function() { + }); + it("Mjolnir responded to !mjolnir help", async function () { 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")) { - resolve(event); - } - }))}); + client.on( + "room.message", + noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Print status information")) { + resolve(event); + } + }), + ); + }); // check we get one back console.log(config); - await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir help"}) - await reply - }) -}) + await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: "m.text", body: "!mjolnir help" }); + await reply; + }); +}); diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index eee1f094..5a1ac9ae 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -3,7 +3,7 @@ */ import { makeMjolnir } from "./mjolnirSetupUtils"; -import { read as configRead } from '../../src/config'; +import { read as configRead } from "../../src/config"; (async () => { const config = configRead(); diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index 3cb7e8db..bda36ae0 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -1,37 +1,42 @@ -import {newTestUser} from "./clientHelper"; +import { newTestUser } from "./clientHelper"; -import {MatrixClient} from "@vector-im/matrix-bot-sdk"; -import {getFirstReaction} from "./commands/commandUtils"; -import {strict as assert} from "assert"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { getFirstReaction } from "./commands/commandUtils"; +import { strict as assert } from "assert"; import { DEFAULT_MAX_MENTIONS } from "../../src/protections/MentionSpam"; describe("Test: Mention spam protection", function () { let client: MatrixClient; let room: string; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, {name: {contains: "mention-spam-protection"}}); + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "mention-spam-protection" } }); await client.start(); const mjolnirId = await this.mjolnir.client.getUserId(); room = await client.createRoom({ invite: [mjolnirId] }); await client.joinRoom(room); await client.joinRoom(this.config.managementRoom); await client.setUserPowerLevel(mjolnirId, room, 100); - }) + }); this.afterEach(async function () { await client.stop(); - }) + }); function delay(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } - - it("does not redact a normal message", async function() { - await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); - await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { - return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); + it("does not redact a normal message", async function () { + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${room}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: "!mjolnir enable MentionSpam", + }); }); - const testMessage = await client.sendText(room, 'Hello world'); + const testMessage = await client.sendText(room, "Hello world"); await delay(500); @@ -39,22 +44,30 @@ describe("Test: Mention spam protection", function () { assert.equal(Object.keys(fetchedEvent.content).length, 2, "This event should not have been redacted"); }); - it("does not redact a message with some mentions", async function() { - await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); - await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { - return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); + it("does not redact a message with some mentions", async function () { + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${room}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: "!mjolnir enable MentionSpam", + }); }); // Also covers HTML mentions - const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS}, (_, i) => `@user${i}:example.org`); - const messageWithTextMentions = await client.sendText(room, mentionUsers.join(' ')); - const messageWithHTMLMentions = await client.sendHtmlText(room, - mentionUsers.map(u => `${u}`).join(' ')); + const mentionUsers = Array.from({ length: DEFAULT_MAX_MENTIONS }, (_, i) => `@user${i}:example.org`); + const messageWithTextMentions = await client.sendText(room, mentionUsers.join(" ")); + const messageWithHTMLMentions = await client.sendHtmlText( + room, + mentionUsers.map((u) => `${u}`).join(" "), + ); const messageWithMMentions = await client.sendMessage(room, { - msgtype: 'm.text', - body: 'Hello world', - ['m.mentions']: { - user_ids: mentionUsers - } + msgtype: "m.text", + body: "Hello world", + ["m.mentions"]: { + user_ids: mentionUsers, + }, }); await delay(500); @@ -69,30 +82,38 @@ describe("Test: Mention spam protection", function () { assert.equal(Object.keys(fetchedMentionsEvent.content).length, 3, "This event should not have been redacted"); }); - it("does redact a message with too many mentions", async function() { - await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); - await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { - return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); + it("does redact a message with too many mentions", async function () { + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${room}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: "!mjolnir enable MentionSpam", + }); }); // Also covers HTML mentions - const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS+1}, (_, i) => `@user${i}:example.org`); - const mentionDisplaynames = Array.from({length: DEFAULT_MAX_MENTIONS+1}, (_, i) => `Test User ${i}`); + const mentionUsers = Array.from({ length: DEFAULT_MAX_MENTIONS + 1 }, (_, i) => `@user${i}:example.org`); + const mentionDisplaynames = Array.from({ length: DEFAULT_MAX_MENTIONS + 1 }, (_, i) => `Test User ${i}`); // Pre-set the displayname cache. - let protection = this.mjolnir.protectionManager.protections.get('MentionSpam') + let protection = this.mjolnir.protectionManager.protections.get("MentionSpam"); protection.roomDisplaynameCache.set(room, mentionDisplaynames); - const messageWithTextMentions = await client.sendText(room, mentionUsers.join(' ')); - const messageWithHTMLMentions = await client.sendHtmlText(room, - mentionUsers.map(u => `${u}`).join(' ')); + const messageWithTextMentions = await client.sendText(room, mentionUsers.join(" ")); + const messageWithHTMLMentions = await client.sendHtmlText( + room, + mentionUsers.map((u) => `${u}`).join(" "), + ); const messageWithMMentions = await client.sendMessage(room, { - msgtype: 'm.text', - body: 'Hello world', - ['m.mentions']: { - user_ids: mentionUsers - } + msgtype: "m.text", + body: "Hello world", + ["m.mentions"]: { + user_ids: mentionUsers, + }, }); - const messageWithDisplaynameMentions = await client.sendText(room, mentionDisplaynames.join(' ')); + const messageWithDisplaynameMentions = await client.sendText(room, mentionDisplaynames.join(" ")); await delay(500); @@ -112,12 +133,16 @@ describe("Test: Mention spam protection", function () { const messages = []; for (let i = 0; i < 10; i++) { let nextMessage = await client.sendText(room, `hello${i}`); - messages.push(nextMessage) + messages.push(nextMessage); } messages.forEach(async (eventID) => { await client.getEvent(room, eventID); - assert.equal(Object.keys(fetchedDisplaynameEvent.content).length, 0, "This event should have been redacted"); - }) + assert.equal( + Object.keys(fetchedDisplaynameEvent.content).length, + 0, + "This event should have been redacted", + ); + }); }); -}); \ No newline at end of file +}); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 381185cc..97f4c2b4 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -18,11 +18,11 @@ import { MemoryStorageProvider, LogService, LogLevel, - RichConsoleLogger + RichConsoleLogger, } from "@vector-im/matrix-bot-sdk"; -import { Mjolnir} from '../../src/Mjolnir'; -import {getTempCryptoStore, overrideRatelimitForUser, registerUser} from "./clientHelper"; +import { Mjolnir } from "../../src/Mjolnir"; +import { getTempCryptoStore, overrideRatelimitForUser, registerUser } from "./clientHelper"; import { initializeGlobalPerformanceMetrics, initializeSentry, patchMatrixClient } from "../../src/utils"; import { IConfig } from "../../src/config"; @@ -36,13 +36,13 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin try { return await client.resolveRoom(alias); } catch (e) { - if (e?.body?.errcode === 'M_NOT_FOUND') { - console.info(`${alias} hasn't been created yet, so we're making it now.`) + if (e?.body?.errcode === "M_NOT_FOUND") { + console.info(`${alias} hasn't been created yet, so we're making it now.`); let roomId = await client.createRoom({ visibility: "public", }); await client.createRoomAlias(alias, roomId); - return roomId + return roomId; } throw e; } @@ -53,9 +53,15 @@ async function configureMjolnir(config: IConfig) { initializeSentry(config); initializeGlobalPerformanceMetrics(config); - let accessToken = await registerUser(config.homeserverUrl, config.encryption.username, config.encryption.username, config.encryption.password, true) + let accessToken = await registerUser( + config.homeserverUrl, + config.encryption.username, + config.encryption.username, + config.encryption.password, + true, + ); - return accessToken + return accessToken; } export function mjolnir(): Mjolnir | null { @@ -64,7 +70,7 @@ export function mjolnir(): Mjolnir | null { export function matrixClient(): MatrixClient | null { return globalClient; } -let globalClient: MatrixClient | null +let globalClient: MatrixClient | null; let globalMjolnir: Mjolnir | null; /** @@ -72,12 +78,12 @@ let globalMjolnir: Mjolnir | null; */ export async function makeMjolnir(config: IConfig): Promise { let accessToken = await configureMjolnir(config); - let cryptoStore = await getTempCryptoStore() + let cryptoStore = await getTempCryptoStore(); LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); LogService.info("test/mjolnirSetupUtils", "Starting bot..."); let client = new MatrixClient(config.homeserverUrl, accessToken, new MemoryStorageProvider(), cryptoStore); - await client.crypto.prepare() + await client.crypto.prepare(); await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId()); patchMatrixClient(); diff --git a/test/integration/moderationRequestTest.ts b/test/integration/moderationRequestTest.ts index 9f776ea1..92c08b65 100644 --- a/test/integration/moderationRequestTest.ts +++ b/test/integration/moderationRequestTest.ts @@ -18,16 +18,20 @@ const EVENT_MODERATION_REQUEST = "org.matrix.msc3215.abuse.report"; enum SetupMechanism { ManualCommand, - Protection + Protection, } describe("Test: Requesting moderation", async () => { - it(`Mjölnir can setup a room for moderation requests using !mjolnir command`, async function() { + it(`Mjölnir can setup a room for moderation requests using !mjolnir command`, async function () { // Create a few users and a room, make sure that Mjölnir is moderator in the room. - let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); + let goodUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-good-user" }, + }); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" } }); - let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()] }); + let roomId = await goodUser.createRoom({ + invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()], + }); await goodUser.inviteUser(await badUser.getUserId(), roomId); await badUser.joinRoom(roomId); await goodUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); @@ -37,7 +41,7 @@ describe("Test: Requesting moderation", async () => { // Wait until moderated_by/moderator_of are setup while (true) { - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); try { await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); } catch (ex) { @@ -53,14 +57,18 @@ describe("Test: Requesting moderation", async () => { break; } }); - it(`Mjölnir can setup a room for moderation requests using room protections`, async function() { + it(`Mjölnir can setup a room for moderation requests using room protections`, async function () { await this.mjolnir.protectionManager.enableProtection("LocalAbuseReports"); // Create a few users and a room, make sure that Mjölnir is moderator in the room. - let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); + let goodUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-good-user" }, + }); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" } }); - let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()] }); + let roomId = await goodUser.createRoom({ + invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()], + }); await goodUser.inviteUser(await badUser.getUserId(), roomId); await badUser.joinRoom(roomId); await this.mjolnir.client.joinRoom(roomId); @@ -68,10 +76,10 @@ describe("Test: Requesting moderation", async () => { // Wait until Mjölnir has joined the room. while (true) { - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); const joinedRooms = await this.mjolnir.client.getJoinedRooms(); console.debug("Looking for room", roomId, "in", joinedRooms); - if (joinedRooms.some(joinedRoomId => joinedRoomId == roomId)) { + if (joinedRooms.some((joinedRoomId) => joinedRoomId == roomId)) { break; } else { console.log("Mjölnir hasn't joined the room yet, waiting"); @@ -83,7 +91,7 @@ describe("Test: Requesting moderation", async () => { // Wait until moderated_by/moderator_of are setup while (true) { - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); try { await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); } catch (ex) { @@ -99,25 +107,29 @@ describe("Test: Requesting moderation", async () => { break; } }); - it(`Mjölnir propagates moderation requests`, async function() { + it(`Mjölnir propagates moderation requests`, async function () { this.timeout(90000); // Listen for any notices that show up. let notices: any[] = []; this.mjolnir.client.on("room.event", (roomId, event) => { - if (roomId = this.mjolnir.managementRoomId) { + if ((roomId = this.mjolnir.managementRoomId)) { notices.push(event); } }); // Create a few users and a room, make sure that Mjölnir is moderator in the room. - let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); + let goodUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "reporting-abuse-good-user" }, + }); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" } }); let goodUserId = await goodUser.getUserId(); let badUserId = await badUser.getUserId(); - let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()] }); + let roomId = await goodUser.createRoom({ + invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()], + }); await goodUser.inviteUser(await badUser.getUserId(), roomId); await badUser.joinRoom(roomId); await goodUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); @@ -126,12 +138,12 @@ describe("Test: Requesting moderation", async () => { await this.mjolnir.client.sendText(this.mjolnir.managementRoomId, `!mjolnir rooms setup ${roomId} reporting`); // Prepare DM room to send moderation requests. - let dmRoomId = await goodUser.createRoom({ invite: [await this.mjolnir.client.getUserId() ]}); + let dmRoomId = await goodUser.createRoom({ invite: [await this.mjolnir.client.getUserId()] }); this.mjolnir.client.joinRoom(dmRoomId); // Wait until moderated_by/moderator_of are setup while (true) { - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); try { await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); } catch (ex) { @@ -150,11 +162,11 @@ describe("Test: Requesting moderation", async () => { console.log("Test: Requesting moderation - 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 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. - let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long. - let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines. + let badText4 = [...Array(1024)].map((_) => `${Math.random()}`).join(""); // Text is too long. + let badText5 = [...Array(1024)].map((_) => "ABC").join("\n"); // Text has too many lines. let badEventId = await badUser.sendText(roomId, badText); let badEventId2 = await badUser.sendText(roomId, badText2); let badEventId3 = await badUser.sendText(roomId, badText3); @@ -163,9 +175,21 @@ describe("Test: Requesting moderation", async () => { let badEvent2Comment = `COMMENT: ${Math.random()}`; console.log("Test: Requesting moderation - send reports"); - let reportsToFind: any[] = [] - - let sendReport = async ({eventId, nature, comment, text, textPrefix}: {eventId: string, nature: string, text?: string, textPrefix?: string, comment?: string}) => { + let reportsToFind: any[] = []; + + let sendReport = async ({ + eventId, + nature, + comment, + text, + textPrefix, + }: { + eventId: string; + nature: string; + text?: string; + textPrefix?: string; + comment?: string; + }) => { await goodUser.sendRawEvent(dmRoomId, EVENT_MODERATION_REQUEST, { event_id: eventId, room_id: roomId, @@ -186,23 +210,43 @@ describe("Test: Requesting moderation", async () => { }; // Without a comment. - await sendReport({ eventId: badEventId, nature: "org.matrix.msc3215.abuse.nature.disagreement", text: badText }); + await sendReport({ + eventId: badEventId, + nature: "org.matrix.msc3215.abuse.nature.disagreement", + text: badText, + }); // With a comment. - await sendReport({ eventId: badEventId2, nature: "org.matrix.msc3215.abuse.nature.toxic", text: badText2, comment: badEvent2Comment }); + await sendReport({ + eventId: badEventId2, + nature: "org.matrix.msc3215.abuse.nature.toxic", + text: badText2, + comment: badEvent2Comment, + }); // With html in the text. await sendReport({ eventId: badEventId3, nature: "org.matrix.msc3215.abuse.nature.illegal", text: badText3 }); // With a long text. - await sendReport({ eventId: badEventId4, nature: "org.matrix.msc3215.abuse.nature.spam", textPrefix: badText4.substring(0, 256) }); + await sendReport({ + eventId: badEventId4, + nature: "org.matrix.msc3215.abuse.nature.spam", + textPrefix: badText4.substring(0, 256), + }); // With a very long text. - await sendReport({ eventId: badEventId5, nature: "org.matrix.msc3215.abuse.nature.other", textPrefix: badText5.substring(0, 256).split("\n").join(" ") }); + await sendReport({ + eventId: badEventId5, + nature: "org.matrix.msc3215.abuse.nature.other", + textPrefix: badText5.substring(0, 256).split("\n").join(" "), + }); console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); let found: any[] = []; 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; } @@ -229,28 +273,73 @@ describe("Test: Requesting moderation", async () => { assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); - assert.equal(matches.get("event")!.groups!.eventId, toFind.eventId, "The report should specify the correct event id");; - - assert.equal(matches.get("reporter")!.groups!.reporterId, toFind.reporterId, "The report should specify the correct reporter"); - assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter"); - assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups!.reporterDisplay), "The report should display the correct reporter"); - - assert.equal(matches.get("accused")!.groups!.accusedId, toFind.accusedId, "The report should specify the correct accused"); - assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused"); - assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups!.accusedDisplay), "The report should display the correct reporter"); + assert.equal( + matches.get("event")!.groups!.eventId, + toFind.eventId, + "The report should specify the correct event id", + ); + + assert.equal( + matches.get("reporter")!.groups!.reporterId, + toFind.reporterId, + "The report should specify the correct reporter", + ); + assert.equal( + report.reporter_id, + toFind.reporterId, + "The embedded report should specify the correct reporter", + ); + assert.ok( + toFind.reporterId.includes(matches.get("reporter")!.groups!.reporterDisplay), + "The report should display the correct reporter", + ); + + assert.equal( + matches.get("accused")!.groups!.accusedId, + toFind.accusedId, + "The report should specify the correct accused", + ); + assert.equal( + report.accused_id, + toFind.accusedId, + "The embedded report should specify the correct accused", + ); + assert.ok( + toFind.accusedId.includes(matches.get("accused")!.groups!.accusedDisplay), + "The report should display the correct reporter", + ); if (toFind.text) { - assert.equal(matches.get("content")!.groups!.eventContent, toFind.text, "The report should contain the text we inserted in the event"); + assert.equal( + matches.get("content")!.groups!.eventContent, + toFind.text, + "The report should contain the text we inserted in the event", + ); } if (toFind.textPrefix) { - assert.ok(matches.get("content")!.groups!.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups!.eventContent}`); + assert.ok( + matches.get("content")!.groups!.eventContent.startsWith(toFind.textPrefix), + `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups!.eventContent}`, + ); } if (toFind.comment) { - assert.equal(matches.get("comments")!.groups!.comments, toFind.comment, "The report should contain the comment we added"); + assert.equal( + matches.get("comments")!.groups!.comments, + toFind.comment, + "The report should contain the comment we added", + ); } - assert.equal(matches.get("room")!.groups!.roomAliasOrId, roomId, "The report should specify the correct room"); + assert.equal( + matches.get("room")!.groups!.roomAliasOrId, + roomId, + "The report should specify the correct room", + ); assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); - assert.equal(matches.get("nature")!.groups!.natureSource, toFind.nature, "The report should specify the correct nature"); + assert.equal( + matches.get("nature")!.groups!.natureSource, + toFind.nature, + "The report should specify the correct nature", + ); found.push(toFind); break; } @@ -259,25 +348,27 @@ describe("Test: Requesting moderation", async () => { assert.deepEqual(found, reportsToFind, `Found ${found.length} reports out of ${reportsToFind.length}`); }); - it('The redact action works', async function() { + it("The redact action works", async function () { this.timeout(60000); // Listen for any notices that show up. let notices: any[] = []; this.mjolnir.client.on("room.event", (roomId, event) => { - if (roomId = this.mjolnir.managementRoomId) { + if ((roomId = this.mjolnir.managementRoomId)) { notices.push(event); } }); // Create a moderator. - let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }}); + 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); // 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 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(); @@ -295,12 +386,12 @@ describe("Test: Requesting moderation", async () => { await this.mjolnir.client.sendText(this.mjolnir.managementRoomId, `!mjolnir rooms setup ${roomId} reporting`); // Prepare DM room to send moderation requests. - let dmRoomId = await goodUser.createRoom({ invite: [await this.mjolnir.client.getUserId() ]}); + let dmRoomId = await goodUser.createRoom({ invite: [await this.mjolnir.client.getUserId()] }); this.mjolnir.client.joinRoom(dmRoomId); // Wait until moderated_by/moderator_of are setup while (true) { - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); try { await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); } catch (ex) { @@ -319,7 +410,7 @@ describe("Test: Requesting moderation", 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 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); @@ -335,9 +426,8 @@ describe("Test: Requesting moderation", async () => { reporter: goodUserId, }); - console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); let mjolnirRooms = new Set(await this.mjolnir.client.getJoinedRooms()); assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room"); @@ -382,7 +472,7 @@ describe("Test: Requesting moderation", async () => { } assert.ok(redactButtonId, "We should have found the redact button"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); // This should have triggered a confirmation request, with more buttons! let confirmEventId = null; @@ -408,10 +498,14 @@ describe("Test: Requesting moderation", async () => { } assert.ok(confirmEventId, "We should have found the confirm button"); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); // This should have redacted the message. let newBadEvent = await this.mjolnir.client.getEvent(roomId, badEventId); - assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event"); + assert.deepEqual( + Object.keys(newBadEvent.content), + [], + "Redaction should have removed the content of the offending event", + ); }); -}); \ No newline at end of file +}); diff --git a/test/integration/nsfwProtectionTest.ts b/test/integration/nsfwProtectionTest.ts index c86fd384..ed215e09 100644 --- a/test/integration/nsfwProtectionTest.ts +++ b/test/integration/nsfwProtectionTest.ts @@ -1,42 +1,47 @@ -import {newTestUser} from "./clientHelper"; +import { newTestUser } from "./clientHelper"; -import {MatrixClient} from "@vector-im/matrix-bot-sdk"; -import {getFirstReaction} from "./commands/commandUtils"; -import {strict as assert} from "assert"; -import { readFileSync } from 'fs'; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { getFirstReaction } from "./commands/commandUtils"; +import { strict as assert } from "assert"; +import { readFileSync } from "fs"; describe("Test: NSFW protection", function () { let client: MatrixClient; let room: string; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, {name: {contains: "nsfw-protection"}}); + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "nsfw-protection" } }); await client.start(); const mjolnirId = await this.mjolnir.client.getUserId(); room = await client.createRoom({ invite: [mjolnirId] }); await client.joinRoom(room); await client.joinRoom(this.config.managementRoom); await client.setUserPowerLevel(mjolnirId, room, 100); - }) + }); this.afterEach(async function () { await client.stop(); - }) + }); function delay(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } - - it("Nsfw protection doesn't redact sfw images", async function() { + it("Nsfw protection doesn't redact sfw images", async function () { this.timeout(20000); - await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); - await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { - return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir enable NsfwProtection` }); + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${room}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir enable NsfwProtection`, + }); }); - const data = readFileSync('test_tree.jpg'); - const mxc = await client.uploadContent(data, 'image/png'); - let content = {"msgtype": "m.image", "body": "test.jpeg", "url": mxc}; + const data = readFileSync("test_tree.jpg"); + const mxc = await client.uploadContent(data, "image/png"); + let content = { msgtype: "m.image", body: "test.jpeg", url: mxc }; let imageMessage = await client.sendMessage(room, content); await delay(500); @@ -44,35 +49,41 @@ describe("Test: NSFW protection", function () { assert.equal(Object.keys(processedImage.content).length, 3, "This event should not have been redacted"); }); - it("Nsfw protection redacts nsfw images", async function() { + it("Nsfw protection redacts nsfw images", async function () { this.timeout(20000); // dial the sensitivity on the protection way up so that all images are flagged as NSFW this.mjolnir.config.nsfwSensitivity = 0.0; - await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); - await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { - return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir enable NsfwProtection` }); + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${room}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir enable NsfwProtection`, + }); }); - const data = readFileSync('test_tree.jpg'); - const mxc = await client.uploadContent(data, 'image/png'); - let content = {"msgtype": "m.image", "body": "test.jpeg", "url": mxc}; + const data = readFileSync("test_tree.jpg"); + const mxc = await client.uploadContent(data, "image/png"); + let content = { msgtype: "m.image", body: "test.jpeg", url: mxc }; let imageMessage = await client.sendMessage(room, content); - let formatted_body = `` + let formatted_body = ``; let htmlContent = { - msgtype: "m.image", - body: formatted_body, - format: "org.matrix.custom.html", - formatted_body: formatted_body - }; - let htmlMessage = await client.sendMessage(room, htmlContent) + msgtype: "m.image", + body: formatted_body, + format: "org.matrix.custom.html", + formatted_body: formatted_body, + }; + let htmlMessage = await client.sendMessage(room, htmlContent); await delay(500); let processedImage = await client.getEvent(room, imageMessage); assert.equal(Object.keys(processedImage.content).length, 0, "This event should have been redacted"); - let processedHtml = await client.getEvent(room, htmlMessage) - assert.equal(Object.keys(processedHtml.content).length, 0, "This html image event should have been redacted") + let processedHtml = await client.getEvent(room, htmlMessage); + assert.equal(Object.keys(processedHtml.content).length, 0, "This html image event should have been redacted"); }); -}); \ No newline at end of file +}); diff --git a/test/integration/openMetricsTest.ts b/test/integration/openMetricsTest.ts index 61da8d57..709f13a0 100644 --- a/test/integration/openMetricsTest.ts +++ b/test/integration/openMetricsTest.ts @@ -11,21 +11,24 @@ async function fetchMetrics(config: IConfig): Promise { uri.port = `${config.health.openMetrics!.port}`; uri.pathname = config.health.openMetrics!.endpoint; return await new Promise((resolve, reject) => - getRequestFn()({ - method: "GET", - uri - }, (error: object, _response: any, body: string) => { - if (error) { - reject(error); - } else { - resolve(body); - } - }) + getRequestFn()( + { + method: "GET", + uri, + }, + (error: object, _response: any, body: string) => { + if (error) { + reject(error); + } else { + resolve(body); + } + }, + ), ); } -describe("Test that we can read metrics using the API", function() { - it('can fetch default metrics', async function() { +describe("Test that we can read metrics using the API", function () { + it("can fetch default metrics", async function () { console.debug("config", this.mjolnir.config); let metrics = await fetchMetrics(this.mjolnir.config); console.debug("Got metrics", metrics); @@ -36,8 +39,12 @@ describe("Test that we can read metrics using the API", function() { } // Sample of metrics that we're injecting. - for (let name of ["mjolnir_performance_http_request_sum", "mjolnir_status_api_request_pass", "mjolnir_status_api_request_fail"]) { + for (let name of [ + "mjolnir_performance_http_request_sum", + "mjolnir_status_api_request_pass", + "mjolnir_status_api_request_fail", + ]) { assert(metrics.includes(name), `Metrics should contain custom metric \`${name}\``); } - }) -}); \ No newline at end of file + }); +}); diff --git a/test/integration/policyConsumptionTest.ts b/test/integration/policyConsumptionTest.ts index 7decb7fe..f801d41f 100644 --- a/test/integration/policyConsumptionTest.ts +++ b/test/integration/policyConsumptionTest.ts @@ -9,17 +9,22 @@ 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: string) => { - if (error) { - reject(error) - } else { - resolve(JSON.parse(body)) - } - })); +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: string) => { + if (error) { + reject(error); + } else { + resolve(JSON.parse(body)); + } + }, + ), + ); } /** @@ -30,123 +35,186 @@ 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)}`); + 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 => { + 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 => { + await new Promise((resolve) => { setTimeout(resolve, 1500); - }) - LogService.debug('policyConsumptionTest', `Rules after they have changed: ${JSON.stringify(rules, null, 2)}`); + }); + 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)}`); + 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', { + await this.mjolnir.client.setAccountData("org.matrix.mjolnir.watched_lists", { references: [], }); } - }) - this.beforeAll(async function() { + }); + 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") + 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() { + }); + 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 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" }}); + 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()]}); + 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 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 ${banList} ${badUserId}` }); + await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir ban ${banList} ${badUserId}`, + }); }); }); - 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.'); + 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 ${banList} ${badUserId}` }); + await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unban ${banList} ${badUserId}`, + }); }); }); - assert.ok(await badUser.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'})); + 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 () { + }); + 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" }}); + 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 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 ${banList} ${badRoom}` }); + await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir ban ${banList} ${badRoom}`, + }); }); }); - 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'); + 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 ${banList} ${badRoom}` }); + await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unban ${banList} ${badRoom}`, + }); }); }); - 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 () { + 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" }}); + 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 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 ${banList} ${targetRoom}` }); + await getFirstReaction(this.mjolnir.client, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir ban ${banList} ${targetRoom}`, + }); }); }); - 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 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` }); + 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.'); - }) + assert.ok( + await moderator.sendMessage(targetRoom, { msgtype: "m.text", body: "test" }), + "should now be able to send messages to the room.", + ); + }); }); diff --git a/test/integration/protectedRoomsConfigTest.ts b/test/integration/protectedRoomsConfigTest.ts index d85196c8..82694205 100644 --- a/test/integration/protectedRoomsConfigTest.ts +++ b/test/integration/protectedRoomsConfigTest.ts @@ -1,4 +1,3 @@ - import { strict as assert } from "assert"; import { MatrixClient, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; import { MatrixSendClient } from "../../src/MatrixEmitter"; @@ -18,8 +17,8 @@ async function getProtectedRoomsFromAccountData(client: MatrixSendClient): Promi return rooms.rooms!; } -describe('Test: config.protectAllJoinedRooms behaves correctly.', function() { - it('does not clobber the account data.', async function() { +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!; @@ -27,20 +26,20 @@ describe('Test: config.protectAllJoinedRooms behaves correctly.', function() { 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)) + [...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)); - + (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)); + 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); @@ -60,6 +59,5 @@ describe('Test: config.protectAllJoinedRooms behaves correctly.', function() { // Confirm that it is the right room, since we only get the shortcode back when using the command to create a list. const shortcodeInfo = await mjolnir.client.getRoomStateEvent(policyListId, "org.matrix.mjolnir.shortcode", ""); assert.equal(shortcodeInfo.shortcode, policyListShortcode); - }) + }); }); - diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts index 342dcfe2..52031534 100644 --- a/test/integration/protectionSettingsTest.ts +++ b/test/integration/protectionSettingsTest.ts @@ -3,56 +3,64 @@ import { strict as assert } from "assert"; import { Mjolnir } from "../../src/Mjolnir"; import { Protection } from "../../src/protections/IProtection"; import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; -import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; +import { + NumberProtectionSetting, + StringProtectionSetting, + StringListProtectionSetting, +} from "../../src/protections/ProtectionSettings"; import { newTestUser, noticeListener } from "./clientHelper"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; -import {MessageIsMedia} from "../../src/protections/MessageIsMedia"; +import { MessageIsMedia } from "../../src/protections/MessageIsMedia"; -describe("Test: Protection settings", function() { +describe("Test: Protection settings", function () { let client; let room; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }}); + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" } }); await client.start(); room = await client.createRoom(); - await client.joinRoom(room) - }) + await client.joinRoom(room); + }); this.afterEach(async function () { await client.stop(); - }) - it("Mjolnir refuses to save invalid protection setting values", async function() { + }); + 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 + async () => + await this.mjolnir.protectionManager.setProtectionSettings("BasicFloodingProtection", { + maxPerMinute: "soup", + }), + ProtectionSettingValidationError, ); }); - it("Mjolnir successfully saves valid protection setting values", async function() { + 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.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 - ); + assert.equal((await this.mjolnir.protectionManager.getProtectionSettings("05OVMS"))["test"], 123); }); - it("Mjolnir should accumulate changed settings", async function() { + 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.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 }); @@ -60,135 +68,181 @@ describe("Test: Protection settings", function() { assert.equal(settings["test1"], 1); assert.equal(settings["test2"], 2); }); - it("Mjolnir responds to !set correctly", async function() { + it("Mjolnir responds to !set correctly", async function () { this.timeout(20000); 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() }; - }); - + 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); - } - })) + 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 + 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() { + it("Mjolnir adds a value to a list setting", async function () { this.timeout(20000); 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() }; - }); - + 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); - } - })) + 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 + 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"] }); + assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("r33XyT"), { test: ["asd"] }); }); - it("Mjolnir removes a value from a list setting", async function() { + it("Mjolnir removes a value from a list setting", async function () { this.timeout(20000); 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() }; - }); + 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); - } - })) + 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 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 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": [] }); + assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("oXzT0E"), { test: [] }); }); - it("Mjolnir will change a protection setting in-place", async function() { + it("Mjolnir will change a protection setting in-place", async function () { this.timeout(20000); 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() }; - }); + await this.mjolnir.protectionManager.registerProtection( + new (class extends Protection { + name = "d0sNrt"; + description = "A test protection"; + settings = { test: new StringProtectionSetting() }; + })(), + ); - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); let replyPromise: Promise = 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); + 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).content.body.split("\n", 3)[2], - "Changed d0sNrt.test to asd2 (was asd1)" - ) + 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).content.body.split("\n", 3)[2], "Changed d0sNrt.test to asd2 (was asd1)"); }); - it("Events are checked for new content under media protections", async function() { + it("Events are checked for new content under media protections", async function () { this.timeout(20000); await client.joinRoom(this.config.managementRoom); await this.mjolnir.protectionManager.registerProtection(new MessageIsMedia()); // send a regular media message to make sure protections are running - await client.sendMessage(room, {msgtype: "m.image", body: ""}) - let reply = () => new Promise((resolve, reject) => { - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Redacting event")) { - resolve(event); - } - })) - }); + await client.sendMessage(room, { msgtype: "m.image", body: "" }); + let reply = () => + new Promise((resolve, reject) => { + client.on( + "room.message", + noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Redacting event")) { + resolve(event); + } + }), + ); + }); await reply; - await client.sendMessage(room, {body: "", msgtype: "m.text", "m.new_content": {msgtype: "m.image", body: ""}, "m.relates_to": {"rel_type": "m.replace"}}) - let reply2 = () => new Promise((resolve, reject) => { - client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { - if (event.content.body.includes("Redacting event")) { - resolve(event); - } - })) + await client.sendMessage(room, { + "body": "", + "msgtype": "m.text", + "m.new_content": { msgtype: "m.image", body: "" }, + "m.relates_to": { rel_type: "m.replace" }, }); + let reply2 = () => + new Promise((resolve, reject) => { + client.on( + "room.message", + noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Redacting event")) { + resolve(event); + } + }), + ); + }); await reply2; }); }); - diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index e98f60f5..d75c87eb 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -2,40 +2,42 @@ import { Mjolnir } from "../../src/Mjolnir"; import { Protection } from "../../src/protections/IProtection"; import { newTestUser } from "./clientHelper"; -describe("Test: Report polling", function() { +describe("Test: Report polling", function () { let client; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }}); - }) - it("Mjolnir correctly retrieves a report from synapse", async function() { + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" } }); + }); + it("Mjolnir correctly retrieves a report from synapse", async function () { this.timeout(40000); let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await client.getUserId()] }); await client.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); + const eventId = await client.sendMessage(protectedRoomId, { msgtype: "m.text", body: "uwNd3q" }); class CustomProtection extends Protection { name = "jYvufI"; description = "A test protection"; - settings = { }; + settings = {}; constructor(private resolve) { super(); } - async handleReport (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) { + async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) { if (reason === "x5h1Je") { this.resolve(null); } } } - await new Promise(async resolve => { + await new Promise(async (resolve) => { await this.mjolnir.protectionManager.registerProtection(new CustomProtection(resolve)); await this.mjolnir.protectionManager.enableProtection("jYvufI"); await client.doRequest( "POST", - `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { - reason: "x5h1Je" - } + `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, + "", + { + reason: "x5h1Je", + }, ); }); // So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26) @@ -44,7 +46,6 @@ describe("Test: Report polling", function() { // Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling. // Ok, well apparently that needs a big refactor to change, but if you change the config before running this test, // then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326. - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); }); }); - diff --git a/test/integration/roomMembersTest.ts b/test/integration/roomMembersTest.ts index 493f9c7a..90ebbb06 100644 --- a/test/integration/roomMembersTest.ts +++ b/test/integration/roomMembersTest.ts @@ -5,14 +5,11 @@ 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() { +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" - ]; + const ROOMS = ["!room_0@localhost", "!room_1@localhost"]; for (let room of ROOMS) { manager.addRoom(room); } @@ -24,12 +21,12 @@ describe("Test: Testing RoomMemberManager", function() { const SAMPLE_SIZE = 100; for (let i = 0; i < SAMPLE_SIZE; ++i) { const event = { - type: 'm.room.member', + type: "m.room.member", state_key: userId(i), sender: userId(i), content: { - membership: "join" - } + membership: "join", + }, }; await manager.handleEvent(ROOMS[i % ROOMS.length], event, joinDate(i)); } @@ -38,15 +35,19 @@ describe("Test: Testing RoomMemberManager", function() { 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])); + 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`); + assert.equal( + ts, + joinDate(i).getTime(), + `User ${user} should have been seen joining the room at the right timestamp`, + ); map.delete(user); } @@ -60,17 +61,17 @@ describe("Test: Testing RoomMemberManager", function() { for (let i = 0; i < SAMPLE_SIZE / 3; ++i) { const user = userId(i * 3); const event = { - type: 'm.room.member', + type: "m.room.member", state_key: user, sender: user, content: { - membership: "leave" + membership: "leave", }, unsigned: { prev_content: { - membership: "join" - } - } + membership: "join", + }, + }, }; await manager.handleEvent(ROOMS[0], event, leaveDate(i)); await manager.handleEvent(ROOMS[1], event, leaveDate(i)); @@ -81,8 +82,8 @@ describe("Test: Testing RoomMemberManager", function() { 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])); + 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); @@ -91,7 +92,11 @@ describe("Test: Testing RoomMemberManager", function() { 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`); + 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`); @@ -108,19 +113,19 @@ describe("Test: Testing RoomMemberManager", function() { for (let i = 0; i < SAMPLE_SIZE / 9; ++i) { const user = userId(i * 9); const event = { - type: 'm.room.member', + type: "m.room.member", state_key: user, sender: user, content: { - membership: "join" + membership: "join", }, unsigned: { prev_content: { - membership: "leave" - } - } + membership: "leave", + }, + }, }; - const room = ROOMS[i * 9 % 2]; + const room = ROOMS[(i * 9) % 2]; await manager.handleEvent(room, event, rejoinDate(i * 9)); } @@ -129,8 +134,8 @@ describe("Test: Testing RoomMemberManager", function() { 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])); + 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); @@ -140,13 +145,21 @@ describe("Test: Testing RoomMemberManager", function() { 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}`); + 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`); + assert.equal( + ts, + joinDate(i).getTime(), + `User ${user} should have been seen joining the room at the right timestamp`, + ); map.delete(user); } } @@ -160,8 +173,8 @@ describe("Test: Testing RoomMemberManager", function() { 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])); + 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); @@ -170,10 +183,17 @@ describe("Test: Testing RoomMemberManager", function() { 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}`); + 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.ok( + !ts, + `When looking only at recent entries, user ${user} should not be seen as a member of room ${i % 2} anymore`, + ); } } @@ -191,8 +211,8 @@ describe("Test: Testing RoomMemberManager", function() { 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])); + 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); @@ -202,13 +222,24 @@ describe("Test: Testing RoomMemberManager", function() { 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}`); + 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`); + 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`); + assert.equal( + ts, + joinDate(i).getTime(), + `After cleanup, user ${user} should have been seen joining the room at the right timestamp`, + ); map.delete(user); } } @@ -222,8 +253,8 @@ describe("Test: Testing RoomMemberManager", function() { 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])); + 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); @@ -232,19 +263,34 @@ describe("Test: Testing RoomMemberManager", function() { 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}`); + 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.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"); + 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() { + afterEach(async function () { await this.moderator?.stop(); for (let array of [this.users, this.goodUsers, this.badUsers]) { for (let client of array || []) { @@ -253,21 +299,23 @@ describe("Test: Testing RoomMemberManager", function() { } }); - it("RoomMemberManager counts correctly when we actually join/leave/get banned from the room", async function() { + 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!; // Setup a moderator. this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); - await mjolnir.client.inviteUser(await this.moderator.getUserId(), mjolnir.managementRoomId) + 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` } })); + this.users.push( + await newTestUser(this.config.homeserverUrl, { name: { contains: `user_${i}_room_member_test` } }), + ); } const userIds = []; for (let client of this.users) { @@ -284,7 +332,10 @@ describe("Test: Testing RoomMemberManager", function() { const roomIds = [roomId1, roomId2]; for (let roomId of roomIds) { - await this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${roomId}` }); + await this.moderator.sendMessage(mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${roomId}`, + }); } let protectedRoomsUpdated = false; @@ -294,25 +345,28 @@ describe("Test: Testing RoomMemberManager", function() { for (let roomId of roomIds) { if (!protectedRooms.includes(roomId)) { protectedRoomsUpdated = false; - await new Promise(resolve => setTimeout(resolve, 1_000)); + 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"); + 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 status joins ${roomId}`; - return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); + 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"); @@ -327,18 +381,31 @@ describe("Test: Testing RoomMemberManager", function() { 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"); + 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 status joins ${roomId}`; - return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); + 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`); + 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}`); + 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}`); + assert.ok( + !body.includes(userIds[j]), + `After joins, the command should NOT display user ${userIds[j]} in room ${roomId}`, + ); } } } @@ -363,21 +430,27 @@ describe("Test: Testing RoomMemberManager", function() { const roomId = roomIds[i]; const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { const command = `!mjolnir status joins ${roomId}`; - return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); + 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}`); + 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}`); + 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() { + 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!; @@ -391,8 +464,12 @@ describe("Test: Testing RoomMemberManager", function() { 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` } })); + 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[] = []; @@ -437,7 +514,10 @@ describe("Test: Testing RoomMemberManager", function() { 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}` }); + await this.moderator.sendMessage(mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${roomId}`, + }); } let protectedRoomsUpdated = false; @@ -448,7 +528,7 @@ describe("Test: Testing RoomMemberManager", function() { const roomId = allRoomIds[i]; if (!protectedRooms.includes(roomId)) { protectedRoomsUpdated = false; - await new Promise(resolve => setTimeout(resolve, 1_000)); + await new Promise((resolve) => setTimeout(resolve, 1_000)); } } } while (!protectedRoomsUpdated); @@ -460,11 +540,11 @@ describe("Test: Testing RoomMemberManager", function() { } } - await new Promise(resolve => setTimeout(resolve, 5_000)); + await new Promise((resolve) => setTimeout(resolve, 5_000)); const cutDate = new Date(); - await new Promise(resolve => setTimeout(resolve, 5_000)); + await new Promise((resolve) => setTimeout(resolve, 5_000)); // Bad users join after cut date. for (let user of this.badUsers) { @@ -509,9 +589,24 @@ describe("Test: Testing RoomMemberManager", function() { // 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}) { + 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.shouldAffectControlProtected = + typeof shouldAffectControlProtected === "undefined" ? false : shouldAffectControlProtected; this.command = command; this.n = typeof n === "undefined" ? 1 : n; this.method = method; @@ -586,24 +681,28 @@ describe("Test: Testing RoomMemberManager", function() { // 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`, + 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`, + 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`, + 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`, + command: (roomId: string) => + `!mjolnir since ${Date.now() - cutDate.getTime()}ms unmute 100 ${roomId} bad, bad user`, method: Method.unmute, sameRoom: true, }), @@ -638,13 +737,15 @@ describe("Test: Testing RoomMemberManager", function() { // 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}`, + 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`, + command: (_: string, roomAlias: string) => + `!mjolnir since ${Date.now() - cutDate.getTime()}ms kick 100 ${roomAlias} for some reason`, method: Method.kick, }), @@ -662,15 +763,28 @@ describe("Test: Testing RoomMemberManager", function() { // Just-in-case health check, before starting. { - const usersInUnprotectedControlProtected = await mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID); + 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`); + 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`); + 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`, + ); } } @@ -680,48 +794,88 @@ describe("Test: Testing RoomMemberManager", function() { 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}`); + 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 }); + 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 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})`); + 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`); + 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`); + 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`); + 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`); + 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`); + 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: diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index ee5e33c5..eea8d636 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -6,20 +6,20 @@ import { newTestUser, noticeListener } from "./clientHelper"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence"; -describe("Test: standard consequences", function() { +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" }}); + 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() { + }); + it("Mjolnir applies a standard consequence redaction", async function () { this.timeout(20000); let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); @@ -27,43 +27,41 @@ describe("Test: standard consequences", function() { 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.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: Promise = new Promise(async (resolve, reject) => { - const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); + 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 + 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 + roomId === this.mjolnir.managementRoomId && + event?.type === "m.room.message" && + event?.content?.body?.startsWith("protection JY2TPN enacting redact against ") && + redaction !== undefined ) { - resolve([redaction, event]) + resolve([redaction, event]); } }); }); - const [eventRedact, eventMessage] = await reply + const [eventRedact, eventMessage] = await reply; }); - it("Mjolnir applies a standard consequence ban", async function() { + it("Mjolnir applies a standard consequence ban", async function () { this.timeout(20000); let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); @@ -71,77 +69,83 @@ describe("Test: standard consequences", function() { 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.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: Promise = new Promise(async (resolve, reject) => { - const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); + const messageId = await badUser.sendMessage(protectedRoomId, { msgtype: "m.text", body: "7Uga3d" }); let ban; - badUser.on('room.leave', (roomId, event) => { + badUser.on("room.leave", (roomId, event) => { if ( - roomId === protectedRoomId - && event?.type === "m.room.member" - && event.content?.membership === "ban" - && event.state_key === badUser.userId + roomId === protectedRoomId && + event?.type === "m.room.member" && + event.content?.membership === "ban" && + event.state_key === badUser.userId ) { ban = event; } }); - badUser.on('room.event', (roomId, 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 + roomId === this.mjolnir.managementRoomId && + event?.type === "m.room.message" && + event?.content?.body?.startsWith("protection 0LxMTy enacting ban against ") && + ban !== undefined ) { - resolve([ban, event]) + resolve([ban, event]); } }); }); - const [eventBan, eventMessage] = await reply + const [eventBan, eventMessage] = await reply; }); - it("Mjolnir doesn't ban a good user", async function() { + 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()] }); + 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.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) => { + this.mjolnir.client.on("room.message", async (roomId, event) => { if (event?.content?.body === "SUwvFT") { - await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "8HUnwb"}); + await badUser.sendMessage(protectedRoomId, { msgtype: "m.text", body: "8HUnwb" }); } }); - this.mjolnir.client.on('room.event', (roomId, event) => { + this.mjolnir.client.on("room.event", (roomId, event) => { if ( - roomId === protectedRoomId - && event?.type === "m.room.member" - && event.content?.membership === "ban" + roomId === protectedRoomId && + event?.type === "m.room.member" && + event.content?.membership === "ban" ) { if (event.state_key === goodUser.userId) { reject("good user has been banned"); @@ -151,9 +155,8 @@ describe("Test: standard consequences", function() { } }); }); - await goodUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "SUwvFT"}); + await goodUser.sendMessage(protectedRoomId, { msgtype: "m.text", body: "SUwvFT" }); - await reply + await reply; }); }); - diff --git a/test/integration/throttleQueueTest.ts b/test/integration/throttleQueueTest.ts index 1a461f69..9e4e8150 100644 --- a/test/integration/throttleQueueTest.ts +++ b/test/integration/throttleQueueTest.ts @@ -3,8 +3,8 @@ import { strict as assert } from "assert"; import { UserID } from "@vector-im/matrix-bot-sdk"; import { ThrottlingQueue } from "../../src/queues/ThrottlingQueue"; -describe("Test: ThrottlingQueue", function() { - it("Tasks enqueued with `push()` are executed exactly once and in the right order", async function() { +describe("Test: ThrottlingQueue", function () { + it("Tasks enqueued with `push()` are executed exactly once and in the right order", async function () { this.timeout(20000); const queue = new ThrottlingQueue(this.mjolnir, 10); @@ -34,34 +34,36 @@ describe("Test: ThrottlingQueue", function() { // Give code a little bit more time to trip itself, in case `promises` are accidentally // resolved too early. - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); queue.dispose(); }); - it("Tasks enqueued with `push()` are executed exactly once and in the right order, even if we call `block()` at some point", async function() { + it("Tasks enqueued with `push()` are executed exactly once and in the right order, even if we call `block()` at some point", async function () { this.timeout(20000); const queue = new ThrottlingQueue(this.mjolnir, 10); let state = new Map(); let promises: Promise[] = []; for (let counter = 0; counter < 10; ++counter) { const i = counter; - promises.push(queue.push(async () => { - if (state.get(i)) { - throw new Error(`We shouldn't have set state[${i}] yet`); - } - state.set(i, true); - for (let j = 0; j < i; ++j) { - queue.block(100); - if (!state.get(j)) { - throw new Error(`We should have set state[${j}] already`); + promises.push( + queue.push(async () => { + if (state.get(i)) { + throw new Error(`We shouldn't have set state[${i}] yet`); } - } - if (i % 2 === 0) { - // Arbitrary call to `delay()`. - queue.block(20); - } - })); + state.set(i, true); + for (let j = 0; j < i; ++j) { + queue.block(100); + if (!state.get(j)) { + throw new Error(`We should have set state[${j}] already`); + } + } + if (i % 2 === 0) { + // Arbitrary call to `delay()`. + queue.block(20); + } + }), + ); } queue.block(100); @@ -75,10 +77,8 @@ describe("Test: ThrottlingQueue", function() { // Give code a little bit more time to trip itself, in case `promises` are accidentally // resolved too early. - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); queue.dispose(); }); }); - - diff --git a/test/integration/throttleTest.ts b/test/integration/throttleTest.ts index cec433f3..429fad31 100644 --- a/test/integration/throttleTest.ts +++ b/test/integration/throttleTest.ts @@ -3,31 +3,38 @@ import { newTestUser } from "./clientHelper"; import { getMessagesByUserIn } from "../../src/utils"; describe("Test: throttled users can function with Mjolnir.", function () { - it('throttled users survive being throttled by synapse', async function() { - let throttledUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "throttled" }, isThrottled: true }); + it("throttled users survive being throttled by synapse", async function () { + let throttledUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "throttled" }, + isThrottled: true, + }); let throttledUserId = await throttledUser.getUserId(); let targetRoom = await throttledUser.createRoom(); // send enough messages to hit the rate limit. - await Promise.all([...Array(25).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`}))); + await Promise.all( + [...Array(25).keys()].map((i) => + throttledUser.sendMessage(targetRoom, { msgtype: "m.text.", body: `Message #${i}` }), + ), + ); let messageCount = 0; await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 25, (events) => { messageCount += events.length; }); assert.equal(messageCount, 25, "There should have been 25 messages in this room"); - }) -}) + }); +}); /** * We used to have a test here that tested whether Mjolnir was going to carry out a redact order the default limits in a reasonable time scale. * Now I think that's never going to happen without writing a new algorithm for respecting rate limiting. * Which is not something there is time for. - * + * * https://github.com/matrix-org/synapse/pull/13018 - * + * * Synapse rate limits were broken and very permitting so that's why the current hack worked so well. - * Now it is not broken, so our rate limit handling is. - * + * Now it is not broken, so our rate limit handling is. + * * https://github.com/matrix-org/mjolnir/commit/b850e4554c6cbc9456e23ab1a92ede547d044241 - * + * * Honestly I don't think we can expect anyone to be able to use Mjolnir under default rate limits. */ diff --git a/test/integration/timelinePaginationTest.ts b/test/integration/timelinePaginationTest.ts index 0df691f6..6792a482 100644 --- a/test/integration/timelinePaginationTest.ts +++ b/test/integration/timelinePaginationTest.ts @@ -7,65 +7,89 @@ import { getMessagesByUserIn } from "../../src/utils"; * Ensure that Mjolnir paginates only the necessary segment of the room timeline when backfilling. */ describe("Test: timeline pagination", function () { - it('does not paginate across the entire room history while backfilling.', async function() { + it("does not paginate across the entire room history while backfilling.", async function () { this.timeout(60000); // Create a few users and a room. - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" } }); let badUserId = await badUser.getUserId(); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]}); + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()] }); await badUser.joinRoom(targetRoom); // send some irrelevant messages - await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); + await Promise.all( + [...Array(200).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); // bad guy sends 5 messages for (let i = 0; i < 5; i++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); } // send some irrelevant messages - await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); // bad guy sends 1 extra message at the most recent edge of the timeline. - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); // then call this paignator and ensure that we don't go across the entire room history. let cbCount = 0; let eventCount = 0; - await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) { + await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function (events) { cbCount += 1; eventCount += events.length; - events.map(e => assert.equal(e.sender, badUserId, "All the events should be from the same sender")); + events.map((e) => assert.equal(e.sender, badUserId, "All the events should be from the same sender")); }); - assert.equal(cbCount, 1, "The callback only needs to be called once with all the messages because the events should be filtered."); - assert.equal(eventCount, 7, "There shouldn't be any more events (1 member event and 6 messages), and they should all be from the same account."); - }) - it('does not call the callback with an empty array when there are no relevant events', async function() { + assert.equal( + cbCount, + 1, + "The callback only needs to be called once with all the messages because the events should be filtered.", + ); + assert.equal( + eventCount, + 7, + "There shouldn't be any more events (1 member event and 6 messages), and they should all be from the same account.", + ); + }); + it("does not call the callback with an empty array when there are no relevant events", async function () { this.timeout(60000); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" } }); let badUserId = await badUser.getUserId(); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); let targetRoom = await moderator.createRoom(); // send some irrelevant messages - await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); + await Promise.all( + [...Array(200).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); // The callback should not be called. let cbCount = 0; await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, (events) => { cbCount += 1; }); assert.equal(cbCount, 0, "The callback should never get called"); - }) - it("The limit provided is respected", async function() { + }); + it("The limit provided is respected", async function () { this.timeout(60000); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" } }); let badUserId = await badUser.getUserId(); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); - let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]}); + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()] }); await badUser.joinRoom(targetRoom); // send some bad person messages // bad guy sends 5 messages at the start of the timeline for (let i = 0; i < 5; i++) { - await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"}); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); } // send some irrelevant messages - await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); + await Promise.all( + [...Array(200).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); let cbCount = 0; await getMessagesByUserIn(moderator, "*spammer*", targetRoom, 200, (events) => { cbCount += 1; @@ -77,21 +101,23 @@ describe("Test: timeline pagination", function () { assert.equal(cbCount, 0, "The callback should never be called as the limit should be reached beforehand."); await getMessagesByUserIn(moderator, "*spammer*", targetRoom, 205, (events) => { cbCount += 1; - events.map(e => assert.equal(e.sender, badUserId, "All the events should be from the same sender")); + events.map((e) => assert.equal(e.sender, badUserId, "All the events should be from the same sender")); }); assert.equal(cbCount, 1, "The callback should be called once with events matching the glob."); }); - it("Gives the events to the callback ordered by youngest first (even more important when the limit is reached halfway through a chunk).", async function() { + it("Gives the events to the callback ordered by youngest first (even more important when the limit is reached halfway through a chunk).", async function () { this.timeout(60000); - let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }}); + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); let moderatorId = await moderator.getUserId(); let targetRoom = await moderator.createRoom(); for (let i = 0; i < 20; i++) { - await moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `${i}`}) + await moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `${i}` }); } await getMessagesByUserIn(moderator, moderatorId, targetRoom, 5, (events) => { - let messageNumbers = events.map(event => parseInt(event.content.body, 10)); - messageNumbers.map(n => assert.equal(n >= 15, true, "The youngest events should be given to the callback first.")) + let messageNumbers = events.map((event) => parseInt(event.content.body, 10)); + messageNumbers.map((n) => + assert.equal(n >= 15, true, "The youngest events should be given to the callback first."), + ); }); - }) + }); }); diff --git a/test/integration/utilsTest.ts b/test/integration/utilsTest.ts index 8e33b286..b10a4ca0 100644 --- a/test/integration/utilsTest.ts +++ b/test/integration/utilsTest.ts @@ -2,33 +2,32 @@ import { strict as assert } from "assert"; import { LogLevel } from "@vector-im/matrix-bot-sdk"; import ManagementRoomOutput from "../../src/ManagementRoomOutput"; -describe("Test: utils", function() { - it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() { +describe("Test: utils", function () { + it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function () { const managementRoomAlias = this.config.managementRoom; const managementRoomOutput: ManagementRoomOutput = this.mjolnir.managementRoomOutput; - await this.mjolnir.client.sendStateEvent( - this.mjolnir.managementRoomId, - "m.room.canonical_alias", - "", - { alias: managementRoomAlias } - ); + await this.mjolnir.client.sendStateEvent(this.mjolnir.managementRoomId, "m.room.canonical_alias", "", { + alias: managementRoomAlias, + }); - const message: any = await new Promise(async resolve => { - this.mjolnir.client.on('room.message', (roomId, event) => { + const message: any = await new Promise(async (resolve) => { + this.mjolnir.client.on("room.message", (roomId, event) => { if (roomId === this.mjolnir.managementRoomId) { if (event.content?.body?.startsWith("it's")) { resolve(event); } } - }) - await managementRoomOutput.logMessage(LogLevel.INFO, 'replaceRoomIdsWithPills test', + }); + await managementRoomOutput.logMessage( + LogLevel.INFO, + "replaceRoomIdsWithPills test", `it's fun here in ${this.mjolnir.managementRoomId}`, - [this.mjolnir.managementRoomId, "!myfaketestid:example.com"]); + [this.mjolnir.managementRoomId, "!myfaketestid:example.com"], + ); }); assert.equal( message.content.formatted_body, - `it's fun here in ${managementRoomAlias}` + `it's fun here in ${managementRoomAlias}`, ); }); }); - From c5c561bff776d5f5bc8e3e19d847dd792fd1a3bc Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 7 Oct 2024 14:52:34 -0700 Subject: [PATCH 3/4] update prettierignore --- .prettierignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.prettierignore b/.prettierignore index 76f764c3..50425ff1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,9 +5,9 @@ .vscode/ /.github -/.config -/.docs -/.src/appservice/config +/config +/docs +/src/appservice/config /*.log package-lock.json From 3e965bf0d2f17768187b7de0abe2f0075551b106 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 7 Oct 2024 14:52:47 -0700 Subject: [PATCH 4/4] add check prettier when linting --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f757e381..b6dece21 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "tsc", "postbuild": "rm -rf lib/test/ && cp -r lib/src/* lib/ && rm -rf lib/src/", - "lint": "tslint --project ./tsconfig.json -t stylish", + "lint": "tslint --project ./tsconfig.json -t stylish && npx prettier . --check", "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 --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",