diff --git a/mx-tester.yml b/mx-tester.yml index 79feba98..b25b46e2 100644 --- a/mx-tester.yml +++ b/mx-tester.yml @@ -75,3 +75,19 @@ homeserver: remote: per_second: 10000 burst_count: 10000 + +# Creating a few users simplifies testing. +users: + - localname: admin + admin: true + rooms: + - public: true + name: "List of users" + alias: access-control-list + members: + - admin + - user_in_mjolnir_for_all + # This user can use Mjölnir-for-all + - localname: user_in_mjolnir_for_all + # This user cannot + - localname: user_regular diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 8e187ae4..51e8d21d 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -129,7 +129,7 @@ export class Mjolnir { if (options.autojoinOnlyIfManager) { const managers = await client.getJoinedRoomMembers(mjolnir.managementRoomId); if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite - } else { + } else if (options.acceptInvitesFromSpace) { const spaceId = await client.resolveRoom(options.acceptInvitesFromSpace); const spaceUserIds = await client.getJoinedRoomMembers(spaceId) .catch(async e => { @@ -154,7 +154,7 @@ export class Mjolnir { */ static async setupMjolnirFromConfig(client: MatrixSendClient, matrixEmitter: MatrixEmitter, config: IConfig): Promise { if (!config.autojoinOnlyIfManager && config.acceptInvitesFromSpace === getDefaultConfig().acceptInvitesFromSpace) { - throw new TypeError("`autojoinOnlyIfManager` has been disabled, yet no space has been provided for `acceptInvitesFromSpace`."); + 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 policyLists: PolicyList[] = []; const joinedRooms = await client.getJoinedRooms(); @@ -257,8 +257,13 @@ export class Mjolnir { this.protectionManager = new ProtectionManager(this); this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config); - const protections = new ProtectionManager(this); - this.protectedRoomsTracker = new ProtectedRoomsSet(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config); + this.protectedRoomsTracker = new ProtectedRoomsSet( + client, + clientUserId, + managementRoomId, + this.managementRoomOutput, + this.protectionManager, + config); } public get lists(): PolicyList[] { diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index ed93b25e..21c4e4c6 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -115,12 +115,6 @@ export class ProtectedRoomsSet { private readonly clientUserId: string, private readonly managementRoomId: string, private readonly managementRoomOutput: ManagementRoomOutput, - /** - * The protection manager is only used to verify the permissions - * that the protection manager requires are correct for this set of rooms. - * The protection manager is not really compatible with this abstraction yet - * because of a direct dependency on the protection manager in Mjolnir commands. - */ private readonly protectionManager: ProtectionManager, private readonly config: IConfig, ) { @@ -275,11 +269,13 @@ export class ProtectedRoomsSet { } this.protectedRooms.add(roomId); this.protectedRoomActivityTracker.addProtectedRoom(roomId); + this.protectionManager.addProtectedRoom(roomId); } public removeProtectedRoom(roomId: string): void { this.protectedRoomActivityTracker.removeProtectedRoom(roomId); this.protectedRooms.delete(roomId); + this.protectionManager.removeProtectedRoom(roomId); } /** diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 5c14b14f..26916e3f 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -58,6 +58,7 @@ import { BaseFunction, CommandTable, defineCommandTable } from "./interface-mana import { findMatrixInterfaceAdaptor, MatrixContext } from "./interface-manager/MatrixInterfaceAdaptor"; import { ArgumentStream } from "./interface-manager/ParamaterParsing"; import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand"; +import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand"; export interface MjolnirContext extends MatrixContext { mjolnir: Mjolnir, @@ -122,6 +123,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execAddProtectedRoom(roomId, event, mjolnir, parts); } 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') { + return await execSetupProtectedRoom(roomId, event, mjolnir, parts); } else if (parts[1] === 'rooms' && parts.length === 2) { return await execListProtectedRooms(roomId, event, mjolnir); } else if (parts[1] === 'move' && parts.length > 3) { @@ -188,6 +191,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir rooms - Lists all the protected rooms\n" + "!mjolnir rooms add - Adds a protected room (may cause high server load)\n" + "!mjolnir rooms remove - Removes a protected room\n" + + "!mjolnir rooms setup reporting - Setup decentralized reporting in a room\n" + "!mjolnir move - Moves a to a new \n" + "!mjolnir directory add - Publishes a room in the server's room directory\n" + "!mjolnir directory remove - Removes a room from the server's room directory\n" + diff --git a/src/commands/SetupDecentralizedReportingCommand.ts b/src/commands/SetupDecentralizedReportingCommand.ts new file mode 100644 index 00000000..1b1ed017 --- /dev/null +++ b/src/commands/SetupDecentralizedReportingCommand.ts @@ -0,0 +1,61 @@ +import { Mjolnir } from "../Mjolnir"; +import { LogLevel } from "matrix-bot-sdk"; + +const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by"; +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'], '❌'); + return; + } + const protectedRoomId = await mjolnir.client.joinRoom(parts[3]); + + try { + const userId = await mjolnir.client.getUserId(); + + // 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); + } catch (ex) { + previousState = null; + } + + // Setup protected room -> moderation room link. + // We do this before the other one to be able to fail early if we do not have a sufficient + // powerlevel. + let eventId = await mjolnir.client.sendStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY, { + room_id: commandRoomId, + user_id: userId, + }); + + try { + // Setup moderation room -> protected room. + await mjolnir.client.sendStateEvent(commandRoomId, EVENT_MODERATOR_OF, protectedRoomId, { + user_id: userId, + }); + } catch (ex) { + // If the second `sendStateEvent` fails, we could end up with a room half setup, which + // is bad. Attempt to rollback. + try { + if (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"); + } + } finally { + // Ignore second exception + 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'], '✅'); +} diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index 2c87f1d5..2ac8fc66 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -42,11 +42,26 @@ export abstract class Protection { readonly requiredStatePermissions: string[] = []; abstract settings: { [setting: string]: AbstractProtectionSetting }; + /** + * A new room has been added to the list of rooms to protect with this protection. + */ + async startProtectingRoom(mjolnir: Mjolnir, roomId: string) { + // By default, do nothing. + } + + /** + * A room has been removed from the list of rooms to protect with this protection. + */ + async stopProtectingRoom(mjolnir: Mjolnir, roomId: string) { + // By default, do nothing. + } + /* * Handle a single event from a protected room, to decide if we need to * respond to it */ async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + // By default, do nothing. } /* @@ -54,6 +69,7 @@ export abstract class Protection { * need to respond to it */ async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { + // By default, do nothing. } /** diff --git a/src/protections/LocalAbuseReports.ts b/src/protections/LocalAbuseReports.ts new file mode 100644 index 00000000..c0ff71fb --- /dev/null +++ b/src/protections/LocalAbuseReports.ts @@ -0,0 +1,98 @@ +/* +Copyright 2023 Element. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { LogLevel } from "matrix-bot-sdk"; +import { Mjolnir } from "../Mjolnir"; +import { Protection } from "./IProtection"; + +/* + An implementation of per decentralized abuse reports, as per + https://github.com/Yoric/matrix-doc/blob/aristotle/proposals/3215-towards-decentralized-moderation.md + */ + +const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by"; +const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; + +/** + * Setup decentralized abuse reports in protected rooms. + */ +export class LocalAbuseReports extends Protection { + 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"; + readonly requiredStatePermissions = [EVENT_MODERATED_BY]; + + /** + * A new room has been added to the list of rooms to protect with this protection. + */ + async startProtectingRoom(mjolnir: Mjolnir, protectedRoomId: string) { + try { + const userId = await mjolnir.client.getUserId(); + + // 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); + } catch (ex) { + previousState = null; + } + if (previousState && previousState["room_id"] && previousState["user_id"]) { + if (previousState["room_id"] === mjolnir.managementRoomId && previousState["user_id"] === userId) { + // The room is already setup, do nothing. + 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\``); + return; + } + } + + // Setup protected room -> moderation room link. + // We do this before the other one to be able to fail early if we do not have a sufficient + // powerlevel. + let eventId; + try { + eventId = await mjolnir.client.sendStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY, { + room_id: mjolnir.managementRoomId, + 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\``); + return; + } + + try { + // Setup moderation room -> protected room. + await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, EVENT_MODERATOR_OF, protectedRoomId, { + user_id: userId, + }); + } 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\``); + try { + await mjolnir.client.redactEvent(protectedRoomId, eventId, "Rolling back incomplete MSC3215 setup"); + } finally { + // Ignore second exception, propagate first. + throw ex; + } + } + } catch (ex) { + mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", ex.message); + } + } +} \ No newline at end of file diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 5c1d3d19..c65e7117 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -41,6 +41,7 @@ import { Consequence } from "./consequence"; import { htmlEscape } from "../utils"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; +import { LocalAbuseReports } from "./LocalAbuseReports"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), @@ -51,6 +52,7 @@ const PROTECTIONS: Protection[] = [ new TrustedReporters(), new DetectFederationLag(), new JoinWaveShortCircuit(), + new LocalAbuseReports(), ]; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; @@ -108,6 +110,11 @@ export class ProtectionManager { // this.getProtectionSettings() validates this data for us, so we don't need to protection.settings[key].setValue(value); } + if (protection.enabled) { + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.startProtectingRoom(this.mjolnir, roomId); + } + } } /* @@ -115,11 +122,17 @@ export class ProtectionManager { * * @param protection The protection object we want to unregister */ - public unregisterProtection(protectionName: string) { - if (!(this._protections.has(protectionName))) { + public async unregisterProtection(protectionName: string) { + let protection = this._protections.get(protectionName); + if (!protection) { throw new Error("Failed to find protection by name: " + protectionName); } this._protections.delete(protectionName); + if (protection.enabled) { + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.stopProtectingRoom(this.mjolnir, roomId); + } + } } /* @@ -170,9 +183,13 @@ export class ProtectionManager { */ public async enableProtection(name: string) { const protection = this._protections.get(name); - if (protection !== undefined) { - protection.enabled = true; - await this.saveEnabledProtections(); + if (protection === undefined) { + return; + } + protection.enabled = true; + await this.saveEnabledProtections(); + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.startProtectingRoom(this.mjolnir, roomId); } } @@ -198,9 +215,13 @@ export class ProtectionManager { */ public async disableProtection(name: string) { const protection = this._protections.get(name); - if (protection !== undefined) { - protection.enabled = false; - await this.saveEnabledProtections(); + if (protection === undefined) { + return; + } + protection.enabled = false; + await this.saveEnabledProtections(); + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.stopProtectingRoom(this.mjolnir, roomId); } } @@ -405,4 +426,24 @@ export class ProtectionManager { await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason); } } + + public async addProtectedRoom(roomId: string) { + for (const protection of this.enabledProtections) { + try { + await protection.startProtectingRoom(this.mjolnir, roomId); + } catch (ex) { + this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, protection.name, ex); + } + } + } + + public async removeProtectedRoom(roomId: string) { + for (const protection of this.enabledProtections) { + try { + await protection.stopProtectingRoom(this.mjolnir, roomId); + } catch (ex) { + this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, protection.name, ex); + } + } + } } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index 025c9856..0e6f02ed 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -54,12 +54,16 @@ export const ABUSE_REPORT_KEY = "org.matrix.mjolnir.abuse.report"; /// reports (see `IReportWithAction` for the content). export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.abuse.action.confirmation"; +/// MSC3215-style abuse report sent to the moderators (instead of to the homeserver admin). +const EVENT_MODERATION_REQUEST = "org.matrix.msc3215.abuse.report"; +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.hate_speech", "spam"], - ["org.matrix.msc3215.abuse.nature.spam", "impersonation"], + ["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.violence", "threats of violence or death, either to self or others"], @@ -68,6 +72,10 @@ const NATURE_DESCRIPTIONS_LIST: [string, string][] = [ ["org.matrix.msc3215.abuse.nature.ncii", "non consensual intimate imagery, including revenge porn"], ["org.matrix.msc3215.abuse.nature.nsfw", "NSFW content (pornography, gore...) in a SFW room"], ["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"], + ["org.matrix.msc3215.abuse.nature.illegal", "illegal content [consider warning authorities]"], + ["org.matrix.msc3215.abuse.nature.toxic", "toxic behavior"], + ["org.matrix.msc3215.abuse.nature.other", "other"], + ["org.matrix.msc3215.abuse.nature.test", "just a test, please ignore"], ]; const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST); @@ -91,6 +99,10 @@ export class ReportManager extends EventEmitter { super(); // Configure bot interactions. mjolnir.matrixEmitter.on("room.event", async (roomId, event) => { + // Reactions within the room. + if (roomId !== mjolnir.managementRoomId) { + return; + } try { switch (event["type"]) { case "m.reaction": { @@ -102,6 +114,20 @@ export class ReportManager extends EventEmitter { LogService.error("ReportManager", "Uncaught error while handling an event", ex); } }); + mjolnir.matrixEmitter.on("room.event", async (roomId, event) => { + // Moderation requests in ANY room. + try { + switch (event["type"]) { + case EVENT_MODERATION_REQUEST: { + await this.handleUntrustedModerationRequest(roomId, event); + break; + } + } + } catch (ex) { + LogService.error("ReportManager", "Uncaught error while handling an event", ex); + } + }); + this.displayManager = new DisplayManager(this); } @@ -129,6 +155,52 @@ export class ReportManager extends EventEmitter { } } + public async handleUntrustedModerationRequest(dmRoomId: string, report: any) { + let { event_id: eventId, nature, room_id: roomId, reporter: reporterId, comment } = report["content"] || {}; + + // SECURITY: check that we are expecting moderation requests from that room. + if (!roomId) { + LogService.warn("ReportManager", "Received a moderation request without `room_id`"); + return; + } + + // 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); + 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"); + return; + } + + // SAFETY: validate `comment`, `nature`. + if (comment && typeof comment !== "string") { + LogService.warn("ReportManager", "Received a moderation request with a comment that isn't a string"); + return; + } + if (typeof nature !== "string" || !NATURE_DESCRIPTIONS.has(nature)) { + LogService.warn("ReportManager", "Received a moderation request with an invalid nature", nature); + return; + } + + // Fetch the report and act upon it. + let event; + try { + 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); + 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.mjolnir.client.sendNotice(dmRoomId, "Thank you for your report, it has been sent to the moderators"); + } + /** * Handle a reaction to an abuse report. * diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 9484a831..e19363f6 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -264,13 +264,6 @@ describe("Test: Reporting abuse", async () => { console.log("Test: Reporting abuse - send reports"); // Time to report. - let reportToFind = { - reporterId: goodUserId, - accusedId: badUserId, - eventId: badEventId, - text: badText, - comment: null, - }; try { await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); } catch (e) { diff --git a/test/integration/moderationRequestTest.ts b/test/integration/moderationRequestTest.ts new file mode 100644 index 00000000..9f776ea1 --- /dev/null +++ b/test/integration/moderationRequestTest.ts @@ -0,0 +1,417 @@ +import { strict as assert } from "assert"; +import { ABUSE_REPORT_KEY } from "../../src/report/ReportManager"; +import { newTestUser } from "./clientHelper"; + +const REPORT_NOTICE_REGEXPS = { + reporter: /Filed by (?[^ ]*) \((?[^ ]*)\)/, + accused: /Against (?[^ ]*) \((?[^ ]*)\)/, + room: /Room (?[^ ]*)/, + event: /Event (?[^ ]*) Go to event/, + content: /Content (?.*)/, + comments: /Comments Comments (?.*)/, + nature: /Nature (?[^(]*) \((?[^ ]*)\)/, +}; + +const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by"; +const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; +const EVENT_MODERATION_REQUEST = "org.matrix.msc3215.abuse.report"; + +enum SetupMechanism { + ManualCommand, + Protection +} + +describe("Test: Requesting moderation", async () => { + 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 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); + + // Setup moderated_by/moderator_of. + await this.mjolnir.client.sendText(this.mjolnir.managementRoomId, `!mjolnir rooms setup ${roomId} reporting`); + + // Wait until moderated_by/moderator_of are setup + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + console.log("moderated_by not setup yet, waiting"); + continue; + } + try { + await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + } catch (ex) { + console.log("moderator_of not setup yet, waiting"); + continue; + } + break; + } + }); + 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 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); + await goodUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); + + // Wait until Mjölnir has joined the room. + while (true) { + 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)) { + break; + } else { + console.log("Mjölnir hasn't joined the room yet, waiting"); + } + } + + // Setup moderated_by/moderator_of. + this.mjolnir.addProtectedRoom(roomId); + + // Wait until moderated_by/moderator_of are setup + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + console.log("moderated_by not setup yet, waiting"); + continue; + } + try { + await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + } catch (ex) { + console.log("moderator_of not setup yet, waiting"); + continue; + } + break; + } + }); + 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) { + 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 goodUserId = await goodUser.getUserId(); + let badUserId = await badUser.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); + + // Setup moderated_by/moderator_of. + 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() ]}); + this.mjolnir.client.joinRoom(dmRoomId); + + // Wait until moderated_by/moderator_of are setup + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + console.log("moderated_by not setup yet, waiting"); + continue; + } + try { + await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + } catch (ex) { + console.log("moderator_of not setup yet, waiting"); + continue; + } + break; + } + + 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 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 badEventId = await badUser.sendText(roomId, badText); + let badEventId2 = await badUser.sendText(roomId, badText2); + let badEventId3 = await badUser.sendText(roomId, badText3); + let badEventId4 = await badUser.sendText(roomId, badText4); + let badEventId5 = await badUser.sendText(roomId, badText5); + 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}) => { + await goodUser.sendRawEvent(dmRoomId, EVENT_MODERATION_REQUEST, { + event_id: eventId, + room_id: roomId, + moderated_by_id: await this.mjolnir.client.getUserId(), + nature, + reporter: goodUserId, + comment, + }); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId, + text, + textPrefix, + comment: comment || null, + nature, + }); + }; + + // Without a comment. + 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 }); + // 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) }); + // With a very long text. + 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)); + 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) { + // Not a report or not our report. + continue; + } + let report = event.content[ABUSE_REPORT_KEY]; + let body = event.content.body as string; + let matches: Map | null = new Map(); + for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) { + let match = body.match(REPORT_NOTICE_REGEXPS[key]); + if (match) { + console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups); + } else { + console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]); + matches = null; + break; + } + matches.set(key, match); + } + if (!matches) { + // Not a report, skipping. + console.debug("Not a report, skipping"); + continue; + } + + assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); + assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); + + assert.equal(matches.get("event")!.groups!.eventId, toFind.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"); + } + 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}`); + } + if (toFind.comment) { + assert.equal(matches.get("comments")!.groups!.comments, toFind.comment, "The report should contain the comment we added"); + } + assert.equal(matches.get("room")!.groups!.roomAliasOrId, roomId, "The report should specify the correct room"); + assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); + assert.equal(matches.get("nature")!.groups!.natureSource, toFind.nature, "The report should specify the correct nature"); + found.push(toFind); + break; + } + } + } + assert.deepEqual(found, reportsToFind, `Found ${found.length} reports out of ${reportsToFind.length}`); + }); + + 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) { + notices.push(event); + } + }); + + // Create a moderator. + let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }}); + this.mjolnir.client.inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId); + await moderatorUser.joinRoom(this.mjolnir.managementRoomId); + + // Create a few users and a room. + let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-good-user" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-bad-user" }}); + let goodUserId = await goodUser.getUserId(); + let badUserId = await badUser.getUserId(); + + let roomId = await moderatorUser.createRoom({ invite: [await badUser.getUserId()] }); + await moderatorUser.inviteUser(await goodUser.getUserId(), roomId); + await moderatorUser.inviteUser(await badUser.getUserId(), roomId); + await badUser.joinRoom(roomId); + await goodUser.joinRoom(roomId); + + // Setup Mjölnir as moderator for our room. + await moderatorUser.inviteUser(await this.mjolnir.client.getUserId(), roomId); + await moderatorUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); + + // Setup moderated_by/moderator_of. + 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() ]}); + this.mjolnir.client.joinRoom(dmRoomId); + + // Wait until moderated_by/moderator_of are setup + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + console.log("moderated_by not setup yet, waiting"); + continue; + } + try { + await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + } catch (ex) { + console.log("moderator_of not setup yet, waiting"); + continue; + } + break; + } + + console.log("Test: Reporting abuse - send messages"); + // Exchange a few messages. + let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported. + let badText = `BAD: ${Math.random()}`; // Will be reported as abuse. + let goodEventId = await goodUser.sendText(roomId, goodText); + let badEventId = await badUser.sendText(roomId, badText); + let goodEventId2 = await goodUser.sendText(roomId, goodText); + + console.log("Test: Reporting abuse - send reports"); + + // Time to report. + await goodUser.sendRawEvent(dmRoomId, EVENT_MODERATION_REQUEST, { + event_id: badEventId, + room_id: roomId, + moderated_by_id: await this.mjolnir.client.getUserId(), + nature: "org.matrix.msc3215.abuse.nature.test", + reporter: goodUserId, + }); + + + console.log("Test: Reporting abuse - wait"); + 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"); + + // Find the notice + let noticeId; + for (let event of notices) { + if ("content" in event && ABUSE_REPORT_KEY in event.content) { + if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != badEventId) { + // Not a report or not our report. + continue; + } + noticeId = event.event_id; + break; + } + } + assert.ok(noticeId, "We should have found our notice"); + + // Find the buttons. + let buttons: any[] = []; + for (let event of notices) { + if (event["type"] != "m.reaction") { + continue; + } + if (event["content"]["m.relates_to"]["rel_type"] != "m.annotation") { + continue; + } + if (event["content"]["m.relates_to"]["event_id"] != noticeId) { + continue; + } + buttons.push(event); + } + + // Find the redact button... and click it. + let redactButtonId = null; + for (let button of buttons) { + if (button["content"]["m.relates_to"]["key"].includes("[redact-message]")) { + redactButtonId = button["event_id"]; + await moderatorUser.sendEvent(this.mjolnir.managementRoomId, "m.reaction", button["content"]); + break; + } + } + assert.ok(redactButtonId, "We should have found the redact button"); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // This should have triggered a confirmation request, with more buttons! + let confirmEventId = null; + for (let event of notices) { + console.debug("Is this the confirm button?", event); + if (!event["content"]["m.relates_to"]) { + console.debug("Not a reaction"); + continue; + } + if (!event["content"]["m.relates_to"]["key"].includes("[confirm]")) { + console.debug("Not confirm"); + continue; + } + if (!event["content"]["m.relates_to"]["event_id"] == redactButtonId) { + console.debug("Not reaction to redact button"); + continue; + } + + // It's the confirm button, click it! + confirmEventId = event["event_id"]; + await moderatorUser.sendEvent(this.mjolnir.managementRoomId, "m.reaction", event["content"]); + break; + } + assert.ok(confirmEventId, "We should have found the confirm button"); + + 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"); + }); +}); \ No newline at end of file diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts index 10940fd5..ce435ea5 100644 --- a/test/integration/protectionSettingsTest.ts +++ b/test/integration/protectionSettingsTest.ts @@ -1,7 +1,7 @@ import { strict as assert } from "assert"; import { Mjolnir } from "../../src/Mjolnir"; -import { IProtection } from "../../src/protections/IProtection"; +import { Protection } from "../../src/protections/IProtection"; import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; import { newTestUser, noticeListener } from "./clientHelper"; @@ -26,7 +26,7 @@ describe("Test: Protection settings", function() { it("Mjolnir successfully saves valid protection setting values", async function() { this.timeout(20000); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "05OVMS"; description = "A test protection"; settings = { test: new NumberProtectionSetting(3) }; @@ -41,8 +41,9 @@ describe("Test: Protection settings", function() { it("Mjolnir should accumulate changed settings", async function() { this.timeout(20000); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "HPUjKN"; + description = "A test protection"; settings = { test1: new NumberProtectionSetting(3), test2: new NumberProtectionSetting(4) @@ -59,7 +60,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "JY2TPN"; description = "A test protection"; settings = { test: new StringProtectionSetting() }; @@ -84,7 +85,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "r33XyT"; description = "A test protection"; settings = { test: new StringListProtectionSetting() }; @@ -108,7 +109,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "oXzT0E"; description = "A test protection"; settings = { test: new StringListProtectionSetting() }; @@ -133,13 +134,13 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "d0sNrt"; description = "A test protection"; settings = { test: new StringProtectionSetting() }; }); - let replyPromise = new Promise((resolve, reject) => { + let 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 ")) { diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 03aba82b..e98f60f5 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -1,5 +1,5 @@ import { Mjolnir } from "../../src/Mjolnir"; -import { IProtection } from "../../src/protections/IProtection"; +import { Protection } from "../../src/protections/IProtection"; import { newTestUser } from "./clientHelper"; describe("Test: Report polling", function() { @@ -15,18 +15,21 @@ describe("Test: Report polling", function() { await this.mjolnir.addProtectedRoom(protectedRoomId); const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); + class CustomProtection extends Protection { + name = "jYvufI"; + description = "A test protection"; + settings = { }; + constructor(private resolve) { + super(); + } + async handleReport (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) { + if (reason === "x5h1Je") { + this.resolve(null); + } + } + } await new Promise(async resolve => { - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { - name = "jYvufI"; - description = "A test protection"; - settings = { }; - handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { }; - handleReport = (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) => { - if (reason === "x5h1Je") { - resolve(null); - } - }; - }); + await this.mjolnir.protectionManager.registerProtection(new CustomProtection(resolve)); await this.mjolnir.protectionManager.enableProtection("jYvufI"); await client.doRequest( "POST", diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index f762f4c3..ee5e33c5 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -1,7 +1,7 @@ import { strict as assert } from "assert"; import { Mjolnir } from "../../src/Mjolnir"; -import { IProtection } from "../../src/protections/IProtection"; +import { Protection } from "../../src/protections/IProtection"; import { newTestUser, noticeListener } from "./clientHelper"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence"; @@ -27,7 +27,7 @@ describe("Test: standard consequences", function() { await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "JY2TPN"; description = "A test protection"; settings = { }; @@ -39,7 +39,7 @@ describe("Test: standard consequences", function() { }); await this.mjolnir.protectionManager.enableProtection("JY2TPN"); - let reply = new Promise(async (resolve, reject) => { + let reply: Promise = new Promise(async (resolve, reject) => { const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); let redaction; badUser.on('room.event', (roomId, event) => { @@ -71,7 +71,7 @@ describe("Test: standard consequences", function() { await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "0LxMTy"; description = "A test protection"; settings = { }; @@ -83,7 +83,7 @@ describe("Test: standard consequences", function() { }); await this.mjolnir.protectionManager.enableProtection("0LxMTy"); - let reply = new Promise(async (resolve, reject) => { + let reply: Promise = new Promise(async (resolve, reject) => { const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); let ban; badUser.on('room.leave', (roomId, event) => { @@ -118,7 +118,7 @@ describe("Test: standard consequences", function() { await goodUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "95B1Cr"; description = "A test protection"; settings = { };