From 5395a660718e34d678349ee511656113e665e169 Mon Sep 17 00:00:00 2001 From: David Teller Date: Tue, 19 Jul 2022 14:28:16 +0200 Subject: [PATCH 1/7] Reworking CLI tokenization --- package.json | 3 +- src/commands/CommandHandler.ts | 85 ++++++++------- src/commands/Lexer.ts | 85 +++++++++++++++ src/commands/SinceCommand.ts | 190 +++++++++++---------------------- src/utils.ts | 1 + yarn.lock | 10 +- 6 files changed, 196 insertions(+), 178 deletions(-) create mode 100644 src/commands/Lexer.ts 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/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 4d967804..73de0a8b 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -38,90 +38,89 @@ 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 { Lexer } from "./Lexer"; 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); - - // 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 line = event['content']['body']; + const parts = line.trim().split(' ').filter(p => p.trim().length > 0); + const lexer = new Lexer(line); + lexer.consume("command"); // Consume `!mjolnir`. + let cmd = parts.length === 1 ? null : lexer.consume("id").text; try { - if (parts.length === 1 || parts[1] === 'status') { + if (parts.length === 1 || cmd === 'status') { return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); - } else if (parts[1] === 'ban' && parts.length > 2) { + } else if (cmd === 'ban' && parts.length > 2) { return await execBanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unban' && parts.length > 2) { + } else if (cmd === 'unban' && parts.length > 2) { return await execUnbanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') { + } else if (cmd === 'rules' && parts.length === 4 && parts[2] === 'matching') { return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) - } else if (parts[1] === 'rules') { + } else if (cmd === 'rules') { return await execDumpRulesCommand(roomId, event, mjolnir); - } else if (parts[1] === 'sync') { + } else if (cmd === 'sync') { return await execSyncCommand(roomId, event, mjolnir); - } else if (parts[1] === 'verify') { + } else if (cmd === 'verify') { return await execPermissionCheckCommand(roomId, event, mjolnir); - } else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') { + } else if (parts.length >= 5 && cmd === 'list' && parts[2] === 'create') { return await execCreateListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'watch' && parts.length > 1) { + } else if (cmd === 'watch' && parts.length > 1) { return await execWatchCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unwatch' && parts.length > 1) { + } else if (cmd === 'unwatch' && parts.length > 1) { return await execUnwatchCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'redact' && parts.length > 1) { + } else if (cmd === 'redact' && parts.length > 1) { return await execRedactCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'import' && parts.length > 2) { + } else if (cmd === 'import' && parts.length > 2) { return await execImportCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'default' && parts.length > 2) { + } else if (cmd === 'default' && parts.length > 2) { return await execSetDefaultListCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'deactivate' && parts.length > 2) { + } else 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) { + } else if (cmd === 'since') { + return await execSinceCommand(roomId, event, mjolnir, lexer); + } else if (cmd === '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 === 'make' && parts[2] === 'admin' && parts.length > 3) { return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); } else { // Help menu diff --git a/src/commands/Lexer.ts b/src/commands/Lexer.ts new file mode 100644 index 00000000..f26904c6 --- /dev/null +++ b/src/commands/Lexer.ts @@ -0,0 +1,85 @@ +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; + +/** + * A lexer for common cases. + */ +export class Lexer extends TokenizrClass { + constructor(string: string) { + super(); + + // Ignore whitespace. + this.rule(/\s+/, (ctx) => { + ctx.ignore() + }) + + // Identifier rules, used e.g. for subcommands `get`, `set` ... + this.rule(/[a-zA-Z_]+/, (ctx) => { + ctx.accept("id"); + }); + + // User IDs + this.rule(/@[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + ctx.accept("userID"); + }); + this.rule(/@[a-zA-Z0-9_.=\-?*/]+:.+/, (ctx) => { + ctx.accept("globUserID"); + }); + + // User IDs + this.rule(/![a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + ctx.accept("roomID"); + }); + this.rule(/#[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + ctx.accept("roomAlias"); + }); + this.rule(/[#!][a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + ctx.accept("roomAliasOrID"); + }); + + // Numbers. + this.rule(/[+-]?[0-9]+/, (ctx, match) => { + ctx.accept("int", parseInt(match[0])) + }); + + // Quoted strings. + this.rule(/"((?:\\"|[^\r\n])*)"/, (ctx, match) => { + ctx.accept("string", match[1].replace(/\\"/g, "\"")) + }); + + // Arbitrary non-space content. + this.rule(/\S+/, (ctx) => { + ctx.accept("nospace"); + }); + + // Dates and durations. + this.rule(/\S+/, (ctx, match) => { + let date = new Date(match[0]); + if (!date || Number.isNaN(date.getDate())) { + let duration = parseDuration(match[0]); + if (!duration || Number.isNaN(duration)) { + ctx.reject(); + } else { + ctx.accept("duration", duration); + } + } else { + ctx.accept("date", date); + } + }); + + // Jokers. + this.rule(/\*/, (ctx) => { + ctx.accept("STAR"); + }); + this.rule(/.*/, ctx => { + ctx.accept("EVERYTHING ELSE"); + }); + + this.input(string); + } +} diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 879a42eb..f0bfafdf 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 "./Lexer"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); @@ -37,67 +37,9 @@ 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 = await execSinceCommandAux(destinationRoomId, event, mjolnir, lexer); if ("error" in result) { mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌'); mjolnir.logMessage(LogLevel.WARN, "SinceCommand", result.error); @@ -126,101 +68,91 @@ 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; +async function execSinceCommandAux(destinationRoomId: string, event: any, mjolnir: Mjolnir, lexer: Lexer): Promise> { + console.debug("YORIC", "lexer", lexer); - // 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; + // Attempt to parse `` as a date or duration. + let dateOrDurationToken: Date | number; + try { + dateOrDurationToken = lexer.alternatives( + () => lexer.consume("date"), + () => lexer.consume("duration") + ); + } catch (ex) { + return { error: "Invalid " }; } - 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; + console.debug("YORIC", "dateOrDurationToken", dateOrDurationToken); + let minDate; + let maxAgeMS; + if (dateOrDurationToken instanceof Date) { + minDate = dateOrDurationToken; + maxAgeMS = Date.now() - dateOrDurationToken.getTime() as number; + } else { + minDate = new Date(Date.now() - dateOrDurationToken); + maxAgeMS = dateOrDurationToken; } - 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.consume("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)}`}; + } + console.debug("YORIC", "action", action); + + // Attempt to parse `` as a number. + const maxEntries = lexer.consume("int").value as number; + console.debug("YORIC", "maxEntries", maxEntries); // Now list affected rooms. 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 === "*") { + do { + try { + let token = lexer.alternatives( + () => lexer.consume("STAR"), + () => lexer.consume("roomAliasOrID"), + ); + if (token.type == "STAR") { 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 (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; } - // 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 = []; + } catch (ex) { + // If we're done with rooms, we have entered . + break; } - reasonParts.push(maybeRoom); - } - + } while(true); if (rooms.size === 0) { return { error: "Missing rooms. Use `*` if you wish to apply to every protected room.", }; } + console.debug("YORIC", "rooms", rooms); + + // Parse everything else as ``, stripping quotes if any have been added. + const reason = lexer.alternatives( + () => lexer.consume("string"), + () => lexer.consume("EVERYTHING ELSE") + ).text; + console.debug("YORIC", "reason", reason); 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/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/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" From 7b094f3e2871170ec422502ce8bc37ca36275509 Mon Sep 17 00:00:00 2001 From: David Teller Date: Tue, 19 Jul 2022 15:36:30 +0200 Subject: [PATCH 2/7] WIP --- src/commands/CommandHandler.ts | 190 ++++++++++++++++++++++++++++++++- src/commands/Lexer.ts | 47 ++++---- src/commands/SinceCommand.ts | 19 ++-- 3 files changed, 220 insertions(+), 36 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 73de0a8b..bef687bb 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -41,18 +41,200 @@ import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { execSinceCommand } from "./SinceCommand"; import { Lexer } from "./Lexer"; - export const COMMAND_PREFIX = "!mjolnir"; +type Command = { + cmd: string, + help: string, + code: (roomId: string, event: {content: {body: string}}, mjolnir: Mjolnir, parts: string[], lexer: Lexer) => Promise, +}; +const COMMANDS: Command[] = [{ + cmd: '', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execStatusCommand(roomId, event, mjolnir, parts.slice(2)) +}, + { + cmd: 'ban', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execBanCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'unban', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execUnbanCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'rules', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execRulesMatchingCommand(roomId, event, mjolnir, parts[3]), +}, +{ + cmd: 'rules', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execDumpRulesCommand(roomId, event, mjolnir), +}, + { + cmd: 'sync', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execSyncCommand(roomId, event, mjolnir), +}, + { + cmd: 'verify', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execPermissionCheckCommand(roomId, event, mjolnir), +}, + { + cmd: 'list', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execCreateListCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'watch', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execWatchCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'unwatch', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execUnwatchCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'redact', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execRedactCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'import', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execImportCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'default', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execSetDefaultListCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'deactivate', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execDeactivateCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'protections', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execListProtections(roomId, event, mjolnir, parts), +}, + { + cmd: 'enable', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execEnableProtection(roomId, event, mjolnir, parts), +}, + { + cmd: 'disable', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execDisableProtection(roomId, event, mjolnir, parts), +}, + { + cmd: 'config', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execConfigSetProtection(roomId, event, mjolnir, parts.slice(3)), +}, + { + cmd: 'config', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execConfigAddProtection(roomId, event, mjolnir, parts.slice(3)), +}, + { + cmd: 'config', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3)), +}, + { + cmd: 'config', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execConfigGetProtection(roomId, event, mjolnir, parts.slice(3)), +}, + { + cmd: 'rooms', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execAddProtectedRoom(roomId, event, mjolnir, parts), +}, + { + cmd: 'rooms', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execRemoveProtectedRoom(roomId, event, mjolnir, parts), +}, + { + cmd: 'rooms', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execListProtectedRooms(roomId, event, mjolnir), +}, + { + cmd: 'move', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execMoveAliasCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'directory', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execAddRoomToDirectoryCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'directory', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execRemoveRoomFromDirectoryCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'alias', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execAddAliasCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'alias', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execRemoveAliasCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'resolve', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execResolveCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'powerlevel', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execSetPowerLevelCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'shutdown', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execShutdownRoomCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'since', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execSinceCommand(roomId, event, mjolnir, lexer), +}, + { + cmd: 'kick', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execKickCommand(roomId, event, mjolnir, parts), +}, + { + cmd: 'make', + help: "FIXME", + code: (roomId, event, mjolnir, parts, lexer) => execMakeRoomAdminCommand(roomId, event, mjolnir, parts), +}]; + export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { const line = event['content']['body']; const parts = line.trim().split(' ').filter(p => p.trim().length > 0); + console.debug("YORIC", "line", line); const lexer = new Lexer(line); - lexer.consume("command"); // Consume `!mjolnir`. - let cmd = parts.length === 1 ? null : lexer.consume("id").text; + lexer.token("command"); // Consume `!mjolnir`. + const cmd = lexer.token("id").text; + console.debug("YORIC", "cmd", cmd); try { - if (parts.length === 1 || cmd === 'status') { + if (cmd === '' || cmd === 'status') { return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); } else if (cmd === 'ban' && parts.length > 2) { return await execBanCommand(roomId, event, mjolnir, parts); diff --git a/src/commands/Lexer.ts b/src/commands/Lexer.ts index f26904c6..8057b95f 100644 --- a/src/commands/Lexer.ts +++ b/src/commands/Lexer.ts @@ -18,47 +18,47 @@ export class Lexer extends TokenizrClass { ctx.ignore() }) + // Command rules, e.g. `!mjolnir` + this.rule("command", /![a-zA-Z_]+/, (ctx) => { + ctx.accept("command"); + }); + // Identifier rules, used e.g. for subcommands `get`, `set` ... - this.rule(/[a-zA-Z_]+/, (ctx) => { + this.rule("id", /[a-zA-Z_]+/, (ctx) => { ctx.accept("id"); }); - // User IDs - this.rule(/@[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + // Users + this.rule("userID", /@[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { ctx.accept("userID"); }); - this.rule(/@[a-zA-Z0-9_.=\-?*/]+:.+/, (ctx) => { + this.rule("globUserID", /@[a-zA-Z0-9_.=\-?*/]+:.+/, (ctx) => { ctx.accept("globUserID"); }); - // User IDs - this.rule(/![a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + // Rooms + this.rule("roomID", /![a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { ctx.accept("roomID"); }); - this.rule(/#[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + this.rule("roomAlias", /#[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { ctx.accept("roomAlias"); }); - this.rule(/[#!][a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + this.rule("roomAliasOrID", /[#!][a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { ctx.accept("roomAliasOrID"); }); - + // Numbers. - this.rule(/[+-]?[0-9]+/, (ctx, match) => { + this.rule("int", /[+-]?[0-9]+/, (ctx, match) => { ctx.accept("int", parseInt(match[0])) }); // Quoted strings. - this.rule(/"((?:\\"|[^\r\n])*)"/, (ctx, match) => { + this.rule("string", /"((?:\\"|[^\r\n])*)"/, (ctx, match) => { ctx.accept("string", match[1].replace(/\\"/g, "\"")) }); - // Arbitrary non-space content. - this.rule(/\S+/, (ctx) => { - ctx.accept("nospace"); - }); - // Dates and durations. - this.rule(/\S+/, (ctx, match) => { + this.rule("dateOrDuration", /\S+/, (ctx, match) => { let date = new Date(match[0]); if (!date || Number.isNaN(date.getDate())) { let duration = parseDuration(match[0]); @@ -73,13 +73,20 @@ export class Lexer extends TokenizrClass { }); // Jokers. - this.rule(/\*/, (ctx) => { + this.rule("STAR", /\*/, (ctx) => { ctx.accept("STAR"); }); - this.rule(/.*/, ctx => { - ctx.accept("EVERYTHING ELSE"); + this.rule(/./, (ctx) => { + ctx.accept("ANYTHING ELSE") }); 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 f0bfafdf..38708557 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -69,15 +69,10 @@ function formatResult(action: string, targetRoomId: string, recentJoins: Join[], // - 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, lexer: Lexer): Promise> { - console.debug("YORIC", "lexer", lexer); - // Attempt to parse `` as a date or duration. let dateOrDurationToken: Date | number; try { - dateOrDurationToken = lexer.alternatives( - () => lexer.consume("date"), - () => lexer.consume("duration") - ); + dateOrDurationToken = lexer.token("dateOrDuration").value; } catch (ex) { return { error: "Invalid " }; } @@ -93,7 +88,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni } // Attempt to parse `` as Action. - let actionToken = lexer.consume("id").text; + let actionToken = lexer.token("id").text; let action: Action | null = null; for (let key in Action) { const maybeAction = Action[key as keyof typeof Action]; @@ -108,7 +103,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni console.debug("YORIC", "action", action); // Attempt to parse `` as a number. - const maxEntries = lexer.consume("int").value as number; + const maxEntries = lexer.token("int").value as number; console.debug("YORIC", "maxEntries", maxEntries); // Now list affected rooms. @@ -116,8 +111,8 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni do { try { let token = lexer.alternatives( - () => lexer.consume("STAR"), - () => lexer.consume("roomAliasOrID"), + () => lexer.token("STAR"), + () => lexer.token("roomAliasOrID"), ); if (token.type == "STAR") { for (let roomId of Object.keys(mjolnir.protectedRooms)) { @@ -147,8 +142,8 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni // Parse everything else as ``, stripping quotes if any have been added. const reason = lexer.alternatives( - () => lexer.consume("string"), - () => lexer.consume("EVERYTHING ELSE") + () => lexer.token("string"), + () => lexer.token("EVERYTHING ELSE") ).text; console.debug("YORIC", "reason", reason); From fb54799463e28c18f209c41acfa563830f3187b0 Mon Sep 17 00:00:00 2001 From: David Teller Date: Tue, 19 Jul 2022 17:35:08 +0200 Subject: [PATCH 3/7] WIP --- src/commands/CommandHandler.ts | 194 ++------------------------------- src/commands/Lexer.ts | 37 ++++--- src/commands/SinceCommand.ts | 71 ++++++------ 3 files changed, 70 insertions(+), 232 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index bef687bb..4ea42a61 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -28,8 +28,10 @@ import { execRedactCommand } from "./RedactCommand"; import { execImportCommand } from "./ImportCommand"; import { execSetDefaultListCommand } 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"; @@ -43,198 +45,20 @@ import { Lexer } from "./Lexer"; export const COMMAND_PREFIX = "!mjolnir"; -type Command = { - cmd: string, - help: string, - code: (roomId: string, event: {content: {body: string}}, mjolnir: Mjolnir, parts: string[], lexer: Lexer) => Promise, -}; -const COMMANDS: Command[] = [{ - cmd: '', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execStatusCommand(roomId, event, mjolnir, parts.slice(2)) -}, - { - cmd: 'ban', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execBanCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'unban', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execUnbanCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'rules', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execRulesMatchingCommand(roomId, event, mjolnir, parts[3]), -}, -{ - cmd: 'rules', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execDumpRulesCommand(roomId, event, mjolnir), -}, - { - cmd: 'sync', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execSyncCommand(roomId, event, mjolnir), -}, - { - cmd: 'verify', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execPermissionCheckCommand(roomId, event, mjolnir), -}, - { - cmd: 'list', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execCreateListCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'watch', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execWatchCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'unwatch', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execUnwatchCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'redact', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execRedactCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'import', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execImportCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'default', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execSetDefaultListCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'deactivate', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execDeactivateCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'protections', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execListProtections(roomId, event, mjolnir, parts), -}, - { - cmd: 'enable', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execEnableProtection(roomId, event, mjolnir, parts), -}, - { - cmd: 'disable', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execDisableProtection(roomId, event, mjolnir, parts), -}, - { - cmd: 'config', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execConfigSetProtection(roomId, event, mjolnir, parts.slice(3)), -}, - { - cmd: 'config', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execConfigAddProtection(roomId, event, mjolnir, parts.slice(3)), -}, - { - cmd: 'config', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3)), -}, - { - cmd: 'config', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execConfigGetProtection(roomId, event, mjolnir, parts.slice(3)), -}, - { - cmd: 'rooms', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execAddProtectedRoom(roomId, event, mjolnir, parts), -}, - { - cmd: 'rooms', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execRemoveProtectedRoom(roomId, event, mjolnir, parts), -}, - { - cmd: 'rooms', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execListProtectedRooms(roomId, event, mjolnir), -}, - { - cmd: 'move', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execMoveAliasCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'directory', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execAddRoomToDirectoryCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'directory', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execRemoveRoomFromDirectoryCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'alias', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execAddAliasCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'alias', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execRemoveAliasCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'resolve', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execResolveCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'powerlevel', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execSetPowerLevelCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'shutdown', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execShutdownRoomCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'since', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execSinceCommand(roomId, event, mjolnir, lexer), -}, - { - cmd: 'kick', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execKickCommand(roomId, event, mjolnir, parts), -}, - { - cmd: 'make', - help: "FIXME", - code: (roomId, event, mjolnir, parts, lexer) => execMakeRoomAdminCommand(roomId, event, mjolnir, parts), -}]; - export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { const line = event['content']['body']; const parts = line.trim().split(' ').filter(p => p.trim().length > 0); console.debug("YORIC", "line", line); const lexer = new Lexer(line); lexer.token("command"); // Consume `!mjolnir`. - const cmd = lexer.token("id").text; + const cmd = lexer.alternatives( + () => lexer.token("id").text, + () => null + ); console.debug("YORIC", "cmd", cmd); try { - if (cmd === '' || cmd === 'status') { + if (cmd === null || cmd === 'status') { return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); } else if (cmd === 'ban' && parts.length > 2) { return await execBanCommand(roomId, event, mjolnir, parts); diff --git a/src/commands/Lexer.ts b/src/commands/Lexer.ts index 8057b95f..126a1406 100644 --- a/src/commands/Lexer.ts +++ b/src/commands/Lexer.ts @@ -58,28 +58,39 @@ export class Lexer extends TokenizrClass { }); // Dates and durations. - this.rule("dateOrDuration", /\S+/, (ctx, match) => { - let date = new Date(match[0]); - if (!date || Number.isNaN(date.getDate())) { - let duration = parseDuration(match[0]); - if (!duration || Number.isNaN(duration)) { - ctx.reject(); + try { + this.rule("dateOrDuration", /(?:"([^"]+)")|(\S+)/, (ctx, match) => { + let content = match[1] || match[2]; + console.debug("YORIC", "Lexer", "dateOrDuration", content); + let date = new Date(content); + console.debug("YORIC", "Lexer", "dateOrDuration", "date", date); + if (!date || Number.isNaN(date.getDate())) { + let duration = parseDuration(content); + console.debug("YORIC", "Lexer", "dateOrDuration", "duration", duration); + if (!duration || Number.isNaN(duration)) { + ctx.reject(); + } else { + ctx.accept("duration", duration); + } } else { - ctx.accept("duration", duration); + ctx.accept("date", date); } - } else { - ctx.accept("date", date); - } - }); + }); + } catch (ex) { + console.error("YORIC", ex); + } // Jokers. this.rule("STAR", /\*/, (ctx) => { ctx.accept("STAR"); }); - this.rule(/./, (ctx) => { - ctx.accept("ANYTHING ELSE") + + // Everything left in the string. + this.rule("ETC", /.*/, (ctx) => { + ctx.accept("ETC") }); + console.debug("YORIC", "Preparing lexer", string); this.input(string); } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 38708557..4143887b 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -39,7 +39,13 @@ type Summary = { succeeded: userId[], failed: userId[] }; // !mjolnir since / [...rooms] [...reason] export async function execSinceCommand(destinationRoomId: string, event: any, mjolnir: Mjolnir, lexer: Lexer) { - let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, 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); @@ -70,21 +76,16 @@ function formatResult(action: string, targetRoomId: string, recentJoins: Join[], // - in case of success, returns `{ok: undefined}`, in case of error, returns `{error: string}`. async function execSinceCommandAux(destinationRoomId: string, event: any, mjolnir: Mjolnir, lexer: Lexer): Promise> { // Attempt to parse `` as a date or duration. - let dateOrDurationToken: Date | number; - try { - dateOrDurationToken = lexer.token("dateOrDuration").value; - } catch (ex) { - return { error: "Invalid " }; - } - console.debug("YORIC", "dateOrDurationToken", dateOrDurationToken); + let dateOrDuration: Date |number = lexer.token("dateOrDuration").value; + console.debug("YORIC", "dateOrDuration", dateOrDuration); let minDate; let maxAgeMS; - if (dateOrDurationToken instanceof Date) { - minDate = dateOrDurationToken; - maxAgeMS = Date.now() - dateOrDurationToken.getTime() as number; + if (dateOrDuration instanceof Date) { + minDate = dateOrDuration; + maxAgeMS = Date.now() - dateOrDuration.getTime() as number; } else { - minDate = new Date(Date.now() - dateOrDurationToken); - maxAgeMS = dateOrDurationToken; + minDate = new Date(Date.now() - dateOrDuration); + maxAgeMS = dateOrDuration; } // Attempt to parse `` as Action. @@ -109,27 +110,29 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni // Now list affected rooms. const rooms: Set = new Set(); do { - try { - let token = lexer.alternatives( - () => lexer.token("STAR"), - () => lexer.token("roomAliasOrID"), - ); - if (token.type == "STAR") { - for (let roomId of Object.keys(mjolnir.protectedRooms)) { - rooms.add(roomId); - } - continue; - } - 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)}.`); - } + let token = lexer.alternatives( + () => lexer.token("STAR"), + () => lexer.token("roomAliasOrID"), + ); + console.debug("YORIC", "token", token); + if (!token) { + // We have reached the end of rooms. + break; + } + if (token.type === "STAR") { + for (let roomId of Object.keys(mjolnir.protectedRooms)) { rooms.add(roomId); - continue; } - } catch (ex) { - // If we're done with rooms, we have entered . + 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; + } + if (token.type == 'EOF') { break; } } while(true); @@ -143,8 +146,8 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni // Parse everything else as ``, stripping quotes if any have been added. const reason = lexer.alternatives( () => lexer.token("string"), - () => lexer.token("EVERYTHING ELSE") - ).text; + () => lexer.token("ETC") + )?.text || ""; console.debug("YORIC", "reason", reason); const progressEventId = await mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '⏳'); From 3cb4ffc3e67172c92898da5a4760bc65af932392 Mon Sep 17 00:00:00 2001 From: David Teller Date: Tue, 19 Jul 2022 17:36:28 +0200 Subject: [PATCH 4/7] WIP --- src/commands/Lexer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/Lexer.ts b/src/commands/Lexer.ts index 126a1406..4891899f 100644 --- a/src/commands/Lexer.ts +++ b/src/commands/Lexer.ts @@ -12,7 +12,7 @@ const TokenizrClass = Tokenizr || TokenizrModule; export class Lexer extends TokenizrClass { constructor(string: string) { super(); - + console.debug("YORIC", "Lexer", 0); // Ignore whitespace. this.rule(/\s+/, (ctx) => { ctx.ignore() @@ -58,6 +58,7 @@ export class Lexer extends TokenizrClass { }); // Dates and durations. + console.debug("YORIC", "Lexer", 1); try { this.rule("dateOrDuration", /(?:"([^"]+)")|(\S+)/, (ctx, match) => { let content = match[1] || match[2]; From da4edb88548fcce1405eb2542bfb4dadc5fc1839 Mon Sep 17 00:00:00 2001 From: David Teller Date: Tue, 19 Jul 2022 18:25:29 +0200 Subject: [PATCH 5/7] WIP --- src/commands/CommandHandler.ts | 4 +- src/commands/Lexer.ts | 78 ++++++++++++++++++---------------- src/commands/SinceCommand.ts | 33 ++++++-------- test/integration/fixtures.ts | 2 +- 4 files changed, 59 insertions(+), 58 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 4ea42a61..478fd082 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -48,14 +48,14 @@ export const COMMAND_PREFIX = "!mjolnir"; export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { const line = event['content']['body']; const parts = line.trim().split(' ').filter(p => p.trim().length > 0); - console.debug("YORIC", "line", line); + const lexer = new Lexer(line); lexer.token("command"); // Consume `!mjolnir`. + // Extract command. const cmd = lexer.alternatives( () => lexer.token("id").text, () => null ); - console.debug("YORIC", "cmd", cmd); try { if (cmd === null || cmd === 'status') { diff --git a/src/commands/Lexer.ts b/src/commands/Lexer.ts index 4891899f..1e30a50a 100644 --- a/src/commands/Lexer.ts +++ b/src/commands/Lexer.ts @@ -6,92 +6,98 @@ 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 common cases. + * A lexer for command parsing. + * + * Recommended use is `lexer.token("state")`. */ export class Lexer extends TokenizrClass { constructor(string: string) { super(); - console.debug("YORIC", "Lexer", 0); // Ignore whitespace. - this.rule(/\s+/, (ctx) => { + this.rule(WHITESPACE, (ctx) => { ctx.ignore() }) // Command rules, e.g. `!mjolnir` - this.rule("command", /![a-zA-Z_]+/, (ctx) => { + this.rule("command", COMMAND, (ctx) => { ctx.accept("command"); }); // Identifier rules, used e.g. for subcommands `get`, `set` ... - this.rule("id", /[a-zA-Z_]+/, (ctx) => { + this.rule("id", IDENTIFIER, (ctx) => { ctx.accept("id"); }); // Users - this.rule("userID", /@[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + this.rule("userID", USER_ID, (ctx) => { ctx.accept("userID"); }); - this.rule("globUserID", /@[a-zA-Z0-9_.=\-?*/]+:.+/, (ctx) => { + this.rule("globUserID", GLOB_USER_ID, (ctx) => { ctx.accept("globUserID"); }); // Rooms - this.rule("roomID", /![a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + this.rule("roomID", ROOM_ID, (ctx) => { ctx.accept("roomID"); }); - this.rule("roomAlias", /#[a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + this.rule("roomAlias", ROOM_ALIAS, (ctx) => { ctx.accept("roomAlias"); }); - this.rule("roomAliasOrID", /[#!][a-zA-Z0-9_.=\-/]+:.+/, (ctx) => { + this.rule("roomAliasOrID", ROOM_ALIAS_OR_ID, (ctx) => { ctx.accept("roomAliasOrID"); }); // Numbers. - this.rule("int", /[+-]?[0-9]+/, (ctx, match) => { + this.rule("int", INT, (ctx, match) => { ctx.accept("int", parseInt(match[0])) }); // Quoted strings. - this.rule("string", /"((?:\\"|[^\r\n])*)"/, (ctx, match) => { + this.rule("string", STRING, (ctx, match) => { ctx.accept("string", match[1].replace(/\\"/g, "\"")) }); // Dates and durations. - console.debug("YORIC", "Lexer", 1); - try { - this.rule("dateOrDuration", /(?:"([^"]+)")|(\S+)/, (ctx, match) => { - let content = match[1] || match[2]; - console.debug("YORIC", "Lexer", "dateOrDuration", content); - let date = new Date(content); - console.debug("YORIC", "Lexer", "dateOrDuration", "date", date); - if (!date || Number.isNaN(date.getDate())) { - let duration = parseDuration(content); - console.debug("YORIC", "Lexer", "dateOrDuration", "duration", duration); - if (!duration || Number.isNaN(duration)) { - ctx.reject(); - } else { - ctx.accept("duration", duration); - } + 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("date", date); + ctx.accept("duration", duration); } - }); - } catch (ex) { - console.error("YORIC", ex); - } + } else { + ctx.accept("date", date); + } + }); // Jokers. - this.rule("STAR", /\*/, (ctx) => { + this.rule("STAR", STAR, (ctx) => { ctx.accept("STAR"); }); // Everything left in the string. - this.rule("ETC", /.*/, (ctx) => { - ctx.accept("ETC") + this.rule("ETC", ETC, (ctx, match) => { + ctx.accept("ETC", match[0].trim()); }); - console.debug("YORIC", "Preparing lexer", string); this.input(string); } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 4143887b..24f42b44 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -77,7 +77,6 @@ function formatResult(action: string, targetRoomId: string, recentJoins: Join[], 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; - console.debug("YORIC", "dateOrDuration", dateOrDuration); let minDate; let maxAgeMS; if (dateOrDuration instanceof Date) { @@ -101,25 +100,28 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni if (!action) { return {error: `Invalid . Expected one of ${JSON.stringify(Action)}`}; } - console.debug("YORIC", "action", action); // Attempt to parse `` as a number. const maxEntries = lexer.token("int").value as number; - console.debug("YORIC", "maxEntries", maxEntries); - // Now list affected rooms. + // Parse rooms. + // Parse everything else as ``, stripping quotes if any have been added. const rooms: Set = new Set(); + let reason = ""; do { + let token = lexer.alternatives( + // Room () => lexer.token("STAR"), () => lexer.token("roomAliasOrID"), + // Reason + () => lexer.token("string"), + () => lexer.token("ETC") ); - console.debug("YORIC", "token", token); - if (!token) { - // We have reached the end of rooms. + if (!token || token.type === "EOF") { + // We have reached the end of rooms, no reason. break; - } - if (token.type === "STAR") { + } else if (token.type === "STAR") { for (let roomId of Object.keys(mjolnir.protectedRooms)) { rooms.add(roomId); } @@ -131,8 +133,9 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni } rooms.add(roomId); continue; - } - if (token.type == 'EOF') { + } else if (token.type === "string" || token.type === "ETC") { + // We have reached the end of rooms with a reason. + reason = token.text; break; } } while(true); @@ -141,14 +144,6 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni error: "Missing rooms. Use `*` if you wish to apply to every protected room.", }; } - console.debug("YORIC", "rooms", rooms); - - // Parse everything else as ``, stripping quotes if any have been added. - const reason = lexer.alternatives( - () => lexer.token("string"), - () => lexer.token("ETC") - )?.text || ""; - console.debug("YORIC", "reason", reason); const progressEventId = await mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '⏳'); 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; From 0cc6862e118c4d6ffc57683003b9d49d12f70fa5 Mon Sep 17 00:00:00 2001 From: David Teller Date: Fri, 22 Jul 2022 11:33:49 +0200 Subject: [PATCH 6/7] 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 00000000..ab76dc8b --- /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 478fd082..4c87c933 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 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/Lexer.ts b/src/commands/Lexer.ts deleted file mode 100644 index 1e30a50a..00000000 --- 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 24f42b44..3118a162 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); From a9d3c2b0a0244143333871fb2f06d3f8547aacee Mon Sep 17 00:00:00 2001 From: David Teller Date: Fri, 22 Jul 2022 13:48:25 +0200 Subject: [PATCH 7/7] WIP --- src/Mjolnir.ts | 53 ++---- src/commands/Command.ts | 220 +++++++++++++++-------- src/commands/CommandHandler.ts | 94 ++++------ src/commands/CreateBanListCommand.ts | 89 ++++----- src/commands/DeactivateCommand.ts | 29 +-- src/commands/DumpRulesCommand.ts | 25 ++- src/commands/ImportCommand.ts | 117 ++++++------ src/commands/PermissionCheckCommand.ts | 14 +- src/commands/RedactCommand.ts | 69 ++++--- src/commands/SetDefaultBanListCommand.ts | 32 ++-- src/commands/StatusCommand.ts | 32 ++-- src/commands/SyncCommand.ts | 12 +- src/commands/UnbanBanCommand.ts | 25 ++- src/commands/WatchUnwatchCommand.ts | 49 +++-- 14 files changed, 503 insertions(+), 357 deletions(-) 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 index ab76dc8b..2b7da8c6 100644 --- a/src/commands/Command.ts +++ b/src/commands/Command.ts @@ -1,3 +1,19 @@ +/* +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"; @@ -5,8 +21,6 @@ import Tokenizr from "tokenizr"; // 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; @@ -22,6 +36,27 @@ 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. @@ -37,41 +72,41 @@ export class Lexer extends TokenizrClass { }) // Identifier rules, used e.g. for subcommands `get`, `set` ... - this.rule("command", COMMAND, (ctx) => { - ctx.accept("command"); + this.rule(Token.COMMAND, COMMAND, (ctx) => { + ctx.accept(Token.COMMAND); }); // Users - this.rule("userID", USER_ID, (ctx) => { - ctx.accept("userID"); + this.rule(Token.USER_ID, USER_ID, (ctx) => { + ctx.accept(Token.USER_ID); }); - this.rule("globUserID", GLOB_USER_ID, (ctx) => { - ctx.accept("globUserID"); + this.rule(Token.GLOB_USER_ID, GLOB_USER_ID, (ctx) => { + ctx.accept(Token.GLOB_USER_ID); }); // Rooms - this.rule("roomID", ROOM_ID, (ctx) => { - ctx.accept("roomID"); + this.rule(Token.ROOM_ID, ROOM_ID, (ctx) => { + ctx.accept(Token.ROOM_ID); }); - this.rule("roomAlias", ROOM_ALIAS, (ctx) => { - ctx.accept("roomAlias"); + this.rule(Token.ROOM_ALIAS, ROOM_ALIAS, (ctx) => { + ctx.accept(Token.ROOM_ALIAS); }); - this.rule("roomAliasOrID", ROOM_ALIAS_OR_ID, (ctx) => { - ctx.accept("roomAliasOrID"); + this.rule(Token.ROOM_ALIAS_OR_ID, ROOM_ALIAS_OR_ID, (ctx) => { + ctx.accept(Token.ROOM_ALIAS_OR_ID); }); // Numbers. - this.rule("int", INT, (ctx, match) => { - ctx.accept("int", parseInt(match[0])) + this.rule(Token.INT, INT, (ctx, match) => { + ctx.accept(Token.INT, parseInt(match[0])) }); // Quoted strings. - this.rule("string", STRING, (ctx, match) => { - ctx.accept("string", match[1].replace(/\\"/g, "\"")) + this.rule(Token.STRING, STRING, (ctx, match) => { + ctx.accept(Token.STRING, match[1].replace(/\\"/g, "\"")) }); // Dates and durations. - this.rule("dateOrDuration", DATE_OR_DURATION, (ctx, match) => { + 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())) { @@ -79,28 +114,35 @@ export class Lexer extends TokenizrClass { if (!duration || Number.isNaN(duration)) { ctx.reject(); } else { - ctx.accept("duration", duration); + ctx.accept(Token.DURATION, duration); } } else { - ctx.accept("date", date); + ctx.accept(Token.DATE, date); } }); + this.rule(Token.PERMALINK, PERMALINK, (ctx) => { + ctx.accept(Token.PERMALINK); + }); + // Jokers. - this.rule("STAR", STAR, (ctx) => { - ctx.accept("STAR"); + 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("ETC", ETC, (ctx, match) => { - ctx.accept("ETC", match[0].trim()); + this.rule(Token.ETC, ETC, (ctx, match) => { + ctx.accept(Token.ETC, match[0].trim()); }); this.input(string); } - public token(state?: string): TokenizrModule.Token { - if (typeof state === "string") { + public token(state?: Token | string): TokenizrModule.Token { + if (typeof state !== "undefined") { this.state(state); } return super.token(); @@ -123,6 +165,8 @@ export interface Command { */ readonly helpArgs: string; + readonly accept?: (lexer: Lexer) => boolean; + /** * Execute the command. * @@ -165,7 +209,7 @@ export class CommandManager { /** * All the prefixes this bot needs to answer to. */ - private PREFIXES: string[] = []; + private readonly prefixes: string[] = []; /** * Register a new command. @@ -180,18 +224,14 @@ export class CommandManager { } 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; + const getMainPrefix = () => this.prefixes[0].trim(); class HelpCommand implements Command { command: "help"; helpDescription: "This help message"; @@ -203,10 +243,11 @@ export class CommandManager { let prefixes = []; let width = 0; + let mainPrefix = getMainPrefix(); // Compute width to display the help properly. for (let command of allCommands) { - let prefix = `${this.c} ${command.command} ${command.helpArgs} `; + let prefix = `${mainPrefix} ${command.command} ${command.helpArgs} `; width = Math.max(width, prefix.length); prefixes.push(prefix); } @@ -230,39 +271,22 @@ export class CommandManager { 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); + 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} `); } - if (config.commands.allowNoPrefix) { - this.PREFIXES.push("!"); - } - - // Initialize listening to messages. + this.mjolnir.client.on("room.message", this.onMessageCallback); } @@ -289,7 +313,7 @@ export class CommandManager { const body = content['body']; const lowercaseBody = body.toLowerCase(); - const prefixUsed = this.PREFIXES.find(p => lowercaseBody.startsWith(p)); + const prefixUsed = this.prefixes.find(p => lowercaseBody.startsWith(p)); if (!prefixUsed) { // Not a message for the bot. return; @@ -302,11 +326,34 @@ export class CommandManager { 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)); + // 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) { @@ -320,11 +367,30 @@ export class CommandManager { 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); + 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); + } } } -} \ No newline at end of file +} + +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 4c87c933..37b4d99b 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -15,18 +15,18 @@ 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, @@ -40,9 +40,29 @@ import { execShutdownRoomCommand } from "./ShutdownRoomCommand"; import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands"; import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { execSinceCommand } from "./SinceCommand"; -import { Lexer } from "./Command"; +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 line = event['content']['body']; @@ -57,33 +77,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st ); try { - if (cmd === null || cmd === 'status') { - return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); - } else if (cmd === 'ban' && parts.length > 2) { - return await execBanCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'unban' && parts.length > 2) { - return await execUnbanCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'rules' && parts.length === 4 && parts[2] === 'matching') { - return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) - } else if (cmd === 'rules') { - return await execDumpRulesCommand(roomId, event, mjolnir); - } else if (cmd === 'sync') { - return await execSyncCommand(roomId, event, mjolnir); - } else if (cmd === 'verify') { - return await execPermissionCheckCommand(roomId, event, mjolnir); - } else if (parts.length >= 5 && cmd === 'list' && parts[2] === 'create') { - return await execCreateListCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'watch' && parts.length > 1) { - return await execWatchCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'unwatch' && parts.length > 1) { - return await execUnwatchCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'redact' && parts.length > 1) { - return await execRedactCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'import' && parts.length > 2) { - return await execImportCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'default' && parts.length > 2) { - return await execSetDefaultListCommand(roomId, event, mjolnir, parts); - } else if (cmd === 'deactivate' && parts.length > 2) { + if (cmd === 'deactivate' && parts.length > 2) { return await execDeactivateCommand(roomId, event, mjolnir, parts); } else if (cmd === 'protections') { return await execListProtections(roomId, event, mjolnir, parts); @@ -123,30 +117,12 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execShutdownRoomCommand(roomId, event, mjolnir, parts); } else if (cmd === 'since') { return await execSinceCommand(roomId, event, mjolnir, lexer); - } else if (cmd === 'kick' && parts.length > 2) { - return await execKickCommand(roomId, event, mjolnir, parts); } 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" + @@ -167,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/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/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'], '✅'); }