From 67adb530bc35328c6aa40b104a75acfdebaa6a46 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 13 Nov 2023 18:04:22 +0000 Subject: [PATCH] Start cleaning up Mjolnir into Draupnir, starting with listeners. --- src/Draupnir.ts | 112 +++++++++++++++++++++++++++++++++ src/commands/CommandHandler.ts | 92 +++++++++++++++++++-------- 2 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 src/Draupnir.ts diff --git a/src/Draupnir.ts b/src/Draupnir.ts new file mode 100644 index 00000000..bee61503 --- /dev/null +++ b/src/Draupnir.ts @@ -0,0 +1,112 @@ +/** + * Copyright (C) 2022-2023 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * + * However, this file is modified and the modifications in this file + * are NOT distributed, contributed, committed, or licensed under the Apache License. + */ + +import { Logger, Ok, ProtectedRoomsSet, Task, TextMessageContent, Value } from "matrix-protection-suite"; +import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; +import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; +import { WebAPIs } from "./webapis/WebAPIs"; +import { ThrottlingQueue } from "./queues/ThrottlingQueue"; +import ManagementRoomOutput from "./ManagementRoomOutput"; +import { ReportPoller } from "./report/ReportPoller"; +import { ProtectionManager } from "./protections/ProtectionManager"; +import { ReportManager } from "./report/ReportManager"; +import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; +import { MatrixSendClient, SafeMatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { IConfig } from "./config"; +import { COMMAND_PREFIX, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; + +const log = new Logger('Draupnir'); + +export class Draupnir { + private displayName: string; + private localpart: string; + /** + * This is for users who are not listed on a watchlist, + * but have been flagged by the automatic spam detection as suispicous + */ + private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); + + private webapis: WebAPIs; + private readonly commandTable = findCommandTable("mjolnir"); + public taskQueue: ThrottlingQueue; + /** + * Reporting back to the management room. + */ + public readonly managementRoomOutput: ManagementRoomOutput; + /* + * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports + */ + private reportPoller?: ReportPoller; + /** + * Store the protections being used by Mjolnir. + */ + public readonly legacyProtectionManager: ProtectionManager; + /** + * Handle user reports from the homeserver. + */ + public readonly reportManager: ReportManager; + + public readonly reactionHandler: MatrixReactionHandler; + private constructor( + public readonly client: MatrixSendClient, + private readonly clientUserId: string, + public readonly matrixEmitter: SafeMatrixEmitter, + public readonly managementRoomId: string, + public readonly config: IConfig, + public readonly protectedRoomsSet: ProtectedRoomsSet + ) { + this.reactionHandler = new MatrixReactionHandler(this.managementRoomId, client, clientUserId); + this.setupMatrixEmitterListeners(); + } + + private setupMatrixEmitterListeners(): void { + this.matrixEmitter.on("room.message", (roomID, event) => { + if (roomID !== this.managementRoomId) { + return; + } + if (Value.Check(TextMessageContent, event.content)) { + const commandBeingRun = extractCommandFromMessageBody( + event.content.body, + { + prefix: COMMAND_PREFIX, + localpart: this.localpart, + displayName: this.displayName, + userId: this.clientUserId, + additionalPrefixes: this.config.commands.additionalPrefixes, + allowNoPrefix: this.config.commands.allowNoPrefix, + } + ); + if (commandBeingRun === undefined) { + return; + } + log.info(`Command being run by ${event.sender}: ${commandBeingRun}`); + Task(this.client.sendReadReceipt(roomID, event.event_id).then((_) => Ok(undefined))) + Task(handleCommand(roomID, event, commandBeingRun, this, this.commandTable).then((_) => Ok(undefined))); + } + }); + } +} diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f2212db3..4e180882 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -73,75 +73,113 @@ import "./Rules"; import "./WatchUnwatchCommand"; import "./Help"; import "./SetDisplayNameCommand"; +import { RoomMessage, StringRoomID } from "matrix-protection-suite"; export const COMMAND_PREFIX = "!mjolnir"; -export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir, commandTable: CommandTable) { - const cmd = event['content']['body']; - const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); +export async function handleCommand( + roomID: StringRoomID, + event: RoomMessage, + normalisedCommand: string, + mjolnir: Mjolnir, + commandTable: CommandTable +) { + const parts = normalisedCommand.trim().split(' ').filter(p => p.trim().length > 0); // A shell-style parser that can parse `"a b c"` (with quotes) as a single argument. // We do **not** want to parse `#` as a comment start, though. - const tokens = tokenize(cmd.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); + const tokens = tokenize(normalisedCommand.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); try { if (parts[1] === 'joins') { - return await showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 2)); + return await showJoinsStatus(roomID, event, mjolnir, parts.slice(/* ["joins"] */ 2)); } else if (parts[1] === 'sync') { - return await execSyncCommand(roomId, event, mjolnir); + return await execSyncCommand(roomID, event, mjolnir); } else if (parts[1] === 'verify') { - return await execPermissionCheckCommand(roomId, event, mjolnir); + return await execPermissionCheckCommand(roomID, event, mjolnir); } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { - return await execCreateListCommand(roomId, event, mjolnir, parts); + return await execCreateListCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'redact' && parts.length > 1) { - return await execRedactCommand(roomId, event, mjolnir, parts); + return await execRedactCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'import' && parts.length > 2) { - return await execImportCommand(roomId, event, mjolnir, parts); + return await execImportCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'default' && parts.length > 2) { - return await execSetDefaultListCommand(roomId, event, mjolnir, parts); + return await execSetDefaultListCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'protections') { - return await execListProtections(roomId, event, mjolnir, parts); + return await execListProtections(roomID, event, mjolnir, parts); } else if (parts[1] === 'enable' && parts.length > 1) { - return await execEnableProtection(roomId, event, mjolnir, parts); + return await execEnableProtection(roomID, event, mjolnir, parts); } else if (parts[1] === 'disable' && parts.length > 1) { - return await execDisableProtection(roomId, event, mjolnir, parts); + return await execDisableProtection(roomID, event, mjolnir, parts); } else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) { - return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3)) + return await execConfigSetProtection(roomID, event, mjolnir, parts.slice(3)) } else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) { - return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3)) + return await execConfigAddProtection(roomID, event, mjolnir, parts.slice(3)) } else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) { - return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3)) + return await execConfigRemoveProtection(roomID, event, mjolnir, parts.slice(3)) } else if (parts[1] === 'config' && parts[2] === 'get') { - return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3)) + return await execConfigGetProtection(roomID, event, mjolnir, parts.slice(3)) } else if (parts[1] === 'resolve' && parts.length > 2) { - return await execResolveCommand(roomId, event, mjolnir, parts); + return await execResolveCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'powerlevel' && parts.length > 3) { - return await execSetPowerLevelCommand(roomId, event, mjolnir, parts); + return await execSetPowerLevelCommand(roomID, event, mjolnir, parts); } else if (parts[1] === 'since') { - return await execSinceCommand(roomId, event, mjolnir, tokens); + return await execSinceCommand(roomID, event, mjolnir, tokens); } else if (parts[1] === 'kick' && parts.length > 2) { - return await execKickCommand(roomId, event, mjolnir, parts); + return await execKickCommand(roomID, event, mjolnir, parts); } else { - const readItems = readCommand(cmd).slice(1); // remove "!mjolnir" + const readItems = readCommand(normalisedCommand).slice(1); // remove "!mjolnir" const stream = new ArgumentStream(readItems); const command = commandTable.findAMatchingCommand(stream) ?? findTableCommand("mjolnir", "help"); const adaptor = findMatrixInterfaceAdaptor(command); const mjolnirContext: MjolnirContext = { - mjolnir, roomId, event, client: mjolnir.client, emitter: mjolnir.matrixEmitter, + mjolnir, roomId: roomID, event, client: mjolnir.client, emitter: mjolnir.matrixEmitter, }; try { return await adaptor.invoke(mjolnirContext, mjolnirContext, ...stream.rest()); } catch (e) { const commandError = new CommandException(CommandExceptionKind.Unknown, e, 'Unknown Unexpected Error'); - await tickCrossRenderer.call(mjolnirContext, mjolnir.client, roomId, event, CommandResult.Err(commandError)); + await tickCrossRenderer.call(mjolnirContext, mjolnir.client, roomID, event, CommandResult.Err(commandError)); } } } catch (e) { LogService.error("CommandHandler", e); const text = "There was an error processing your command - see console/log for details"; - const reply = RichReply.createFor(roomId, event, text, text); + const reply = RichReply.createFor(roomID, event, text, text); reply["msgtype"] = "m.notice"; - return await mjolnir.client.sendMessage(roomId, reply); + return await mjolnir.client.sendMessage(roomID, reply); } } + +export function extractCommandFromMessageBody( + body: string, + { prefix, + localpart, + displayName, + userId, + additionalPrefixes, + allowNoPrefix + }: { + prefix: string, + localpart: string, + displayName: string, + userId: string, + additionalPrefixes: string[], + allowNoPrefix: boolean +}): string | undefined { + const plainPrefixes = [prefix, localpart, displayName, userId, ...additionalPrefixes]; + const allPossiblePrefixes = [ + ...plainPrefixes.map(p => `!${p}`), + ...plainPrefixes.map(p => `${p}:`), + ...plainPrefixes, + ...allowNoPrefix ? ['!'] : [], + ]; + const usedPrefixInMessage = allPossiblePrefixes.find(p => body.toLowerCase().startsWith(p.toLowerCase())); + if (usedPrefixInMessage === undefined) { + return; + } + // normalise the event body to make the prefix uniform (in case the bot has spaces in its display name) + const restOfBody = body.substring(usedPrefixInMessage.length); + return prefix + restOfBody.startsWith(' ') ? restOfBody : ` ${restOfBody}`; +}