diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index c7a9e9e8..01932294 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -35,7 +35,7 @@ import { applyUserBans } from "./actions/ApplyBan"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import { Protection } from "./protections/IProtection"; import { PROTECTIONS } from "./protections/protections"; -import { ConsequenceType, Consequence } from "./protections/consequence"; +import { Consequence } from "./protections/consequence"; import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; @@ -959,36 +959,37 @@ export class Mjolnir { await this.printBanlistChanges(changes, policyList, true); } - private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { - switch (consequence.type) { - case ConsequenceType.alert: - break; - case ConsequenceType.redact: - await this.client.redactEvent(roomId, eventId, "abuse detected"); - break; - case ConsequenceType.ban: - await this.client.banUser(sender, roomId, "abuse detected"); - break; - } + private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) { + for (const consequence of consequences) { + try { + if (consequence.name === "alert") { + /* take no additional action, just print the below message to management room */ + } else if (consequence.name === "ban") { + await this.client.banUser(sender, roomId, "abuse detected"); + } else if (consequence.name === "redact") { + await this.client.redactEvent(roomId, eventId, "abuse detected"); + } else { + throw new Error(`unknown consequence ${consequence.name}`); + } - let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]}` - + ` against ${htmlEscape(sender)}` - + ` in ${htmlEscape(roomId)}`; - if (consequence.reason !== undefined) { - // even though internally-sourced, there's no promise that `consequence.reason` - // will never have user-supplied information, so escape it - message += ` (reason: ${htmlEscape(consequence.reason)})`; - } - - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.notice", - body: message, - [CONSEQUENCE_EVENT_DATA]: { - who: sender, - room: roomId, - type: ConsequenceType[consequence.type] + let message = `protection ${protection.name} enacting` + + ` ${consequence.name}` + + ` against ${htmlEscape(sender)}` + + ` in ${htmlEscape(roomId)}` + + ` (reason: ${htmlEscape(consequence.reason)})`; + await this.client.sendMessage(this.managementRoomId, { + msgtype: "m.notice", + body: message, + [CONSEQUENCE_EVENT_DATA]: { + who: sender, + room: roomId, + types: [consequence.name], + } + }); + } catch (e) { + await this.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`); } - }); + } } private async handleEvent(roomId: string, event: any) { @@ -1018,9 +1019,9 @@ export class Mjolnir { // Iterate all the enabled protections for (const protection of this.enabledProtections) { - let consequence: Consequence | undefined = undefined; + let consequences: Consequence[] | undefined = undefined; try { - consequence = await protection.handleEvent(this, roomId, event); + consequences = await protection.handleEvent(this, roomId, event); } catch (e) { const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); LogService.error("Mjolnir", "Error handling protection: " + protection.name); @@ -1030,8 +1031,8 @@ export class Mjolnir { continue; } - if (consequence !== undefined) { - await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence); + if (consequences !== undefined) { + await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences); } } diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index b8d41cd9..4a7e2e0f 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -35,7 +35,7 @@ export abstract class Protection { * 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 { + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { } /* diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index 9decad10..b26ab9f2 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { Protection } from "./IProtection"; +import { ConsequenceBan, ConsequenceRedact } from "./consequence"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; import { isTrueJoinEvent } from "../utils"; @@ -95,21 +96,14 @@ export class WordList extends Protection { } } - // Perform the test - if (message && this.badWords!.test(message)) { - await mjolnir.logMessage(LogLevel.WARN, "WordList", `Banning ${event['sender']} for word list violation in ${roomId}.`); - if (!mjolnir.config.noop) { - await mjolnir.client.banUser(event['sender'], roomId, "Word list violation"); - } else { - await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); - } + if (!message) { + return; + } - // Redact the event - if (!mjolnir.config.noop) { - await mjolnir.client.redactEvent(roomId, event['event_id'], "spam"); - } else { - await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); - } + const matches = message.match(this.badWords!); + if (matches) { + const reason = `bad word: ${matches[0]}`; + return [new ConsequenceBan(reason), new ConsequenceRedact(reason)]; } } } diff --git a/src/protections/consequence.ts b/src/protections/consequence.ts index 7abf5dcb..26c11c18 100644 --- a/src/protections/consequence.ts +++ b/src/protections/consequence.ts @@ -1,23 +1,44 @@ - -/* - * Distinct individual actions that can be caused as a result of detected abuse - */ -export enum ConsequenceType { - // effectively a no-op. just tell the management room - alert, - // redact the event that triggered this consequence - redact, - // ban the user that sent the event that triggered this consequence - ban -} - export class Consequence { /* - * Action to take upon detection of abuse and an optional explanation of the detection + * A requested action to take against a user after detected abuse * - * @param type Action to take + * @param name The name of the consequence being requested * @param reason Brief explanation of why we're taking an action, printed to management room. * this will be HTML escaped before printing, just in case it has user-provided data */ - constructor(public readonly type: ConsequenceType, public readonly reason?: string) {} + constructor(public name: string, public reason: string) { } +} + +export class ConsequenceAlert extends Consequence { + /* + * Request an alert to be created after detected abuse + * + * @param reason Brief explanation of why we're taking an action, printed to management room. + * this will be HTML escaped before printing, just in case it has user-provided data + */ + constructor(reason: string) { + super("alert", reason); + } +} +export class ConsequenceRedact extends Consequence { + /* + * Request a message redaction after detected abuse + * + * @param reason Brief explanation of why we're taking an action, printed to management room. + * this will be HTML escaped before printing, just in case it has user-provided data + */ + constructor(reason: string) { + super("redact", reason); + } +} +export class ConsequenceBan extends Consequence { + /* + * Request a ban after detected abuse + * + * @param reason Brief explanation of why we're taking an action, printed to management room. + * this will be HTML escaped before printing, just in case it has user-provided data + */ + constructor(reason: string) { + super("ban", reason); + } } diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index d7bfe144..4a142990 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -4,7 +4,7 @@ import { Mjolnir } from "../../src/Mjolnir"; import { IProtection } from "../../src/protections/IProtection"; import { newTestUser, noticeListener } from "./clientHelper"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; -import { ConsequenceType, Consequence } from "../../src/protections/consequence"; +import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence"; describe("Test: standard consequences", function() { let badUser; @@ -33,7 +33,7 @@ describe("Test: standard consequences", function() { settings = { }; handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { if (event.content.body === "ngmWkF") { - return new Consequence(ConsequenceType.redact, "asd"); + return [new ConsequenceRedact("asd")]; } }; }); @@ -77,7 +77,7 @@ describe("Test: standard consequences", function() { settings = { }; handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { if (event.content.body === "7Uga3d") { - return new Consequence(ConsequenceType.ban, "asd"); + return [new ConsequenceBan("asd")]; } }; }); @@ -124,7 +124,7 @@ describe("Test: standard consequences", function() { settings = { }; handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { if (event.content.body === "8HUnwb") { - return new Consequence(ConsequenceType.ban, "asd"); + return [new ConsequenceBan("asd")]; } }; });