diff --git a/package.json b/package.json index 659c403b..f35c4e97 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start:dev": "yarn build && node --async-stack-traces lib/index.js", "test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts", "test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"", + "test:YORIC": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/roomMembersTest.ts\"", "test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts", "version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py" }, @@ -45,7 +46,7 @@ "jsdom": "^16.6.0", "matrix-bot-sdk": "^0.5.19", "parse-duration": "^1.0.2", - "shell-quote": "^1.7.3" + "tokenizr": "^1.6.7" }, "engines": { "node": ">=16.0.0" diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 60260726..e990b14f 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -30,7 +30,6 @@ import { import BanList, { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, ListRuleChange, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/BanList"; import { applyServerAcls } from "./actions/ApplyAcl"; import { RoomUpdateError } from "./models/RoomUpdateError"; -import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; import { applyUserBans } from "./actions/ApplyBan"; import config from "./config"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; @@ -50,6 +49,7 @@ import RuleServer from "./models/RuleServer"; import { RoomMemberManager } from "./RoomMembers"; import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; +import { CommandManager } from "./commands/Command"; const levelToFn = { [LogLevel.DEBUG.toString()]: LogService.debug, @@ -79,6 +79,7 @@ export class Mjolnir { private localpart: string; private currentState: string = STATE_NOT_STARTED; public readonly roomJoins: RoomMemberManager; + public readonly commandManager: CommandManager; public protections = new Map(); /** * This is for users who are not listed on a watchlist, @@ -202,45 +203,12 @@ export class Mjolnir { this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); } + this.commandManager = new CommandManager(managementRoomId, this); + // Setup bot. client.on("room.event", this.handleEvent.bind(this)); - client.on("room.message", async (roomId, event) => { - if (roomId !== this.managementRoomId) return; - if (!event['content']) return; - - const content = event['content']; - if (content['msgtype'] === "m.text" && content['body']) { - const prefixes = [ - COMMAND_PREFIX, - this.localpart + ":", - this.displayName + ":", - await client.getUserId() + ":", - this.localpart + " ", - this.displayName + " ", - await client.getUserId() + " ", - ...config.commands.additionalPrefixes.map(p => `!${p}`), - ...config.commands.additionalPrefixes.map(p => `${p}:`), - ...config.commands.additionalPrefixes.map(p => `${p} `), - ...config.commands.additionalPrefixes, - ]; - if (config.commands.allowNoPrefix) prefixes.push("!"); - - const prefixUsed = prefixes.find(p => content['body'].toLowerCase().startsWith(p.toLowerCase())); - if (!prefixUsed) return; - - // rewrite the event body to make the prefix uniform (in case the bot has spaces in its display name) - let restOfBody = content['body'].substring(prefixUsed.length); - if (!restOfBody.startsWith(" ")) restOfBody = ` ${restOfBody}`; - event['content']['body'] = COMMAND_PREFIX + restOfBody; - LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`); - - await client.sendReadReceipt(roomId, event['event_id']); - return handleCommand(roomId, event, this); - } - }); - client.on("room.join", (roomId: string, event: any) => { LogService.info("Mjolnir", `Joined ${roomId}`); return this.resyncJoinedRooms(); @@ -257,6 +225,19 @@ export class Mjolnir { if (profile['displayname']) { this.displayName = profile['displayname']; } + }).then(() => { + const prefixes = [ + "mjolnir", + this.localpart, + ]; + if (this.displayName) { + prefixes.push(this.displayName); + } + prefixes.push(...config.commands.additionalPrefixes); + if (config.commands.allowNoPrefix) { + prefixes.push("!"); + } + this.commandManager.init(prefixes); }); // Setup room activity watcher diff --git a/src/commands/Command.ts b/src/commands/Command.ts new file mode 100644 index 00000000..2b7da8c6 --- /dev/null +++ b/src/commands/Command.ts @@ -0,0 +1,396 @@ +/* +Copyright 2022 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. +*/ + +import { Mjolnir } from '../Mjolnir'; +import Tokenizr from "tokenizr"; + +// For some reason, different versions of TypeScript seem +// to disagree on how to import Tokenizr +import * as TokenizrModule from "tokenizr"; +import { htmlEscape, parseDuration } from "../utils"; +import { LogService, RichReply } from 'matrix-bot-sdk'; +const TokenizrClass = Tokenizr || TokenizrModule; + +const WHITESPACE = /\s+/; +const COMMAND = /[a-zA-Z_]+/; +const USER_ID = /@[a-zA-Z0-9_.=\-/]+:\S+/; +const GLOB_USER_ID = /@[a-zA-Z0-9_.=\-?*/]+:\S+/; +const ROOM_ID = /![a-zA-Z0-9_.=\-/]+:\S+/; +const ROOM_ALIAS = /#[a-zA-Z0-9_.=\-/]+:\S+/; +const ROOM_ALIAS_OR_ID = /[#!][a-zA-Z0-9_.=\-/]+:\S+/; +const INT = /[+-]?[0-9]+/; +const STRING = /"((?:\\"|[^\r\n])*)"/; +const DATE_OR_DURATION = /(?:"([^"]+)")|([^"]\S+)/; +const STAR = /\*/; +const ETC = /.*$/; +const WORD = /\S+/; +const PERMALINK = /https:\/\/matrix.to\*\S+]/; + +export enum Token { + WHITESPACE = "whitespace", + COMMAND = "command", + USER_ID = "userID", + GLOB_USER_ID = "globUserID", + ROOM_ID = "roomID", + ROOM_ALIAS = "roomAlias", + ROOM_ALIAS_OR_ID = "roomAliasOrID", + INT = "int", + STRING = "string", + DATE_OR_DURATION = "dateOrDuration", + STAR = "star", + ETC = "etc", + WORD = "word", + PERMALINK = "permalink", + DATE = "date", + DURATION = "duration", +} + +/** + * A lexer for command parsing. + * + * Recommended use is `lexer.token("state")`. + */ +export class Lexer extends TokenizrClass { + constructor(string: string) { + super(); + // Ignore whitespace. + this.rule(WHITESPACE, (ctx) => { + ctx.ignore() + }) + + // Identifier rules, used e.g. for subcommands `get`, `set` ... + this.rule(Token.COMMAND, COMMAND, (ctx) => { + ctx.accept(Token.COMMAND); + }); + + // Users + this.rule(Token.USER_ID, USER_ID, (ctx) => { + ctx.accept(Token.USER_ID); + }); + this.rule(Token.GLOB_USER_ID, GLOB_USER_ID, (ctx) => { + ctx.accept(Token.GLOB_USER_ID); + }); + + // Rooms + this.rule(Token.ROOM_ID, ROOM_ID, (ctx) => { + ctx.accept(Token.ROOM_ID); + }); + this.rule(Token.ROOM_ALIAS, ROOM_ALIAS, (ctx) => { + ctx.accept(Token.ROOM_ALIAS); + }); + this.rule(Token.ROOM_ALIAS_OR_ID, ROOM_ALIAS_OR_ID, (ctx) => { + ctx.accept(Token.ROOM_ALIAS_OR_ID); + }); + + // Numbers. + this.rule(Token.INT, INT, (ctx, match) => { + ctx.accept(Token.INT, parseInt(match[0])) + }); + + // Quoted strings. + this.rule(Token.STRING, STRING, (ctx, match) => { + ctx.accept(Token.STRING, match[1].replace(/\\"/g, "\"")) + }); + + // Dates and durations. + this.rule(Token.DATE_OR_DURATION, DATE_OR_DURATION, (ctx, match) => { + let content = match[1] || match[2]; + let date = new Date(content); + if (!date || Number.isNaN(date.getDate())) { + let duration = parseDuration(content); + if (!duration || Number.isNaN(duration)) { + ctx.reject(); + } else { + ctx.accept(Token.DURATION, duration); + } + } else { + ctx.accept(Token.DATE, date); + } + }); + + this.rule(Token.PERMALINK, PERMALINK, (ctx) => { + ctx.accept(Token.PERMALINK); + }); + + // Jokers. + this.rule(Token.STAR, STAR, (ctx) => { + ctx.accept(Token.STAR); + }); + this.rule(Token.WORD, WORD, (ctx)=> { + ctx.accept(Token.WORD); + }); + + // Everything left in the string. + this.rule(Token.ETC, ETC, (ctx, match) => { + ctx.accept(Token.ETC, match[0].trim()); + }); + + this.input(string); + } + + public token(state?: Token | string): TokenizrModule.Token { + if (typeof state !== "undefined") { + this.state(state); + } + return super.token(); + } +} + +export interface Command { + /** + * The name for the command, e.g. "get". + */ + readonly command: string; + + /** + * A human-readable help for the command. + */ + readonly helpDescription: string; + + /** + * A human-readable description for the arguments. + */ + readonly helpArgs: string; + + readonly accept?: (lexer: Lexer) => boolean; + + /** + * Execute the command. + * + * @param mjolnir The owning instance of Mjolnir. + * @param roomID The command room. Used mainly to display responses. + * @param lexer The lexer holding the command-line. Both `!mjolnir` (or equivalent) and `this.command` + * have already been consumed. This `Command` is responsible for validating the contents + * of this command-line. + * @param event The original event. Used mainly to post response. + */ + exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise; +} + +export class CommandManager { + /** + * All commands, in the order of registration. + */ + private readonly commands: Command[]; + + /** + * A map of command string (e.g. `status`) to `Command`. + */ + private readonly commandsPerCommand: Map; + + /** + * The command used when no command is given. + */ + private defaultCommand: Command | null; + + /** + * The command used to display the help message. + */ + private readonly helpCommand: Command; + + /** + * The callback used to process messages. + */ + private readonly onMessageCallback: (roomId: string, event: any) => Promise; + + /** + * All the prefixes this bot needs to answer to. + */ + private readonly prefixes: string[] = []; + + /** + * Register a new command. + */ + public add(command: Command, options: { isDefault?: boolean } = {}) { + const isDefault = options?.isDefault || false; + this.commands.push(command); + this.commandsPerCommand.set(command.command, command); + if (isDefault) { + this.defaultCommand = command; + } + } + + public constructor( + private readonly managementRoomId: string, + private readonly mjolnir: Mjolnir + ) { + this.onMessageCallback = this.handleMessage.bind(this); + + // Prepare help message. + const commands = this.commands; + const getMainPrefix = () => this.prefixes[0].trim(); + class HelpCommand implements Command { + command: "help"; + helpDescription: "This help message"; + // For the time being we don't support `!mjolnir help `. + helpArgs: ""; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + // Inject the help at the end of commands. + let allCommands = [...commands, this]; + + let prefixes = []; + let width = 0; + let mainPrefix = getMainPrefix(); + + // Compute width to display the help properly. + for (let command of allCommands) { + let prefix = `${mainPrefix} ${command.command} ${command.helpArgs} `; + width = Math.max(width, prefix.length); + prefixes.push(prefix); + } + + // Now build actual help message. + let lines = []; + for (let i = 0; i < prefixes.length; ++i) { + let prefix = prefixes[i].padEnd(width); + let line = `${prefix} - ${allCommands[i].helpDescription}`; + lines.push(line); + } + + let message = lines.join("\n"); + const html = `Mjolnir help:
${htmlEscape(message)}
`; + const text = `Mjolnir help:\n${message}`; + const reply = RichReply.createFor(roomID, event, text, html); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomID, reply); + } + } + this.helpCommand = new HelpCommand(); + } + + public async init(prefixes: string[]) { + // Prepare prefixes. + this.prefixes.length = 0; + for (let prefix of prefixes) { + let lowercase = prefix.trim().toLowerCase(); + if (!lowercase.startsWith("!")) { + // Note: This means that if the prefix is `!mjolnir`, we will also + // respond to `!mjolniren` or any other suffixed variant. + this.prefixes.push(`!${lowercase}`); + } + if (!lowercase.endsWith(":")) { + this.prefixes.push(`${lowercase}:`); + } + this.prefixes.push(`${lowercase} `); + } + + this.mjolnir.client.on("room.message", this.onMessageCallback); + } + + public async dispose() { + this.mjolnir.client.removeListener("room.message", this.onMessageCallback); + } + + /** + * Handle messages in any room to which we belong. + * + * @param roomId The room in which the message is received. + * @param event An untrusted event. + */ + private async handleMessage(roomId: string, event: any) { + try { + if (roomId != this.managementRoomId) { + // Security-critical: We only ever accept commands from our management room. + return; + } + const content = event['content']; + if (!content || content['msgtype'] !== "m.text" || content['body']) { + return; + } + + const body = content['body']; + const lowercaseBody = body.toLowerCase(); + const prefixUsed = this.prefixes.find(p => lowercaseBody.startsWith(p)); + if (!prefixUsed) { + // Not a message for the bot. + return; + } + + // Consume the prefix. + // Note: We're making the assumption that uppercase and lowercase have the + // same length. This might not be true in some locales. + const line = body.substring(prefixUsed.length).trim(); + LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`); + /* No need to await */ this.mjolnir.client.sendReadReceipt(roomId, event['event_id']); + + // Lookup the command. + // It's complicated a bit by the fact that we have commands: + // - containing spaces; + // - that are prefixes of other commands. + // In theory, this could probably be fixed by introducing + // subcommands, sub-sub-commands, etc. but as of this writing, + // I have not found how to implement that without introducing + // backwards incompatibilities. + let cmd; + if (line.length === 0) { + cmd = this.defaultCommand; + } else { + // Scan full list, looking for longest match. + let longestLength = -1; + for (let command of this.commands) { + if (command.command.length > longestLength + && line.startsWith(command.command)) { + if (command.accept) { + let lexer = new Lexer(line.substring(command.command.length)); + if (!command.accept(lexer)) { + continue; + } + } + longestLength = command.command.length; + cmd = command; + } + } + } + + let lexer; + if (cmd) { + lexer = new Lexer(line.substring(cmd.command.length).trim()); + } else { + // Fallback to help. + // Don't attempt to parse line. + cmd = this.helpCommand; + lexer = new Lexer(""); + } + + await cmd.exec(this.mjolnir, roomId, lexer, event); + } catch (ex) { + if (ex instanceof Lexer.ParsingError) { + this.helpCommand.exec(this.mjolnir, roomId, new Lexer(""), event); + } else { + LogService.error("Mjolnir", `Error while processing command: ${ex}`); + const text = `There was an error processing your command: ${htmlEscape(ex.message)}`; + const reply = RichReply.createFor(roomId, event, text, text); + reply["msgtype"] = "m.notice"; + await this.mjolnir.client.sendMessage(roomId, reply); + } + } + } +} + +export abstract class AbstractLegacyCommand implements Command { + abstract command: string; + abstract helpDescription: string; + abstract helpArgs: string; + abstract legacyExec(roomID: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + // Fit legacy signature into `lexer`-based parsing. + const line = lexer.token("ETC").text; + const parts = line.trim().split(' ').filter(p => p.trim().length > 0); + parts.unshift("!mjolnir", this.command); + await this.legacyExec(roomID, event, mjolnir, parts); + } +} + diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 4d967804..37b4d99b 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -15,134 +15,114 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; -import { execStatusCommand } from "./StatusCommand"; -import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand"; -import { execDumpRulesCommand, execRulesMatchingCommand } from "./DumpRulesCommand"; +import { StatusCommand } from "./StatusCommand"; +import { BanCommand, UnbanCommand } from "./UnbanBanCommand"; +import { DumpRulesCommand, RulesMatchingCommand } from "./DumpRulesCommand"; import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk"; import { htmlEscape } from "../utils"; -import { execSyncCommand } from "./SyncCommand"; -import { execPermissionCheckCommand } from "./PermissionCheckCommand"; -import { execCreateListCommand } from "./CreateBanListCommand"; -import { execUnwatchCommand, execWatchCommand } from "./WatchUnwatchCommand"; -import { execRedactCommand } from "./RedactCommand"; -import { execImportCommand } from "./ImportCommand"; -import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; +import { SyncCommand } from "./SyncCommand"; +import { PermissionCheckCommand } from "./PermissionCheckCommand"; +import { CreateListCommand } from "./CreateBanListCommand"; +import { UnwatchCommand, WatchCommand } from "./WatchUnwatchCommand"; +import { RedactPermalinkCommand, RedactUserCommand } from "./RedactCommand"; +import { ImportCommand } from "./ImportCommand"; +import { SetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execDeactivateCommand } from "./DeactivateCommand"; -import { execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection, - execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection } from "./ProtectionsCommands"; +import { + execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection, + execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection +} from "./ProtectionsCommands"; import { execListProtectedRooms } from "./ListProtectedRoomsCommand"; import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand"; import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand"; import { execSetPowerLevelCommand } from "./SetPowerLevelCommand"; import { execShutdownRoomCommand } from "./ShutdownRoomCommand"; import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands"; -import { execKickCommand } from "./KickCommand"; import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; -import { parse as tokenize } from "shell-quote"; import { execSinceCommand } from "./SinceCommand"; +import { KickCommand } from "./KickCommand"; +export function init(mjolnir: Mjolnir) { + for (let command of [ + new StatusCommand(), + new KickCommand(), + new BanCommand(), + new UnbanCommand(), + new RulesMatchingCommand(), + new DumpRulesCommand(), + new SyncCommand(), + new PermissionCheckCommand(), + new CreateListCommand(), + new WatchCommand(), + new UnwatchCommand(), + new RedactUserCommand(), + new RedactPermalinkCommand(), + new ImportCommand(), + new SetDefaultListCommand(), + ]) + mjolnir.commandManager.add(command); +} -export const COMMAND_PREFIX = "!mjolnir"; export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { - const cmd = event['content']['body']; - const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); + const line = event['content']['body']; + const parts = line.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 lexer = new Lexer(line); + lexer.token("command"); // Consume `!mjolnir`. + // Extract command. + const cmd = lexer.alternatives( + () => lexer.token("id").text, + () => null + ); try { - if (parts.length === 1 || parts[1] === 'status') { - return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); - } else if (parts[1] === 'ban' && parts.length > 2) { - return await execBanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unban' && parts.length > 2) { - return await execUnbanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') { - return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) - } else if (parts[1] === 'rules') { - return await execDumpRulesCommand(roomId, event, mjolnir); - } else if (parts[1] === 'sync') { - return await execSyncCommand(roomId, event, mjolnir); - } else if (parts[1] === 'verify') { - return await execPermissionCheckCommand(roomId, event, mjolnir); - } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { - return await execCreateListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'watch' && parts.length > 1) { - return await execWatchCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unwatch' && parts.length > 1) { - return await execUnwatchCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'redact' && parts.length > 1) { - return await execRedactCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'import' && parts.length > 2) { - return await execImportCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'default' && parts.length > 2) { - return await execSetDefaultListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'deactivate' && parts.length > 2) { + if (cmd === 'deactivate' && parts.length > 2) { return await execDeactivateCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'protections') { + } else if (cmd === 'protections') { return await execListProtections(roomId, event, mjolnir, parts); - } else if (parts[1] === 'enable' && parts.length > 1) { + } else if (cmd === 'enable' && parts.length > 1) { return await execEnableProtection(roomId, event, mjolnir, parts); - } else if (parts[1] === 'disable' && parts.length > 1) { + } else if (cmd === 'disable' && parts.length > 1) { return await execDisableProtection(roomId, event, mjolnir, parts); - } else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) { + } else if (cmd === 'config' && parts[2] === 'set' && parts.length > 3) { return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) { + } else if (cmd === 'config' && parts[2] === 'add' && parts.length > 3) { return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) { + } else if (cmd === 'config' && parts[2] === 'remove' && parts.length > 3) { return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'config' && parts[2] === 'get') { + } else if (cmd === 'config' && parts[2] === 'get') { return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3)) - } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'add') { + } else if (cmd === 'rooms' && parts.length > 3 && parts[2] === 'add') { return await execAddProtectedRoom(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') { + } else if (cmd === 'rooms' && parts.length > 3 && parts[2] === 'remove') { return await execRemoveProtectedRoom(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rooms' && parts.length === 2) { + } else if (cmd === 'rooms' && parts.length === 2) { return await execListProtectedRooms(roomId, event, mjolnir); - } else if (parts[1] === 'move' && parts.length > 3) { + } else if (cmd === 'move' && parts.length > 3) { return await execMoveAliasCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'directory' && parts.length > 3 && parts[2] === 'add') { + } else if (cmd === 'directory' && parts.length > 3 && parts[2] === 'add') { return await execAddRoomToDirectoryCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'directory' && parts.length > 3 && parts[2] === 'remove') { + } else if (cmd === 'directory' && parts.length > 3 && parts[2] === 'remove') { return await execRemoveRoomFromDirectoryCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'alias' && parts.length > 4 && parts[2] === 'add') { + } else if (cmd === 'alias' && parts.length > 4 && parts[2] === 'add') { return await execAddAliasCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'alias' && parts.length > 3 && parts[2] === 'remove') { + } else if (cmd === 'alias' && parts.length > 3 && parts[2] === 'remove') { return await execRemoveAliasCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'resolve' && parts.length > 2) { + } else if (cmd === 'resolve' && parts.length > 2) { return await execResolveCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'powerlevel' && parts.length > 3) { + } else if (cmd === 'powerlevel' && parts.length > 3) { return await execSetPowerLevelCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'shutdown' && parts[2] === 'room' && parts.length > 3) { + } else if (cmd === 'shutdown' && parts[2] === 'room' && parts.length > 3) { return await execShutdownRoomCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'since') { - return await execSinceCommand(roomId, event, mjolnir, tokens); - } else if (parts[1] === 'kick' && parts.length > 2) { - return await execKickCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'make' && parts[2] === 'admin' && parts.length > 3) { + } else if (cmd === 'since') { + return await execSinceCommand(roomId, event, mjolnir, lexer); + } else if (cmd === 'make' && parts[2] === 'admin' && parts.length > 3) { return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); } else { // Help menu const menu = "" + - "!mjolnir - Print status information\n" + - "!mjolnir status - Print status information\n" + - "!mjolnir status protection [subcommand] - Print status information for a protection\n" + - "!mjolnir ban [reason] - Adds an entity to the ban list\n" + - "!mjolnir unban [apply] - Removes an entity from the ban list. If apply is 'true', the users matching the glob will actually be unbanned\n" + - "!mjolnir redact [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" + - "!mjolnir redact - Redacts a message by permalink\n" + "!mjolnir kick [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" + - "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + - "!mjolnir rules matching - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user." + - "!mjolnir sync - Force updates of all lists and re-apply rules\n" + - "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + - "!mjolnir list create - Creates a new ban list with the given shortcode and alias\n" + - "!mjolnir watch - Watches a ban list\n" + - "!mjolnir unwatch - Unwatches a ban list\n" + - "!mjolnir import - Imports bans and ACLs into the given list\n" + - "!mjolnir default - Sets the default list for commands\n" + "!mjolnir deactivate - Deactivates a user ID\n" + "!mjolnir protections - List all available protections\n" + "!mjolnir enable - Enables a particular protection\n" + @@ -163,8 +143,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir since / [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since / (up to users)\n" + "!mjolnir shutdown room [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n" + "!mjolnir powerlevel [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms)\n" + - "!mjolnir make admin [user alias/ID] - Make the specified user or the bot itself admin of the room\n" + - "!mjolnir help - This menu\n"; + "!mjolnir make admin [user alias/ID] - Make the specified user or the bot itself admin of the room\n" + ; const html = `Mjolnir help:
${htmlEscape(menu)}
`; const text = `Mjolnir help:\n${menu}`; const reply = RichReply.createFor(roomId, event, text, html); diff --git a/src/commands/CreateBanListCommand.ts b/src/commands/CreateBanListCommand.ts index 68f07864..e7a6f49e 100644 --- a/src/commands/CreateBanListCommand.ts +++ b/src/commands/CreateBanListCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 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. @@ -17,47 +17,52 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { SHORTCODE_EVENT_TYPE } from "../models/BanList"; import { Permalinks, RichReply } from "matrix-bot-sdk"; +import { Command, Lexer, Token } from "./Command"; // !mjolnir list create -export async function execCreateListCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const shortcode = parts[3]; - const aliasLocalpart = parts[4]; - - const powerLevels: { [key: string]: any } = { - "ban": 50, - "events": { - "m.room.name": 100, - "m.room.power_levels": 100, - }, - "events_default": 50, // non-default - "invite": 0, - "kick": 50, - "notifications": { - "room": 20, - }, - "redact": 50, - "state_default": 50, - "users": { - [await mjolnir.client.getUserId()]: 100, - [event["sender"]]: 50 - }, - "users_default": 0, - }; - - const listRoomId = await mjolnir.client.createRoom({ - preset: "public_chat", - room_alias_name: aliasLocalpart, - invite: [event['sender']], - initial_state: [{type: SHORTCODE_EVENT_TYPE, state_key: "", content: {shortcode: shortcode}}], - power_level_content_override: powerLevels, - }); - - const roomRef = Permalinks.forRoom(listRoomId); - await mjolnir.watchList(roomRef); - - const html = `Created new list (${listRoomId}). This list is now being watched.`; - const text = `Created new list (${roomRef}). This list is now being watched.`; - const reply = RichReply.createFor(roomId, event, text, html); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); +export class CreateListCommand implements Command { + public readonly command: 'list create'; + public readonly helpDescription: 'Creates a new ban list with the given shortcode and alias'; + public readonly helpArgs: ' '; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + let shortcode = lexer.token(Token.WORD); + let aliasLocalpart = lexer.token(Token.WORD); + const powerLevels: { [key: string]: any } = { + "ban": 50, + "events": { + "m.room.name": 100, + "m.room.power_levels": 100, + }, + "events_default": 50, // non-default + "invite": 0, + "kick": 50, + "notifications": { + "room": 20, + }, + "redact": 50, + "state_default": 50, + "users": { + [await mjolnir.client.getUserId()]: 100, + [event["sender"]]: 50 + }, + "users_default": 0, + }; + + const listRoomId = await mjolnir.client.createRoom({ + preset: "public_chat", + room_alias_name: aliasLocalpart, + invite: [event['sender']], + initial_state: [{type: SHORTCODE_EVENT_TYPE, state_key: "", content: {shortcode: shortcode}}], + power_level_content_override: powerLevels, + }); + + const roomRef = Permalinks.forRoom(listRoomId); + await mjolnir.watchList(roomRef); + + const html = `Created new list (${listRoomId}). This list is now being watched.`; + const text = `Created new list (${roomRef}). This list is now being watched.`; + const reply = RichReply.createFor(roomID, event, text, html); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomID, reply); + } } diff --git a/src/commands/DeactivateCommand.ts b/src/commands/DeactivateCommand.ts index 39743f40..320f5dd0 100644 --- a/src/commands/DeactivateCommand.ts +++ b/src/commands/DeactivateCommand.ts @@ -16,20 +16,25 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { RichReply } from "matrix-bot-sdk"; +import { Command, Lexer, Token } from "./Command"; // !mjolnir deactivate -export async function execDeactivateCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const victim = parts[2]; +export class SetDefaultListCommand implements Command { + public readonly command: 'deactivate'; + public readonly helpDescription: 'Deactivates a user ID'; + public readonly helpArgs: ''; + async exec(mjolnir: Mjolnir, roomId: string, lexer: Lexer, event: any): Promise { + const victim = lexer.token(Token.USER_ID).text; + const isAdmin = await mjolnir.isSynapseAdmin(); + if (!isAdmin) { + const message = "I am not a Synapse administrator, or the endpoint is blocked"; + const reply = RichReply.createFor(roomId, event, message, message); + reply['msgtype'] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } - const isAdmin = await mjolnir.isSynapseAdmin(); - if (!isAdmin) { - const message = "I am not a Synapse administrator, or the endpoint is blocked"; - const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; - mjolnir.client.sendMessage(roomId, reply); - return; + await mjolnir.deactivateSynapseUser(victim); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } - - await mjolnir.deactivateSynapseUser(victim); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/commands/DumpRulesCommand.ts b/src/commands/DumpRulesCommand.ts index 2c6c3d78..18b7b9b7 100644 --- a/src/commands/DumpRulesCommand.ts +++ b/src/commands/DumpRulesCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 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. @@ -18,6 +18,16 @@ import { RichReply } from "matrix-bot-sdk"; import { Mjolnir } from "../Mjolnir"; import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../models/BanList"; import { htmlEscape } from "../utils"; +import { AbstractLegacyCommand } from "./Command"; + +export class RulesMatchingCommand extends AbstractLegacyCommand { + public readonly command: 'rules matching'; + public readonly helpDescription: 'Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user.'; + public readonly helpArgs: ''; + async legacyExec(roomID: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { + await execRulesMatchingCommand(roomID, event, mjolnir, parts[3]); + } +} /** * List all of the rules that match a given entity. @@ -29,7 +39,7 @@ import { htmlEscape } from "../utils"; * @param entity a user, room id or server. * @returns When a response has been sent to the command. */ -export async function execRulesMatchingCommand(roomId: string, event: any, mjolnir: Mjolnir, entity: string) { +async function execRulesMatchingCommand(roomId: string, event: any, mjolnir: Mjolnir, entity: string) { let html = ""; let text = ""; for (const list of mjolnir.lists) { @@ -71,8 +81,17 @@ export async function execRulesMatchingCommand(roomId: string, event: any, mjoln return mjolnir.client.sendMessage(roomId, reply); } +export class DumpRulesCommand extends AbstractLegacyCommand { + public readonly command: 'rules'; + public readonly helpDescription: 'Lists the rules currently in use by Mjolnir'; + public readonly helpArgs: ''; + async legacyExec(roomID: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { + await execDumpRulesCommand(roomID, event, mjolnir); + } +} + // !mjolnir rules -export async function execDumpRulesCommand(roomId: string, event: any, mjolnir: Mjolnir) { +async function execDumpRulesCommand(roomId: string, event: any, mjolnir: Mjolnir) { let html = "Rules currently in use:
"; let text = "Rules currently in use:\n"; diff --git a/src/commands/ImportCommand.ts b/src/commands/ImportCommand.ts index 077eb3fa..44d73268 100644 --- a/src/commands/ImportCommand.ts +++ b/src/commands/ImportCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 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. @@ -18,72 +18,79 @@ import { Mjolnir } from "../Mjolnir"; import { RichReply } from "matrix-bot-sdk"; import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule"; import { RULE_SERVER, RULE_USER, ruleTypeToStable } from "../models/BanList"; +import { Command, Lexer, Token } from "./Command"; // !mjolnir import -export async function execImportCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const importRoomId = await mjolnir.client.resolveRoom(parts[2]); - const list = mjolnir.lists.find(b => b.listShortcode === parts[3]); - if (!list) { - const errMessage = "Unable to find list - check your shortcode."; - const errReply = RichReply.createFor(roomId, event, errMessage, errMessage); - errReply["msgtype"] = "m.notice"; - mjolnir.client.sendMessage(roomId, errReply); - return; - } +export class ImportCommand implements Command { + public readonly command: 'import'; + public readonly helpDescription: 'Imports bans and ACLs into the given list'; + public readonly helpArgs: ' '; + async exec(mjolnir: Mjolnir, roomId: string, lexer: Lexer, event: any): Promise { + const importRoomId = await mjolnir.client.resolveRoom(lexer.token(Token.ROOM_ALIAS_OR_ID).text); + const shortcode = lexer.token(Token.WORD).text; + const list = mjolnir.lists.find(b => b.listShortcode === shortcode); + if (!list) { + const errMessage = "Unable to find list - check your shortcode."; + const errReply = RichReply.createFor(roomId, event, errMessage, errMessage); + errReply["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomId, errReply); + return; + } - let importedRules = 0; + let importedRules = 0; - const state = await mjolnir.client.getRoomState(importRoomId); - for (const stateEvent of state) { - const content = stateEvent['content'] || {}; - if (!content || Object.keys(content).length === 0) continue; + const state = await mjolnir.client.getRoomState(importRoomId); + for (const stateEvent of state) { + const content = stateEvent['content'] || {}; + if (!content || Object.keys(content).length === 0) continue; - if (stateEvent['type'] === 'm.room.member' && stateEvent['state_key'] !== '') { - // Member event - check for ban - if (content['membership'] === 'ban') { - const reason = content['reason'] || ''; + if (stateEvent['type'] === 'm.room.member' && stateEvent['state_key'] !== '') { + // Member event - check for ban + if (content['membership'] === 'ban') { + const reason = content['reason'] || ''; - await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding user ${stateEvent['state_key']} to ban list`); + await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding user ${stateEvent['state_key']} to ban list`); - const recommendation = recommendationToStable(RECOMMENDATION_BAN); - const ruleContent = { - entity: stateEvent['state_key'], - recommendation, - reason: reason, - }; - const stateKey = `rule:${ruleContent.entity}`; - let stableRule = ruleTypeToStable(RULE_USER); - if (stableRule) { - await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent); + const recommendation = recommendationToStable(RECOMMENDATION_BAN); + const ruleContent = { + entity: stateEvent['state_key'], + recommendation, + reason: reason, + }; + const stateKey = `rule:${ruleContent.entity}`; + let stableRule = ruleTypeToStable(RULE_USER); + if (stableRule) { + await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent); + } + importedRules++; } - importedRules++; - } - } else if (stateEvent['type'] === 'm.room.server_acl' && stateEvent['state_key'] === '') { - // ACL event - ban denied servers - if (!content['deny']) continue; - for (const server of content['deny']) { - const reason = ""; + } else if (stateEvent['type'] === 'm.room.server_acl' && stateEvent['state_key'] === '') { + // ACL event - ban denied servers + if (!content['deny']) continue; + for (const server of content['deny']) { + const reason = ""; - await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`); + await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`); - const recommendation = recommendationToStable(RECOMMENDATION_BAN); - const ruleContent = { - entity: server, - recommendation, - reason: reason, - }; - const stateKey = `rule:${ruleContent.entity}`; - let stableRule = ruleTypeToStable(RULE_SERVER); - if (stableRule) { - await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent); + const recommendation = recommendationToStable(RECOMMENDATION_BAN); + const ruleContent = { + entity: server, + recommendation, + reason: reason, + }; + const stateKey = `rule:${ruleContent.entity}`; + let stableRule = ruleTypeToStable(RULE_SERVER); + if (stableRule) { + await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent); + } + importedRules++; } - importedRules++; } } - } - const message = `Imported ${importedRules} rules to ban list`; - const reply = RichReply.createFor(roomId, event, message, message); - reply['msgtype'] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); + const message = `Imported ${importedRules} rules to ban list`; + const reply = RichReply.createFor(roomId, event, message, message); + reply['msgtype'] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); + } } diff --git a/src/commands/KickCommand.ts b/src/commands/KickCommand.ts index f841ef4a..3e20bfe7 100644 --- a/src/commands/KickCommand.ts +++ b/src/commands/KickCommand.ts @@ -17,63 +17,80 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk"; import config from "../config"; +import { Command, Lexer } from "./Command"; +import { Token } from "tokenizr"; -// !mjolnir kick [room] [reason] -export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let force = false; +export class KickCommand implements Command { + command: "kick"; + helpArgs: " [room alias/ID] [reason]"; + helpDescription: "Kicks a user or all of those matching a glob in a particular room or all protected rooms"; + async exec(mjolnir: Mjolnir, commandRoomId: string, lexer: Lexer, event: any): Promise { - const glob = parts[2]; - let rooms = [...Object.keys(mjolnir.protectedRooms)]; + // Parse command-line args. + let globUserID = lexer.token("globUserID").text; + let roomAliasOrIDToken: Token | null = lexer.alternatives( + () => lexer.token("roomAliasOrID"), + () => null, + ); + let reason = lexer.alternatives( + () => lexer.token("string"), + () => lexer.token("ETC") + ).text as string; - if (parts[parts.length - 1] === "--force") { - force = true; - parts.pop(); - } - - if (config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) { - let replyMessage = "Wildcard bans require an addition `--force` argument to confirm"; - const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); - return; - } - - const kickRule = new MatrixGlob(glob); - - let reason: string | undefined; - if (parts.length > 3) { - let reasonIndex = 3; - if (parts[3].startsWith("#") || parts[3].startsWith("!")) { - rooms = [await mjolnir.client.resolveRoom(parts[3])]; - reasonIndex = 4; + const ARG_FORCE = "--force"; + let hasForce = !config.commands.confirmWildcardBan; + if (reason.endsWith(ARG_FORCE)) { + reason = reason.slice(undefined, ARG_FORCE.length); + hasForce = true; + } + if (reason.trim().length == 0) { + reason = ""; } - reason = parts.slice(reasonIndex).join(' ') || ''; - } - if (!reason) reason = ''; - for (const protectedRoomId of rooms) { - const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]); + // Validate args. + if (!hasForce && /[*?]/.test(globUserID)) { + let replyMessage = "Wildcard bans require an addition `--force` argument to confirm"; + const reply = RichReply.createFor(commandRoomId, event, replyMessage, replyMessage); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(commandRoomId, reply); + return; + } - for (const member of members) { - const victim = member.membershipFor; + // Compute list of rooms. + let rooms; + if (roomAliasOrIDToken) { + rooms = [await mjolnir.client.resolveRoom(roomAliasOrIDToken.text)]; + } else { + rooms = [...Object.keys(mjolnir.protectedRooms)]; + } - if (kickRule.test(victim)) { - await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); + // Proceed. + const kickRule = new MatrixGlob(globUserID); - if (!config.noop) { - try { - await mjolnir.taskQueue.push(async () => { - return mjolnir.client.kickUser(victim, protectedRoomId, reason); - }); - } catch (e) { - await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); + for (const protectedRoomId of rooms) { + const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]); + + for (const member of members) { + const victim = member.membershipFor; + + if (kickRule.test(victim)) { + await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); + + if (!config.noop) { + try { + await mjolnir.taskQueue.push(async () => { + return mjolnir.client.kickUser(victim, protectedRoomId, reason); + }); + } catch (e) { + await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); + } + } else { + await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); } - } else { - await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); } } } + + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅'); } - - return mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/commands/PermissionCheckCommand.ts b/src/commands/PermissionCheckCommand.ts index d3459f10..9910984a 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 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. @@ -15,8 +15,14 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; +import { Command, Lexer } from "./Command"; // !mjolnir verify -export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.verifyPermissions(true, true); -} +export class PermissionCheckCommand implements Command { + public readonly command: 'verify'; + public readonly helpDescription: 'Ensures Mjolnir can moderate all your rooms'; + public readonly helpArgs: ''; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + await mjolnir.verifyPermissions(/* verbose = */ true, /* printRegardless = */ true); + } +} \ No newline at end of file diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index bfc980ed..9ad3e1c9 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 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. @@ -17,37 +17,62 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { redactUserMessagesIn } from "../utils"; import { Permalinks } from "matrix-bot-sdk"; +import { Command, Lexer, Token } from "./Command"; // !mjolnir redact [room alias] [limit] -export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const userId = parts[2]; - let roomAlias: string|null = null; - let limit = Number.parseInt(parts.length > 3 ? parts[3] : "", 10); // default to NaN for later - if (parts.length > 3 && isNaN(limit)) { - roomAlias = await mjolnir.client.resolveRoom(parts[3]); - if (parts.length > 4) { - limit = Number.parseInt(parts[4], 10); - } +export class RedactUserCommand implements Command { + public readonly command: 'redact'; + public readonly helpDescription: 'Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)'; + public readonly helpArgs: ' [room alias/ID] [limit]'; + + // This variant of `redact` accepts a user id. + accept(lexer: Lexer): boolean { + return lexer.alternatives( + () => { lexer.token(Token.USER_ID); return true; }, + () => false + ) } + async exec(mjolnir: Mjolnir, commandRoomId: string, lexer: Lexer, event: any): Promise { + const userID = lexer.token(Token.USER_ID).text; + const maybeRoomAliasOrID = lexer.alternatives( + () => lexer.token(Token.ROOM_ALIAS_OR_ID).text, + () => null + ); + const limit = lexer.alternatives( + () => lexer.token(Token.INT).value, + () => 1000 + ); + - // Make sure we always have a limit set - if (isNaN(limit)) limit = 1000; + const processingReactionId = await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], 'In Progress'); + const targetRoomIds = maybeRoomAliasOrID ? [await mjolnir.client.resolveRoom(maybeRoomAliasOrID)] : Object.keys(mjolnir.protectedRooms); - const processingReactionId = await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], 'In Progress'); + await redactUserMessagesIn(mjolnir, userID, targetRoomIds, limit); + + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅'); + await mjolnir.client.redactEvent(commandRoomId, processingReactionId, 'done processing'); + } +} - if (userId[0] !== '@') { - // Assume it's a permalink - const parsed = Permalinks.parseUrl(parts[2]); +// !mjolnir redact +export class RedactPermalinkCommand implements Command { + public readonly command: 'redact'; + public readonly helpDescription: 'Redacts a message by permalink'; + public readonly helpArgs: ''; + // This variant of `redact` accepts a permalink. + accept(lexer: Lexer): boolean { + return lexer.alternatives( + () => { lexer.token(Token.PERMALINK); return true; }, + () => false + ) + } + async exec(mjolnir: Mjolnir, roomId: string, lexer: Lexer, event: any): Promise { + const processingReactionId = await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], 'In Progress'); + const parsed = Permalinks.parseUrl(lexer.token(Token.PERMALINK).text); const targetRoomId = await mjolnir.client.resolveRoom(parsed.roomIdOrAlias); await mjolnir.client.redactEvent(targetRoomId, parsed.eventId); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing command'); return; } - - const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms); - await redactUserMessagesIn(mjolnir, userId, targetRoomIds, limit); - - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); - await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); } diff --git a/src/commands/SetDefaultBanListCommand.ts b/src/commands/SetDefaultBanListCommand.ts index f4927f1b..0e69908c 100644 --- a/src/commands/SetDefaultBanListCommand.ts +++ b/src/commands/SetDefaultBanListCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2022 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. @@ -16,21 +16,27 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { RichReply } from "matrix-bot-sdk"; +import { Command, Lexer, Token } from "./Command"; export const DEFAULT_LIST_EVENT_TYPE = "org.matrix.mjolnir.default_list"; // !mjolnir default -export async function execSetDefaultListCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const shortcode = parts[2]; - const list = mjolnir.lists.find(b => b.listShortcode === shortcode); - if (!list) { - const replyText = "No ban list with that shortcode was found."; - const reply = RichReply.createFor(roomId, event, replyText, replyText); - reply["msgtype"] = "m.notice"; - mjolnir.client.sendMessage(roomId, reply); - return; - } +export class SetDefaultListCommand implements Command { + public readonly command: 'import'; + public readonly helpDescription: 'Imports bans and ACLs into the given list'; + public readonly helpArgs: ' '; + async exec(mjolnir: Mjolnir, roomId: string, lexer: Lexer, event: any): Promise { + const shortcode = lexer.token(Token.WORD).text; + const list = mjolnir.lists.find(b => b.listShortcode === shortcode); + if (!list) { + const replyText = "No ban list with that shortcode was found."; + const reply = RichReply.createFor(roomId, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } - await mjolnir.client.setAccountData(DEFAULT_LIST_EVENT_TYPE, {shortcode}); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.client.setAccountData(DEFAULT_LIST_EVENT_TYPE, {shortcode}); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + } } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 879a42eb..3118a162 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -16,10 +16,10 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService, RichReply } from "matrix-bot-sdk"; -import { htmlEscape, parseDuration } from "../utils"; -import { ParseEntry } from "shell-quote"; +import { htmlEscape } from "../utils"; import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; import { Join } from "../RoomMembers"; +import { Lexer } from "./Command"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); @@ -37,67 +37,15 @@ type Result = {ok: T} | {error: string}; type userId = string; type Summary = { succeeded: userId[], failed: userId[] }; -/** - * Attempt to parse a `ParseEntry`, as provided by the shell-style parser, using a parsing function. - * - * @param name The name of the object being parsed. Used for error messages. - * @param token The `ParseEntry` provided by the shell-style parser. It will be converted - * to string if possible. Otherwise, this returns an error. - * @param parser A function that attempts to parse `token` (converted to string) into - * its final result. It should provide an error fit for the end-user if it fails. - * @returns An error fit for the end-user if `token` could not be converted to string or - * if `parser` failed. - */ -function parseToken(name: string, token: ParseEntry, parser: (source: string) => Result): Result { - if (!token) { - return { error: `Missing ${name}`}; - } - if (typeof token === "object") { - if ("pattern" in token) { - // In future versions, we *might* be smarter about patterns, but not yet. - token = token.pattern; - } - } - - if (typeof token !== "string") { - return { error: `Invalid ${name}` }; - } - const result = parser(token); - if ("error" in result) { - if (result.error) { - return { error: `Invalid ${name} ${htmlEscape(token)}: ${result.error}`}; - } else { - return { error: `Invalid ${name} ${htmlEscape(token)}`}; - } - } - return result; -} - -/** - * Attempt to convert a token into a string. - * @param name The name of the object being parsed. Used for error messages. - * @param token The `ParseEntry` provided by the shell-style parser. It will be converted - * to string if possible. Otherwise, this returns an error. - * @returns An error fit for the end-user if `token` could not be converted to string, otherwise - * `{ok: string}`. - */ -function getTokenAsString(name: string, token: ParseEntry): {error: string}|{ok: string} { - if (!token) { - return { error: `Missing ${name}`}; - } - if (typeof token === "object" && "pattern" in token) { - // In future versions, we *might* be smarter patterns, but not yet. - token = token.pattern; - } - if (typeof token === "string") { - return {ok: token}; - } - return { error: `Invalid ${name}` }; -} - // !mjolnir since / [...rooms] [...reason] -export async function execSinceCommand(destinationRoomId: string, event: any, mjolnir: Mjolnir, tokens: ParseEntry[]) { - let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens); +export async function execSinceCommand(destinationRoomId: string, event: any, mjolnir: Mjolnir, lexer: Lexer) { + let result; + try { + result = await execSinceCommandAux(destinationRoomId, event, mjolnir, lexer); + } catch (ex) { + result = { error: ex.message }; + console.error("Error executing `since` command", ex); + } if ("error" in result) { mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌'); mjolnir.logMessage(LogLevel.WARN, "SinceCommand", result.error); @@ -126,93 +74,71 @@ function formatResult(action: string, targetRoomId: string, recentJoins: Join[], // - resolves any room alias into a room id; // - attempts to execute action; // - in case of success, returns `{ok: undefined}`, in case of error, returns `{error: string}`. -async function execSinceCommandAux(destinationRoomId: string, event: any, mjolnir: Mjolnir, tokens: ParseEntry[]): Promise> { - const [dateOrDurationToken, actionToken, maxEntriesToken, ...optionalTokens] = tokens; - - // Parse origin date or duration. - const minDateResult = parseToken("/", dateOrDurationToken, source => { - // Attempt to parse `/` as a date. - let maybeMinDate = new Date(source); - let maybeMaxAgeMS = Date.now() - maybeMinDate.getTime() as number; - if (!Number.isNaN(maybeMaxAgeMS)) { - return { ok: { minDate: maybeMinDate, maxAgeMS: maybeMaxAgeMS} }; - } - - //...or as a duration - maybeMaxAgeMS = parseDuration(source); - if (maybeMaxAgeMS && !Number.isNaN(maybeMaxAgeMS)) { - maybeMaxAgeMS = Math.abs(maybeMaxAgeMS); - return { ok: { minDate: new Date(Date.now() - maybeMaxAgeMS), maxAgeMS: maybeMaxAgeMS } } - } - return { error: "" }; - }); - if ("error" in minDateResult) { - return minDateResult; - } - const { minDate, maxAgeMS } = minDateResult.ok!; - - // Parse max entries. - const maxEntriesResult = parseToken("", maxEntriesToken, source => { - const maybeMaxEntries = Number.parseInt(source, 10); - if (Number.isNaN(maybeMaxEntries)) { - return { error: "Not a number" }; - } else { - return { ok: maybeMaxEntries }; - } - }); - if ("error" in maxEntriesResult) { - return maxEntriesResult; +async function execSinceCommandAux(destinationRoomId: string, event: any, mjolnir: Mjolnir, lexer: Lexer): Promise> { + // Attempt to parse `` as a date or duration. + let dateOrDuration: Date |number = lexer.token("dateOrDuration").value; + let minDate; + let maxAgeMS; + if (dateOrDuration instanceof Date) { + minDate = dateOrDuration; + maxAgeMS = Date.now() - dateOrDuration.getTime() as number; + } else { + minDate = new Date(Date.now() - dateOrDuration); + maxAgeMS = dateOrDuration; } - const maxEntries = maxEntriesResult.ok!; // Attempt to parse `` as Action. - const actionResult = parseToken("", actionToken, source => { - for (let key in Action) { - const maybeAction = Action[key as keyof typeof Action]; - if (key === source) { - return { ok: maybeAction } - } else if (maybeAction === source) { - return { ok: maybeAction } - } + let actionToken = lexer.token("id").text; + let action: Action | null = null; + for (let key in Action) { + const maybeAction = Action[key as keyof typeof Action]; + if (key === actionToken || maybeAction === actionToken) { + action = maybeAction; + break; } - return {error: `Expected one of ${JSON.stringify(Action)}`}; - }) - if ("error" in actionResult) { - return actionResult; } - const action: Action = actionResult.ok!; + if (!action) { + return {error: `Invalid . Expected one of ${JSON.stringify(Action)}`}; + } - // Now list affected rooms. + // Attempt to parse `` as a number. + const maxEntries = lexer.token("int").value as number; + + // Parse rooms. + // Parse everything else as ``, stripping quotes if any have been added. const rooms: Set = new Set(); - let reasonParts: string[] | undefined; - for (let token of optionalTokens) { - const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token); - if ("error" in maybeArg) { - return maybeArg; - } - const maybeRoom = maybeArg.ok; - if (!reasonParts) { - // If we haven't reached the reason yet, attempt to use `maybeRoom` as a room. - if (maybeRoom === "*") { - for (let roomId of Object.keys(mjolnir.protectedRooms)) { - rooms.add(roomId); - } - continue; - } else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) { - const roomId = await mjolnir.client.resolveRoom(maybeRoom); - if (!(roomId in mjolnir.protectedRooms)) { - return mjolnir.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); - } + let reason = ""; + do { + + let token = lexer.alternatives( + // Room + () => lexer.token("STAR"), + () => lexer.token("roomAliasOrID"), + // Reason + () => lexer.token("string"), + () => lexer.token("ETC") + ); + if (!token || token.type === "EOF") { + // We have reached the end of rooms, no reason. + break; + } else if (token.type === "STAR") { + for (let roomId of Object.keys(mjolnir.protectedRooms)) { rooms.add(roomId); - continue; } - // If we reach this step, it's not a room, so it must be a reason. - // All further arguments are now part of `reason`. - reasonParts = []; + continue; + } else if (token.type === "roomAliasOrID") { + const roomId = await mjolnir.client.resolveRoom(token.text); + if (!(roomId in mjolnir.protectedRooms)) { + return mjolnir.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); + } + rooms.add(roomId); + continue; + } else if (token.type === "string" || token.type === "ETC") { + // We have reached the end of rooms with a reason. + reason = token.text; + break; } - reasonParts.push(maybeRoom); - } - + } while(true); if (rooms.size === 0) { return { error: "Missing rooms. Use `*` if you wish to apply to every protected room.", @@ -220,7 +146,6 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni } const progressEventId = await mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '⏳'); - const reason: string | undefined = reasonParts?.join(" "); for (let targetRoomId of rooms) { let {html, text} = await (async () => { diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index 987c1331..f4e0aa0c 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -18,22 +18,30 @@ import { Mjolnir, STATE_CHECKING_PERMISSIONS, STATE_NOT_STARTED, STATE_RUNNING, import { RichReply } from "matrix-bot-sdk"; import { htmlEscape, parseDuration } from "../utils"; import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; +import { AbstractLegacyCommand } from "./Command"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); -// !mjolnir -export async function execStatusCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - switch (parts[0]) { - case undefined: - case 'mjolnir': - return showMjolnirStatus(roomId, event, mjolnir); - case 'joins': - return showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 1)); - case 'protection': - return showProtectionStatus(roomId, event, mjolnir, parts.slice(/* ["protection"] */ 1)); - default: - throw new Error(`Invalid status command: ${htmlEscape(parts[0])}`); +export class StatusCommand extends AbstractLegacyCommand { + public readonly command: 'status'; + public readonly helpDescription: 'Print status information'; + public readonly helpArgs: '[mjolnir / joins / protection ]'; + async legacyExec(roomID: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { + parts = parts.slice(2 /* "mjolnir", "status" */) + switch (parts[0]) { + case undefined: + case 'mjolnir': + await showMjolnirStatus(roomID, event, mjolnir); + return; + case 'joins': + await showJoinsStatus(roomID, event, mjolnir, parts.slice(/* ["joins"] */ 1)); + return; + case 'protection': + return showProtectionStatus(roomID, event, mjolnir, parts.slice(/* ["protection"] */ 1)); + default: + throw new Error(`Invalid status command: ${htmlEscape(parts[0])}`); + } } } diff --git a/src/commands/SyncCommand.ts b/src/commands/SyncCommand.ts index 0ebb1743..d83eb44b 100644 --- a/src/commands/SyncCommand.ts +++ b/src/commands/SyncCommand.ts @@ -15,8 +15,16 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; +import { Command, Lexer } from "./Command"; // !mjolnir sync -export async function execSyncCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.syncLists(); +export class SyncCommand implements Command { + public readonly command: 'sync'; + public readonly helpDescription: 'Force updates of all lists and re-apply rules'; + public readonly helpArgs: ''; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + await mjolnir.syncLists(); + } } + + diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index cfac205e..8f7403b2 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -20,6 +20,7 @@ import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule"; import config from "../config"; import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand"; +import { AbstractLegacyCommand } from "./Command"; interface Arguments { list: BanList | null; @@ -115,7 +116,17 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni } // !mjolnir ban [reason] [--force] -export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { +export class BanCommand extends AbstractLegacyCommand { + command: "ban"; + helpArgs: " [reason]"; + helpDescription: "Adds an entity to the ban list"; + async legacyExec(roomID: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { + await execBanCommand(roomID, event, mjolnir, parts); + } +} + +// !mjolnir ban [reason] [--force] +async function execBanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const bits = await parseArguments(roomId, event, mjolnir, parts); if (!bits) return; // error already handled @@ -131,8 +142,18 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } +// !mjolnir ban [reason] [--force] +export class UnbanCommand extends AbstractLegacyCommand { + command: "unban"; + helpDescription: "Removes an entity from the ban list. If apply is 'true', the users matching the glob will actually be unbanned\n"; + helpArgs: " [apply]"; + async legacyExec(roomID: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { + await execUnbanCommand(roomID, event, mjolnir, parts); + } +} + // !mjolnir unban [apply:t/f] -export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { +async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const bits = await parseArguments(roomId, event, mjolnir, parts); if (!bits) return; // error already handled diff --git a/src/commands/WatchUnwatchCommand.ts b/src/commands/WatchUnwatchCommand.ts index d0c695d6..6c3ae9e6 100644 --- a/src/commands/WatchUnwatchCommand.ts +++ b/src/commands/WatchUnwatchCommand.ts @@ -16,29 +16,42 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { Permalinks, RichReply } from "matrix-bot-sdk"; +import { Command, Lexer, Token } from "./Command"; // !mjolnir watch -export async function execWatchCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const list = await mjolnir.watchList(Permalinks.forRoom(parts[2])); - if (!list) { - const replyText = "Cannot watch list due to error - is that a valid room alias?"; - const reply = RichReply.createFor(roomId, event, replyText, replyText); - reply["msgtype"] = "m.notice"; - mjolnir.client.sendMessage(roomId, reply); - return; +export class WatchCommand implements Command { + public readonly command: 'watch'; + public readonly helpDescription: 'Watches a ban list'; + public readonly helpArgs: ''; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + const roomAliasOrID = lexer.token(Token.ROOM_ALIAS_OR_ID).text; + const list = await mjolnir.watchList(Permalinks.forRoom(roomAliasOrID)); + if (!list) { + const replyText = "Cannot watch list due to error - is that a valid room alias?"; + const reply = RichReply.createFor(roomID, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomID, reply); + return; + } + await mjolnir.client.unstableApis.addReactionToEvent(roomID, event['event_id'], '✅'); } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } // !mjolnir unwatch -export async function execUnwatchCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const list = await mjolnir.unwatchList(Permalinks.forRoom(parts[2])); - if (!list) { - const replyText = "Cannot unwatch list due to error - is that a valid room alias?"; - const reply = RichReply.createFor(roomId, event, replyText, replyText); - reply["msgtype"] = "m.notice"; - mjolnir.client.sendMessage(roomId, reply); - return; +export class UnwatchCommand implements Command { + public readonly command: 'unwatch'; + public readonly helpDescription: 'Unwatches a ban list'; + public readonly helpArgs: ''; + async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise { + const roomAliasOrID = lexer.token(Token.ROOM_ALIAS_OR_ID).text; + const list = await mjolnir.unwatchList(Permalinks.forRoom(roomAliasOrID)); + if (!list) { + const replyText = "Cannot unwatch list due to error - is that a valid room alias?"; + const reply = RichReply.createFor(roomID, event, replyText, replyText); + reply["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomID, reply); + return; + } + await mjolnir.client.unstableApis.addReactionToEvent(roomID, event['event_id'], '✅'); } - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/utils.ts b/src/utils.ts index d30609f5..12272a1e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -434,3 +434,4 @@ export function patchMatrixClient() { patchMatrixClientForConciseExceptions(); patchMatrixClientForRetry(); } + diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index a81d6f6d..0c1a7cc3 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -12,7 +12,7 @@ export const mochaHooks = { console.error("---- entering test", JSON.stringify(this.currentTest.title)); // Makes MatrixClient error logs a bit easier to parse. console.log("mochaHooks.beforeEach"); // Sometimes it takes a little longer to register users. - this.timeout(10000) + this.timeout(20000) this.managementRoomAlias = config.managementRoom; this.mjolnir = await makeMjolnir(); config.RUNTIME.client = this.mjolnir.client; diff --git a/yarn.lock b/yarn.lock index 21df7154..1ca08ae4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2561,11 +2561,6 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@^1.7.3: - version "1.7.3" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" - integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== - sigmund@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" @@ -2733,6 +2728,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +tokenizr@^1.6.7: + version "1.6.7" + resolved "https://registry.yarnpkg.com/tokenizr/-/tokenizr-1.6.7.tgz#3ff4f046405192bcf5fe76f438a2538934d0a840" + integrity sha512-WWB9hGxE/PNjX8EyF1Lcu+IgljTY58d/3DPhWGzJxXTKBWtCY8voxvr0OzG3nc/WRubhXwlSx66/JhTypuG4Eg== + tough-cookie@^2.3.3, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"