From 77784a88b1c1dd86df7ef16cb4622e78980730f4 Mon Sep 17 00:00:00 2001 From: David Teller Date: Fri, 14 Jan 2022 17:16:22 +0100 Subject: [PATCH] Refactoring GUI out of the ReportManager. This will let us later use the GUI code for the Trashcan. --- src/gui/GUIManager.ts | 676 ++++++++++++++++++++++++++++++++++ src/report/ReportManager.ts | 710 +----------------------------------- src/utils.ts | 41 +++ 3 files changed, 737 insertions(+), 690 deletions(-) create mode 100644 src/gui/GUIManager.ts diff --git a/src/gui/GUIManager.ts b/src/gui/GUIManager.ts new file mode 100644 index 0000000..e94f194 --- /dev/null +++ b/src/gui/GUIManager.ts @@ -0,0 +1,676 @@ +import { htmlToText } from "html-to-text"; +import * as htmlEscape from "escape-html"; + +import { JSDOM } from "jsdom"; +import { LogService } from "matrix-bot-sdk"; +import { Mjolnir } from "../Mjolnir"; +import { limitLength } from "../utils"; + +/// Regexp, used to extract the action label from an action reaction +/// such as `⚽ Kick user @foobar:localhost from room [kick-user]`. +const REACTION_ACTION = /\[([a-z-]*)\]$/; + +/// Regexp, used to extract the action label from a confirmation reaction +/// such as `🆗 ⚽ Kick user @foobar:localhost from room? [kick-user][confirm]`. +const REACTION_CONFIRMATION = /\[([a-z-]*)\]\[([a-z-]*)\]$/; + +export const NATURE_DESCRIPTIONS_LIST: readonly [string, string][] = Object.freeze([ + ["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.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"], + ["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.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"], +]); +const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST); + + +export enum Kind { + //! A MSC3215-style moderation request + MODERATION_REQUEST, + //! An abuse report, as per https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-rooms-roomid-report-eventid + SERVER_ABUSE_REPORT, + //! Mjölnir encountered a problem while attempting to handle a moderation request or abuse report + ERROR, + //! A moderation request or server abuse report escalated by the server/room moderators. + ESCALATED_REPORT, + //! A message in the trashcan. + MESSAGE_IN_TRASHCAN, +} + +/// The hardcoded `confirm` string, as embedded in confirmation reactions. +const CONFIRM = "confirm"; +/// The hardcoded `cancel` string, as embedded in confirmation reactions. +const CANCEL = "cancel"; + +/** + * An abuse report received from a user. + * + * Note: These reports end up embedded in Matrix messages, + * so we're using Matrix naming conventions rather than JS/TS + * naming conventions. + */ +export interface IReport { + /** + * The user who sent the abuse report. + */ + readonly accused_id: string, + + /** + * The user who sent the message reported as abuse. + */ + readonly reporter_id: string, + + /** + * The room in which `eventId` took place. + */ + readonly room_id: string, + readonly room_alias_or_id: string, + + /** + * The event reported as abuse. + */ + readonly event_id: string, +} + +/** + * An abuse report, extended with the information we need for a confirmation report. + * + * 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. +*/ +export interface IReportWithAction extends IReport { + /** + * The label of the action we're confirming, e.g. `kick-user`. + */ + readonly action: string, + + /** + * The event in which we originally notified of the abuse. + */ + readonly notification_event_id: string, +} + +/** + * A user action displayed in the UI as a Matrix reaction. + */ +export interface IUIAction { + /** + * A unique label. + * + * Used by Mjölnir to differentiate the actions, e.g. `kick-user`. + */ + readonly label: string; + + /** + * A unique Emoji. + * + * Used to help users avoid making errors when clicking on a button. + */ + readonly emoji: string; + + /** + * If `true`, this is an action that needs confirmation. Otherwise, the + * action may be executed immediately. + */ + readonly needsConfirmation: boolean; + + /** + * Detect whether the action may be executed, e.g. whether Mjölnir has + * sufficient powerlevel to execute this action. + * + * **Security caveat** This assumes that the security policy on whether + * the operation can be executed is: + * + * > *Anyone* in the moderation room and who isn't muted can execute + * > an operation iff Mjölnir has the rights to execute it. + * + * @param report Details on the abuse report. + */ + canExecute(manager: Manager, report: IReport, interactionRoomId: string): Promise; + + /** + * A human-readable title to display for the end-user. + * + * @param report Details on the abuse report. + */ + title(manager: Manager, report: IReport): Promise; + + /** + * A human-readable help message to display for the end-user. + * + * @param report Details on the abuse report. + */ + help(manager: Manager, report: IReport): Promise; + + /** + * Attempt to execute the action. + * + * If this method resolves to a non-empty string, the string is + * displayed in the interaction room as formatted body. + */ + execute(manager: Manager, report: IReportWithAction, interactionRoomId: string, displayManager: GUIManager): Promise; +} + +export type Actions = [string /* label */, IUIAction][]; + +export class GUIManager { + private readonly owner: Owner; + private readonly interactionRoomId: string; + private readonly actions: Map>; + private readonly reportKey: string; + private readonly confirmKey: string; + constructor({ owner, interactionRoomId, actions, reportKey, confirmKey }: { owner: Owner, interactionRoomId: string, actions: Actions, reportKey: string, confirmKey?: string }) { + this.owner = owner; + this.interactionRoomId = interactionRoomId; + this.actions = new Map(actions); + this.reportKey = reportKey; + this.confirmKey = confirmKey || `${reportKey}.confirm`; + // Configure bot interactions. + this.owner.mjolnir.client.on("room.event", async (roomId, event) => { + try { + switch (event["type"]) { + case "m.reaction": { + // FIXME: Really + await this.handleReaction({ roomId, event }); + break; + } + } + } catch (ex) { + LogService.error("ReportManager", "Uncaught error while handling an event", ex); + } + }); + + } + + /** + * Display the report and any UI button. + * + * + * # Security + * + * This method DOES NOT PERFORM ANY SECURITY CHECKS. + * + * @param kind The kind of report (server-wide abuse report / room moderation request / ...). Low security. + * @param event The offending event. The fact that it's the offending event MUST be checked. No assumptions are made on the content. + * @param reporterId The user who reported the event. MUST be checked. + * @param reason A user-provided comment. Low-security. + * @param interactionRoomId 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, interactionRoomId: string, error?: string }) { + let { kind, event, reporterId, reason, nature, interactionRoomId, error } = args; + + let roomId = event["room_id"]!; + let eventId = event["event_id"]!; + + let roomAliasOrId = roomId; + try { + roomAliasOrId = await this.owner.mjolnir.client.getPublishedAlias(roomId) || roomId; + } catch (ex) { + // Ignore. + } + + let eventContent; + try { + if (event["type"] === "m.room.encrypted") { + eventContent = { msg: "" }; + } else if ("content" in event) { + const MAX_EVENT_CONTENT_LENGTH = 2048; + const MAX_NEWLINES = 64; + if ("formatted_body" in event.content) { + eventContent = { html: limitLength(event.content.formatted_body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + } else if ("body" in event.content) { + eventContent = { text: limitLength(event.content.body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + } else { + eventContent = { text: limitLength(JSON.stringify(event["content"], null, 2), MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + } + } + } catch (ex) { + eventContent = { msg: `.` }; + } + + let accusedId = event["sender"]; + + let reporterDisplayName: string, accusedDisplayName: string; + try { + reporterDisplayName = await this.owner.mjolnir.client.getUserProfile(reporterId)["displayname"] || reporterId; + } catch (ex) { + reporterDisplayName = ""; + } + try { + accusedDisplayName = await this.owner.mjolnir.client.getUserProfile(accusedId)["displayname"] || accusedId; + } catch (ex) { + accusedDisplayName = ""; + } + + let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`; + let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrId)}`; + + let eventTimestamp; + try { + eventTimestamp = new Date(event["origin_server_ts"]).toUTCString(); + } catch (ex) { + eventTimestamp = `.`; + } + + let title; + switch (kind) { + case Kind.MODERATION_REQUEST: + title = "Moderation request"; + break; + case Kind.SERVER_ABUSE_REPORT: + title = "Abuse report"; + break; + case Kind.ESCALATED_REPORT: + title = "Moderation request escalated by moderators"; + break; + case Kind.ERROR: + title = "Error"; + break; + case Kind.MESSAGE_IN_TRASHCAN: + title = "Message pending moderation"; + break; + } + + let readableNature = "unspecified"; + if (nature) { + readableNature = NATURE_DESCRIPTIONS.get(nature) || readableNature; + } + + // We need to send the report as html to be able to use spoiler markings. + // We build this as dom to be absolutely certain that we're not introducing + // any kind of injection within the report. + + // Please do NOT insert any `${}` in the following backticks, to avoid + // any XSS attack. + const document = new JSDOM(` + +
+ +
+
+ Filed by () +
+ Against () +
+ Nature () +
+
+ Room +
+
+
+
+ Event details +
+ Event Go to event +
+
+ When +
+
+ Content +
+
+
+
+
+ Comments + Comments +
+ `).window.document; + + // ...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] + ]) { + let node = document.getElementById(key); + if (node && value) { + node.textContent = value; + } + } + // ...insert links + for (let [key, value] of [ + ['event-shortcut', eventShortcut], + ['room-shortcut', roomShortcut], + ]) { + let node = document.getElementById(key) as HTMLAnchorElement; + if (node) { + node.href = value; + } + } + + // ...insert HTML content + for (let [key, value] of [ + ['event-content', eventContent], + ]) { + let node = document.getElementById(key); + if (node) { + if ("msg" in value) { + node.textContent = value.msg; + } else if ("text" in value) { + node.textContent = value.text; + } else if ("html" in value) { + node.innerHTML = value.html; + } + } + } + + // ...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", ""); + } + + // Embed additional information in the notice, for use by the + // action buttons. + let report: IReport = { + accused_id: accusedId, + reporter_id: reporterId, + event_id: eventId, + room_id: roomId, + room_alias_or_id: roomAliasOrId, + }; + let notice = { + msgtype: "m.notice", + body: htmlToText(document.body.outerHTML, { wordwrap: false }), + format: "org.matrix.custom.html", + formatted_body: document.body.outerHTML, + }; + notice[this.reportKey] = report; + + let noticeEventId = await this.owner.mjolnir.client.sendMessage(this.interactionRoomId, notice); + if (kind !== Kind.ERROR) { + // Now let's display buttons. + for (let [label, action] of this.actions) { + // Display buttons for actions that can be executed. + if (!await action.canExecute(this.owner, report, interactionRoomId)) { + continue; + } + await this.owner.mjolnir.client.sendEvent(this.interactionRoomId, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": noticeEventId, + "key": `${action.emoji} ${await action.title(this.owner, report)} [${label}]` + } + }); + } + } + } + + /** + * Handle a reaction to a report. + * + * @param roomId The room in which the reaction took place. + * @param event The reaction. + */ + private async handleReaction({ roomId, event }: { roomId: string, event: any }) { + if (event.sender === await this.owner.mjolnir.client.getUserId()) { + // Let's not react to our own reactions. + return; + } + + if (roomId !== this.interactionRoomId) { + // Let's not accept commands in rooms other than the management room. + return; + } + let relation; + try { + relation = event["content"]["m.relates_to"]!; + } catch (ex) { + return; + } + + // Get the original event. + let initialNoticeReport: IReport | undefined, confirmationReport: IReportWithAction | undefined; + try { + let originalEvent = await this.owner.mjolnir.client.getEvent(roomId, relation.event_id); + if (!("content" in originalEvent)) { + return; + } + let content = originalEvent["content"]; + if (this.reportKey in content) { + initialNoticeReport = content[this.reportKey]!; + } else if (this.confirmKey in content) { + confirmationReport = content[this.confirmKey]!; + } + } catch (ex) { + return; + } + if (!initialNoticeReport && !confirmationReport) { + return; + } + + /* + At this point, we know that: + + - We're in the management room; + - Either + - `initialNoticeReport` is defined and we're reacting to one of our reports; or + - `confirmationReport` is defined and we're reacting to a confirmation request. + */ + + if (confirmationReport) { + // Extract the action and the decision. + let matches = relation.key.match(REACTION_CONFIRMATION); + if (!matches) { + // Invalid key. + return; + } + + // Is it a yes or a no? + let decision; + switch (matches[2]) { + case CONFIRM: + decision = true; + break; + case CANCEL: + decision = false; + break; + default: + LogService.debug("GUIManager::handleReaction", "Unknown decision", matches[2]); + return; + } + if (decision) { + LogService.info("GUIManager::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, + interactionRoomId: roomId + }) + } else { + LogService.info("GUIManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]); + this.owner.mjolnir.client.redactEvent(this.interactionRoomId, relation.event_id, "Action cancelled"); + } + + return; + } else if (initialNoticeReport) { + let matches = relation.key.match(REACTION_ACTION); + if (!matches) { + // Invalid key. + return; + } + + let label: string = matches[1]!; + let action: IUIAction | undefined = this.actions.get(label); + if (!action) { + return; + } + confirmationReport = { + action: label, + notification_event_id: relation.event_id, + ...initialNoticeReport + }; + LogService.info("GUIManager::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.owner, initialNoticeReport)}?`, + "m.relationship": { + "rel_type": "m.reference", + "event_id": relation.event_id, + } + }; + confirmation[this.confirmKey] = confirmationReport; + + let requestConfirmationEventId = await this.owner.mjolnir.client.sendMessage(this.interactionRoomId, confirmation); + await this.owner.mjolnir.client.sendEvent(this.interactionRoomId, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": requestConfirmationEventId, + "key": `🆗 ${action.emoji} ${await action.title(this.owner, initialNoticeReport)} [${action.label}][${CONFIRM}]` + } + }); + await this.owner.mjolnir.client.sendEvent(this.interactionRoomId, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": requestConfirmationEventId, + "key": `⬛ Cancel [${action.label}][${CANCEL}]` + } + }); + } else { + // Execute immediately. + LogService.info("GUIManager::handleReaction", "User", event["sender"], "executed (no confirmation needed) action", matches[1]); + this.executeAction({ + label, + report: confirmationReport, + successEventId: relation.event_id, + failureEventId: relation.eventId, + interactionRoomId: 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, interactionRoomId }: { label: string, report: IReportWithAction, successEventId: string, failureEventId: string, onSuccessRemoveEventId?: string, interactionRoomId: string }) { + let action: IUIAction | undefined = this.actions.get(label); + if (!action) { + return; + } + let error: any = null; + let response; + try { + // Check security. + if (interactionRoomId === this.interactionRoomId) { + // Always accept actions executed from the interaction room. + } else { + throw new Error("Security error: Cannot execute this action."); + } + response = await action.execute(this.owner, report, interactionRoomId, this); + } catch (ex) { + error = ex; + } + if (error) { + this.owner.mjolnir.client.sendEvent(this.interactionRoomId, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": failureEventId, + "key": `${action.emoji} ❌` + } + }); + this.owner.mjolnir.client.sendEvent(this.interactionRoomId, "m.notice", { + "body": error.message || "", + "m.relationship": { + "rel_type": "m.reference", + "event_id": failureEventId, + } + }) + } else { + this.owner.mjolnir.client.sendEvent(this.interactionRoomId, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": successEventId, + "key": `${action.emoji} ✅` + } + }); + if (onSuccessRemoveEventId) { + this.owner.mjolnir.client.redactEvent(this.interactionRoomId, onSuccessRemoveEventId, "Action complete"); + } + if (response) { + this.owner.mjolnir.client.sendMessage(this.interactionRoomId, { + msgtype: "m.notice", + "formatted_body": response, + format: "org.matrix.custom.html", + "body": htmlToText(response), + "m.relationship": { + "rel_type": "m.reference", + "event_id": successEventId + } + }) + } + } + } +} + +/** + * UI action: Help. + */ +export class Help implements IUIAction { + public readonly label = "help"; + public readonly emoji = "❓"; + public readonly needsConfirmation = false; + private readonly actionsMap: Map>; + constructor(actions: Actions) { + this.actionsMap = new Map(actions); + } + public async canExecute(_manager: Owner, _report: IReport): Promise { + return true; + } + public async title(_manager: Owner, _report: IReport): Promise { + return "Help"; + } + public async help(_manager: Owner, _report: IReport): Promise { + return "This help"; + } + public async execute(manager: Owner, 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 this.actionsMap.values()) { + if (await action.canExecute(manager, report, moderationRoomId)) { + list.push(`
  • ${action.emoji} ${await action.help(manager, report)}
  • `); + } + } + if (!await this.actionsMap.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; + } +} diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index 7b7b561..59dfd83 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -15,26 +15,12 @@ limitations under the License. */ import { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction"; -import { LogService, UserID } from "matrix-bot-sdk"; -import { htmlToText } from "html-to-text"; import * as htmlEscape from "escape-html"; -import { JSDOM } from 'jsdom'; import config from "../config"; import { Mjolnir } from "../Mjolnir"; - -/// Regexp, used to extract the action label from an action reaction -/// such as `⚽ Kick user @foobar:localhost from room [kick-user]`. -const REACTION_ACTION = /\[([a-z-]*)\]$/; - -/// Regexp, used to extract the action label from a confirmation reaction -/// such as `🆗 ⚽ Kick user @foobar:localhost from room? [kick-user][confirm]`. -const REACTION_CONFIRMATION = /\[([a-z-]*)\]\[([a-z-]*)\]$/; - -/// The hardcoded `confirm` string, as embedded in confirmation reactions. -const CONFIRM = "confirm"; -/// The hardcoded `cancel` string, as embedded in confirmation reactions. -const CANCEL = "cancel"; +import { GUIManager, Help, IReport, IReportWithAction, IUIAction, Kind } from "../gui/GUIManager"; +import { getHomeserver } from "../utils"; /// Custom field embedded as part of notifications to embed abuse reports /// (see `IReport` for the content). @@ -42,56 +28,21 @@ export const ABUSE_REPORT_KEY = "org.matrix.mjolnir.abuse.report"; /// Custom field embedded as part of confirmation reactions to embed abuse /// reports (see `IReportWithAction` for the content). -export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.abuse.action.confirmation"; - -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.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"], - ["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.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"], -]; -const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST); - -enum Kind { - //! A MSC3215-style moderation request - MODERATION_REQUEST, - //! An abuse report, as per https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-rooms-roomid-report-eventid - SERVER_ABUSE_REPORT, - //! Mjölnir encountered a problem while attempting to handle a moderation request or abuse report - ERROR, - //! A moderation request or server abuse report escalated by the server/room moderators. - ESCALATED_REPORT, -} +export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.action.confirmation"; /** * A class designed to respond to abuse reports. */ export class ReportManager { - private displayManager: DisplayManager; + private guiManager: GUIManager; constructor(public mjolnir: Mjolnir) { - // Configure bot interactions. - mjolnir.client.on("room.event", async (roomId, event) => { - try { - switch (event["type"]) { - case "m.reaction": { - await this.handleReaction({ roomId, event }); - break; - } - } - } catch (ex) { - LogService.error("ReportManager", "Uncaught error while handling an event", ex); - } + this.guiManager = new GUIManager({ + owner: this, + interactionRoomId: config.managementRoom, + actions: [...ACTIONS], + reportKey: ABUSE_REPORT_KEY, + confirmKey: ABUSE_ACTION_CONFIRMATION_KEY }); - this.displayManager = new DisplayManager(this); } /** @@ -113,340 +64,14 @@ export class ReportManager { * @param reason A reason provided by the reporter. */ public async handleServerAbuseReport({ reporterId, event, reason }: { roomId: string, eventId: string, reporterId: string, event: any, reason?: string }) { - return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: config.managementRoom }); + return this.guiManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, interactionRoomId: config.managementRoom }); } - - /** - * Handle a reaction to an abuse report. - * - * @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()) { - // Let's not react to our own reactions. - return; - } - - if (roomId !== config.managementRoom) { - // Let's not accept commands in rooms other than the management room. - return; - } - let relation; - try { - relation = event["content"]["m.relates_to"]!; - } catch (ex) { - return; - } - - // Get the original event. - let initialNoticeReport: IReport | undefined, confirmationReport: IReportWithAction | undefined; - try { - let originalEvent = await this.mjolnir.client.getEvent(roomId, relation.event_id); - if (!("content" in originalEvent)) { - return; - } - let content = originalEvent["content"]; - if (ABUSE_REPORT_KEY in content) { - initialNoticeReport = content[ABUSE_REPORT_KEY]!; - } else if (ABUSE_ACTION_CONFIRMATION_KEY in content) { - confirmationReport = content[ABUSE_ACTION_CONFIRMATION_KEY]!; - } - } catch (ex) { - return; - } - if (!initialNoticeReport && !confirmationReport) { - return; - } - - /* - At this point, we know that: - - - We're in the management room; - - Either - - `initialNoticeReport` is defined and we're reacting to one of our reports; or - - `confirmationReport` is defined and we're reacting to a confirmation request. - */ - - if (confirmationReport) { - // Extract the action and the decision. - let matches = relation.key.match(REACTION_CONFIRMATION); - if (!matches) { - // Invalid key. - return; - } - - // Is it a yes or a no? - let decision; - switch (matches[2]) { - case CONFIRM: - decision = true; - break; - case CANCEL: - decision = false; - break; - default: - LogService.debug("ReportManager::handleReaction", "Unknown decision", matches[2]); - return; - } - if (decision) { - 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 - }) - } else { - LogService.info("ReportManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]); - this.mjolnir.client.redactEvent(config.managementRoom, relation.event_id, "Action cancelled"); - } - - return; - } else if (initialNoticeReport) { - let matches = relation.key.match(REACTION_ACTION); - if (!matches) { - // Invalid key. - return; - } - - let label: string = matches[1]!; - let action: IUIAction | undefined = ACTIONS.get(label); - if (!action) { - return; - } - confirmationReport = { - action: label, - notification_event_id: relation.event_id, - ...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)}?`, - "m.relationship": { - "rel_type": "m.reference", - "event_id": relation.event_id, - } - }; - confirmation[ABUSE_ACTION_CONFIRMATION_KEY] = confirmationReport; - - let requestConfirmationEventId = await this.mjolnir.client.sendMessage(config.managementRoom, confirmation); - await this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": requestConfirmationEventId, - "key": `🆗 ${action.emoji} ${await action.title(this, initialNoticeReport)} [${action.label}][${CONFIRM}]` - } - }); - await this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { - "m.relates_to": { - "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]); - this.executeAction({ - label, - report: confirmationReport, - successEventId: relation.event_id, - failureEventId: relation.eventId, - 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 }) { - let action: IUIAction | undefined = ACTIONS.get(label); - if (!action) { - return; - } - let error: any = null; - let response; - try { - // Check security. - if (moderationRoomId === config.managementRoom) { - // Always accept actions executed from the management room. - } else { - throw new Error("Security error: Cannot execute this action."); - } - response = await action.execute(this, report, moderationRoomId, this.displayManager); - } catch (ex) { - error = ex; - } - if (error) { - this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": failureEventId, - "key": `${action.emoji} ❌` - } - }); - this.mjolnir.client.sendEvent(config.managementRoom, "m.notice", { - "body": error.message || "", - "m.relationship": { - "rel_type": "m.reference", - "event_id": failureEventId, - } - }) - } else { - this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": successEventId, - "key": `${action.emoji} ✅` - } - }); - if (onSuccessRemoveEventId) { - this.mjolnir.client.redactEvent(config.managementRoom, onSuccessRemoveEventId, "Action complete"); - } - if (response) { - this.mjolnir.client.sendMessage(config.managementRoom, { - msgtype: "m.notice", - "formatted_body": response, - format: "org.matrix.custom.html", - "body": htmlToText(response), - "m.relationship": { - "rel_type": "m.reference", - "event_id": successEventId - } - }) - } - } - } -} - -/** - * An abuse report received from a user. - * - * Note: These reports end up embedded in Matrix messages, behind key `ABUSE_REPORT_KEY`, - * so we're using Matrix naming conventions rather than JS/TS naming conventions. - */ -interface IReport { - /** - * The user who sent the abuse report. - */ - readonly accused_id: string, - - /** - * The user who sent the message reported as abuse. - */ - readonly reporter_id: string, - - /** - * The room in which `eventId` took place. - */ - readonly room_id: string, - readonly room_alias_or_id: string, - - /** - * The event reported as abuse. - */ - readonly event_id: string, -} - -/** - * An abuse report, extended with the information we need for a confirmation report. - * - * 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, - - /** - * The event in which we originally notified of the abuse. - */ - readonly notification_event_id: string, -} - -/** - * A user action displayed in the UI as a Matrix reaction. - */ -interface IUIAction { - /** - * A unique label. - * - * Used by Mjölnir to differentiate the actions, e.g. `kick-user`. - */ - readonly label: string; - - /** - * A unique Emoji. - * - * Used to help users avoid making errors when clicking on a button. - */ - readonly emoji: string; - - /** - * If `true`, this is an action that needs confirmation. Otherwise, the - * action may be executed immediately. - */ - readonly needsConfirmation: boolean; - - /** - * Detect whether the action may be executed, e.g. whether Mjölnir has - * sufficient powerlevel to execute this action. - * - * **Security caveat** This assumes that the security policy on whether - * the operation can be executed is: - * - * > *Anyone* in the moderation room and who isn't muted can execute - * > an operation iff Mjölnir has the rights to execute it. - * - * @param report Details on the abuse report. - */ - canExecute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise; - - /** - * A human-readable title to display for the end-user. - * - * @param report Details on the abuse report. - */ - title(manager: ReportManager, report: IReport): Promise; - - /** - * A human-readable help message to display for the end-user. - * - * @param report Details on the abuse report. - */ - help(manager: ReportManager, report: IReport): Promise; - - /** - * Attempt to execute the action. - */ - execute(manager: ReportManager, report: IReport, moderationRoomId: string, displayManager: DisplayManager): Promise; } /** * UI action: Ignore bad report */ -class IgnoreBadReport implements IUIAction { +class IgnoreBadReport implements IUIAction { public label = "bad-report"; public emoji = "🚯"; public needsConfirmation = true; @@ -481,7 +106,7 @@ class IgnoreBadReport implements IUIAction { /** * UI action: Redact reported message. */ -class RedactMessage implements IUIAction { +class RedactMessage implements IUIAction { public label = "redact-message"; public emoji = "🗍"; public needsConfirmation = true; @@ -507,7 +132,7 @@ class RedactMessage implements IUIAction { /** * UI action: Kick accused user. */ -class KickAccused implements IUIAction { +class KickAccused implements IUIAction { public label = "kick-accused"; public emoji = "⚽"; public needsConfirmation = true; @@ -533,7 +158,7 @@ class KickAccused implements IUIAction { /** * UI action: Mute accused user. */ -class MuteAccused implements IUIAction { +class MuteAccused implements IUIAction { public label = "mute-accused"; public emoji = "🤐"; public needsConfirmation = true; @@ -559,7 +184,7 @@ class MuteAccused implements IUIAction { /** * UI action: Ban accused. */ -class BanAccused implements IUIAction { +class BanAccused implements IUIAction { public label = "ban-accused"; public emoji = "🚫"; public needsConfirmation = true; @@ -582,42 +207,10 @@ class BanAccused implements IUIAction { } } -/** - * UI action: Help. - */ -class Help implements IUIAction { - public label = "help"; - public emoji = "❓"; - public needsConfirmation = false; - public async canExecute(_manager: ReportManager, _report: IReport): Promise { - return true; - } - public async title(_manager: ReportManager, _report: IReport): Promise { - return "Help"; - } - public async help(_manager: ReportManager, _report: IReport): Promise { - return "This help"; - } - public async execute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise { - // Produce a html list of actions, in the order specified by ACTION_LIST. - let list: string[] = []; - for (let action of ACTION_LIST) { - if (await action.canExecute(manager, report, moderationRoomId)) { - 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)}
  • `) - } - let body = `
      ${list.join("\n")}
    `; - return body; - } -} - /** * Escalate to the moderation room of this instance of Mjölnir. */ -class EscalateToServerModerationRoom implements IUIAction { +class EscalateToServerModerationRoom implements IUIAction { public label = "escalate-to-server-moderation"; public emoji = "⏫"; public needsConfirmation = true; @@ -640,7 +233,7 @@ 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, guiManager: GUIManager): 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 @@ -651,271 +244,11 @@ 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: config.managementRoom, event }); + await guiManager.displayReportAndUI({ kind: Kind.ESCALATED_REPORT, reporterId: report.reporter_id, interactionRoomId: config.managementRoom, event }); return; } } -class DisplayManager { - - constructor(private owner: ReportManager) { - - } - - /** - * Display the report and any UI button. - * - * - * # Security - * - * This method DOES NOT PERFORM ANY SECURITY CHECKS. - * - * @param kind The kind of report (server-wide abuse report / room moderation request). Low security. - * @param event The offending event. The fact that it's the offending event MUST be checked. No assumptions are made on the content. - * @param reporterId The user who reported the event. MUST be checked. - * @param reason A user-provided comment. Low-security. - * @param moderationRoomId The room in which the report and ui will be displayed. MUST be checked. - */ - public async displayReportAndUI(args: { kind: Kind, event: any, reporterId: string, reason?: string, nature?: string, moderationRoomId: string, error?: string }) { - let { kind, event, reporterId, reason, nature, moderationRoomId, error } = args; - - let roomId = event["room_id"]!; - let eventId = event["event_id"]!; - - let roomAliasOrId = roomId; - try { - roomAliasOrId = await this.owner.mjolnir.client.getPublishedAlias(roomId) || roomId; - } catch (ex) { - // Ignore. - } - - let eventContent; - try { - if (event["type"] === "m.room.encrypted") { - eventContent = { msg: "" }; - } else if ("content" in event) { - 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) }; - } else if ("body" in event.content) { - 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) }; - } - } - } catch (ex) { - eventContent = { msg: `.` }; - } - - let accusedId = event["sender"]; - - let reporterDisplayName: string, accusedDisplayName: string; - try { - reporterDisplayName = await this.owner.mjolnir.client.getUserProfile(reporterId)["displayname"] || reporterId; - } catch (ex) { - reporterDisplayName = ""; - } - try { - accusedDisplayName = await this.owner.mjolnir.client.getUserProfile(accusedId)["displayname"] || accusedId; - } catch (ex) { - accusedDisplayName = ""; - } - - let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`; - let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrId)}`; - - let eventTimestamp; - try { - eventTimestamp = new Date(event["origin_server_ts"]).toUTCString(); - } catch (ex) { - eventTimestamp = `.`; - } - - let title; - switch (kind) { - case Kind.MODERATION_REQUEST: - title = "Moderation request"; - break; - case Kind.SERVER_ABUSE_REPORT: - title = "Abuse report"; - break; - case Kind.ESCALATED_REPORT: - title = "Moderation request escalated by moderators"; - break; - case Kind.ERROR: - title = "Error"; - break; - } - - let readableNature = "unspecified"; - if (nature) { - readableNature = NATURE_DESCRIPTIONS.get(nature) || readableNature; - } - - // We need to send the report as html to be able to use spoiler markings. - // We build this as dom to be absolutely certain that we're not introducing - // any kind of injection within the report. - - // Please do NOT insert any `${}` in the following backticks, to avoid - // any XSS attack. - const document = new JSDOM(` - -
    - -
    -
    - Filed by () -
    - Against () -
    - Nature () -
    -
    - Room -
    -
    -
    -
    - Event details -
    - Event Go to event -
    -
    - When -
    -
    - Content -
    -
    -
    -
    -
    - Comments - Comments -
    - `).window.document; - - // ...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] - ]) { - let node = document.getElementById(key); - if (node && value) { - node.textContent = value; - } - } - // ...insert links - for (let [key, value] of [ - ['event-shortcut', eventShortcut], - ['room-shortcut', roomShortcut], - ]) { - let node = document.getElementById(key) as HTMLAnchorElement; - if (node) { - node.href = value; - } - } - - // ...insert HTML content - for (let [key, value] of [ - ['event-content', eventContent], - ]) { - let node = document.getElementById(key); - if (node) { - if ("msg" in value) { - node.textContent = value.msg; - } else if ("text" in value) { - node.textContent = value.text; - } else if ("html" in value) { - node.innerHTML = value.html; - } - } - } - - // ...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", ""); - } - - // Embed additional information in the notice, for use by the - // action buttons. - let report: IReport = { - accused_id: accusedId, - reporter_id: reporterId, - event_id: eventId, - room_id: roomId, - room_alias_or_id: roomAliasOrId, - }; - let notice = { - msgtype: "m.notice", - body: htmlToText(document.body.outerHTML, { wordwrap: false }), - format: "org.matrix.custom.html", - formatted_body: document.body.outerHTML, - }; - notice[ABUSE_REPORT_KEY] = report; - - let noticeEventId = await this.owner.mjolnir.client.sendMessage(config.managementRoom, notice); - if (kind !== Kind.ERROR) { - // Now let's display buttons. - for (let [label, action] of ACTIONS) { - // Display buttons for actions that can be executed. - if (!await action.canExecute(this.owner, report, moderationRoomId)) { - continue; - } - await this.owner.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { - "m.relates_to": { - "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 - // Shorten text if it is too long. - if (text.length > maxLength) { - text = text.substring(0, maxLength); - } - // Shorten text if there are too many newlines. - // Note: This only looks for text newlines, not `
    `, `
  • ` or any other HTML box. - let index = -1; - let newLines = 0; - while (true) { - index = text.indexOf("\n", index); - if (index === -1) { - break; - } - index += 1; - newLines += 1; - if (newLines > maxNewlines) { - text = text.substring(0, index); - break; - } - }; - if (text.length < originalLength) { - return `${text}... [total: ${originalLength} characters]`; - } else { - return text; - } - } -} - /** * The actions we may be able to undertake in reaction to a report. * @@ -928,8 +261,8 @@ const ACTION_LIST = [ new BanAccused(), new EscalateToServerModerationRoom(), new IgnoreBadReport(), - new Help() ]; +ACTION_LIST.push(new Help(ACTION_LIST.map(action => [action.label, action]))); /** * The actions we may be able to undertake in reaction to a report. * @@ -937,6 +270,3 @@ const ACTION_LIST = [ */ const ACTIONS = new Map(ACTION_LIST.map(action => [action.label, action])); -function getHomeserver(userId: string): string { - return new UserID(userId).domain -} diff --git a/src/utils.ts b/src/utils.ts index 4e9dbc1..6095e1e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -325,3 +325,44 @@ export function patchMatrixClient() { // errors. patchMatrixClientForConciseExceptions(); } + +/** + * Shorten a string to a maximal number of characters and newlines. + * + * @param text The original text. + * @param maxLength The maximal length to return, in chars. + * @param maxNewlines The maximal number of lines to return. + * @returns The largest prefix of `text` with at most `maxLength` chars and `maxNewLines` lines. + */ +export function limitLength(text: string, maxLength: number, maxNewlines: number): string { + let originalLength = text.length + // Shorten text if it is too long. + if (text.length > maxLength) { + text = text.substring(0, maxLength); + } + // Shorten text if there are too many newlines. + // Note: This only looks for text newlines, not `
    `, `
  • ` or any other HTML box. + let index = -1; + let newLines = 0; + while (true) { + index = text.indexOf("\n", index); + if (index === -1) { + break; + } + index += 1; + newLines += 1; + if (newLines > maxNewlines) { + text = text.substring(0, index); + break; + } + }; + if (text.length < originalLength) { + return `${text}... [total: ${originalLength} characters]`; + } else { + return text; + } +} + +export function getHomeserver(userId: string): string { + return new UserID(userId).domain +}