From 0cc6862e118c4d6ffc57683003b9d49d12f70fa5 Mon Sep 17 00:00:00 2001 From: David Teller Date: Fri, 22 Jul 2022 11:33:49 +0200 Subject: [PATCH] WIP --- src/commands/Command.ts | 330 +++++++++++++++++++++++++++++++++ src/commands/CommandHandler.ts | 3 +- src/commands/KickCommand.ts | 109 ++++++----- src/commands/Lexer.ts | 110 ----------- src/commands/SinceCommand.ts | 2 +- 5 files changed, 395 insertions(+), 159 deletions(-) create mode 100644 src/commands/Command.ts delete mode 100644 src/commands/Lexer.ts diff --git a/src/commands/Command.ts b/src/commands/Command.ts new file mode 100644 index 0000000..ab76dc8 --- /dev/null +++ b/src/commands/Command.ts @@ -0,0 +1,330 @@ +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 { COMMAND_PREFIX } from './CommandHandler'; +import config from '../config'; +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 = /.*$/; + +/** + * 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("command", COMMAND, (ctx) => { + ctx.accept("command"); + }); + + // Users + this.rule("userID", USER_ID, (ctx) => { + ctx.accept("userID"); + }); + this.rule("globUserID", GLOB_USER_ID, (ctx) => { + ctx.accept("globUserID"); + }); + + // Rooms + this.rule("roomID", ROOM_ID, (ctx) => { + ctx.accept("roomID"); + }); + this.rule("roomAlias", ROOM_ALIAS, (ctx) => { + ctx.accept("roomAlias"); + }); + this.rule("roomAliasOrID", ROOM_ALIAS_OR_ID, (ctx) => { + ctx.accept("roomAliasOrID"); + }); + + // Numbers. + this.rule("int", INT, (ctx, match) => { + ctx.accept("int", parseInt(match[0])) + }); + + // Quoted strings. + this.rule("string", STRING, (ctx, match) => { + ctx.accept("string", match[1].replace(/\\"/g, "\"")) + }); + + // Dates and durations. + this.rule("dateOrDuration", 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("duration", duration); + } + } else { + ctx.accept("date", date); + } + }); + + // Jokers. + this.rule("STAR", STAR, (ctx) => { + ctx.accept("STAR"); + }); + + // Everything left in the string. + this.rule("ETC", ETC, (ctx, match) => { + ctx.accept("ETC", match[0].trim()); + }); + + this.input(string); + } + + public token(state?: string): TokenizrModule.Token { + if (typeof state === "string") { + 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; + + /** + * 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 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( + /** + * A list of command-prefixes to answer to, e.g. `mjolnir`. + */ + public readonly prefixes: string[], + private readonly managementRoomId: string, + private readonly mjolnir: Mjolnir + ) { + this.onMessageCallback = this.handleMessage.bind(this); + // Prepare prefixes. + + // Prepare help message. + const commands = this.commands; + 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; + + // Compute width to display the help properly. + for (let command of allCommands) { + let prefix = `${this.c} ${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() { + // Initialize the list of prefixes to which the bot will respond. + // We perform lowercase-comparison, + const userId = await (await this.mjolnir.client.getUserId()).toLowerCase(); + const profile = await this.mjolnir.client.getUserProfile(userId); + const localpart = userId.split(':')[0].substring(1); + this.PREFIXES = [ + COMMAND_PREFIX.toLowerCase(), + localpart + ":", + localpart + " ", + ]; + + const displayName = profile['displayName']?.toLowerCase(); + if (displayName) { + this.PREFIXES.push(displayName + ":"); + this.PREFIXES.push(displayName + " "); + } + + for (let additionalPrefix of config.commands.additionalPrefixes || []) { + const lowercase = additionalPrefix.toLowerCase(); + for (let prefix of [ + `!${lowercase}`, + `${lowercase}:`, + `!${lowercase} ` + ]) { + this.PREFIXES.push(prefix); + } + } + if (config.commands.allowNoPrefix) { + this.PREFIXES.push("!"); + } + + // Initialize listening to messages. + 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. As some commands contain spaces, we cannot + // simply use the lexer and a lookup in a map. + let cmd = line.length === 0 ? + this.defaultCommand + : this.commands.find(cmd => line.startsWith(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) { + 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); + } + } +} \ No newline at end of file diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 478fd08..4c87c93 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -38,10 +38,9 @@ import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } fro import { execSetPowerLevelCommand } from "./SetPowerLevelCommand"; import { execShutdownRoomCommand } from "./ShutdownRoomCommand"; import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands"; -import { execKickCommand } from "./KickCommand"; import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { execSinceCommand } from "./SinceCommand"; -import { Lexer } from "./Lexer"; +import { Lexer } from "./Command"; export const COMMAND_PREFIX = "!mjolnir"; diff --git a/src/commands/KickCommand.ts b/src/commands/KickCommand.ts index f841ef4..3e20bfe 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/Lexer.ts b/src/commands/Lexer.ts deleted file mode 100644 index 1e30a50..0000000 --- a/src/commands/Lexer.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 { parseDuration } from "../utils"; -const TokenizrClass = Tokenizr || TokenizrModule; - -const WHITESPACE = /\s+/; -const COMMAND = /![a-zA-Z_]+/; -const IDENTIFIER = /[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 = /.*$/; - -/** - * 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() - }) - - // Command rules, e.g. `!mjolnir` - this.rule("command", COMMAND, (ctx) => { - ctx.accept("command"); - }); - - // Identifier rules, used e.g. for subcommands `get`, `set` ... - this.rule("id", IDENTIFIER, (ctx) => { - ctx.accept("id"); - }); - - // Users - this.rule("userID", USER_ID, (ctx) => { - ctx.accept("userID"); - }); - this.rule("globUserID", GLOB_USER_ID, (ctx) => { - ctx.accept("globUserID"); - }); - - // Rooms - this.rule("roomID", ROOM_ID, (ctx) => { - ctx.accept("roomID"); - }); - this.rule("roomAlias", ROOM_ALIAS, (ctx) => { - ctx.accept("roomAlias"); - }); - this.rule("roomAliasOrID", ROOM_ALIAS_OR_ID, (ctx) => { - ctx.accept("roomAliasOrID"); - }); - - // Numbers. - this.rule("int", INT, (ctx, match) => { - ctx.accept("int", parseInt(match[0])) - }); - - // Quoted strings. - this.rule("string", STRING, (ctx, match) => { - ctx.accept("string", match[1].replace(/\\"/g, "\"")) - }); - - // Dates and durations. - this.rule("dateOrDuration", 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("duration", duration); - } - } else { - ctx.accept("date", date); - } - }); - - // Jokers. - this.rule("STAR", STAR, (ctx) => { - ctx.accept("STAR"); - }); - - // Everything left in the string. - this.rule("ETC", ETC, (ctx, match) => { - ctx.accept("ETC", match[0].trim()); - }); - - this.input(string); - } - - public token(state?: string): TokenizrModule.Token { - if (typeof state === "string") { - this.state(state); - } - return super.token(); - } -} diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 24f42b4..3118a16 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -19,7 +19,7 @@ import { LogLevel, LogService, RichReply } from "matrix-bot-sdk"; import { htmlEscape } from "../utils"; import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; import { Join } from "../RoomMembers"; -import { Lexer } from "./Lexer"; +import { Lexer } from "./Command"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE);