From 953b065254b34c8400054b3a90ef87b1dec4662e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 16 Nov 2022 12:15:40 +0000 Subject: [PATCH 1/3] First stage of commands refactor. --- src/Mjolnir.ts | 7 +- src/commands/ApplicationCommand.ts | 69 ++++++++++ src/commands/CommandHandler.ts | 14 +- src/commands/MatrixInterfaceCommand.ts | 170 +++++++++++++++++++++++++ src/commands/UnbanBanCommand.ts | 62 ++++++--- src/config.ts | 4 + 6 files changed, 299 insertions(+), 27 deletions(-) create mode 100644 src/commands/ApplicationCommand.ts create mode 100644 src/commands/MatrixInterfaceCommand.ts diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 304242ce..2387e293 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -39,6 +39,7 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ProtectionManager } from "./protections/ProtectionManager"; import { RoomMemberManager } from "./RoomMembers"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; +import { MatrixCommandTable } from "./commands/MatrixInterfaceCommand"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -86,6 +87,8 @@ export class Mjolnir { */ public readonly reportManager: ReportManager; + private readonly matrixCommandTable: MatrixCommandTable; + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixClient} client @@ -168,6 +171,7 @@ export class Mjolnir { public readonly ruleServer: RuleServer | null, ) { this.protectedRoomsConfig = new ProtectedRoomsConfig(client); + this.matrixCommandTable = new MatrixCommandTable(config.commands.features); // Setup bot. @@ -204,7 +208,8 @@ export class Mjolnir { LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`); await client.sendReadReceipt(roomId, event['event_id']); - return handleCommand(roomId, event, this); + + return handleCommand(roomId, event, this, this.matrixCommandTable); } }); diff --git a/src/commands/ApplicationCommand.ts b/src/commands/ApplicationCommand.ts new file mode 100644 index 00000000..4f9be6bd --- /dev/null +++ b/src/commands/ApplicationCommand.ts @@ -0,0 +1,69 @@ +/* +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. +*/ + +/** + * A feature that an application supports. + * Used by ApplicationCommands as required feature flags they depend on to function. + */ +export interface ApplicationFeature { + name: string, + description: string, +} + + +/** + * These are features that have been defined using `defineApplicationCommand`. + * you can access them using `getApplicationFeature`. + */ +const APPLICATION_FEATURES = new Map(); + +export function defineApplicationFeature(feature: ApplicationFeature): void { + if (APPLICATION_FEATURES.has(feature.name)) { + throw new TypeError(`Application feature has already been defined ${feature.name}`); + } + APPLICATION_FEATURES.set(feature.name, feature); +} + +export function getApplicationFeature(name: string): ApplicationFeature|undefined { + return APPLICATION_FEATURES.get(name); +} + +export class ApplicationCommand Promise> { + constructor( + public readonly requiredFeatures: ApplicationFeature[], + public readonly executor: ExecutorType + ) { + } +} + +export function defineApplicationCommand Promise>( + requiredFeatureNames: string[], + executor: ExecutorType) { + const features = requiredFeatureNames.map(name => { + const feature = getApplicationFeature(name); + if (feature) { + return feature + } else { + throw new TypeError(`Can't find a feature called ${name}`); + } + }) + return new ApplicationCommand(features, executor); +} + +defineApplicationFeature({ + name: "synapse admin", + description: "Requires that the mjolnir account has Synapse admin" +}); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 703af24a..dac35cd8 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -16,7 +16,7 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { execStatusCommand } from "./StatusCommand"; -import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand"; +import "./UnbanBanCommand"; import { execDumpRulesCommand, execRulesMatchingCommand } from "./DumpRulesCommand"; import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk"; import { htmlEscape } from "../utils"; @@ -42,11 +42,12 @@ import { execKickCommand } from "./KickCommand"; import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { parse as tokenize } from "shell-quote"; import { execSinceCommand } from "./SinceCommand"; +import { MatrixCommandTable } from "./MatrixInterfaceCommand"; export const COMMAND_PREFIX = "!mjolnir"; -export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { +export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir, commandTable: MatrixCommandTable) { const cmd = event['content']['body']; const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); @@ -57,10 +58,6 @@ export async function handleCommand(roomId: string, event: { content: { body: st try { if (parts.length === 1 || parts[1] === 'status') { return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); - } else if (parts[1] === 'ban' && parts.length > 2) { - return await execBanCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unban' && parts.length > 2) { - return await execUnbanCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') { return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) } else if (parts[1] === 'rules') { @@ -126,6 +123,11 @@ export async function handleCommand(roomId: string, event: { content: { body: st } else if (parts[1] === 'make' && parts[2] === 'admin' && parts.length > 3) { return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); } else { + const command = commandTable.findAMatchingCommand(parts.slice(1)); + if (command) { + return await command.invoke(mjolnir, roomId, event, parts); + } + // Help menu const menu = "" + "!mjolnir - Print status information\n" + diff --git a/src/commands/MatrixInterfaceCommand.ts b/src/commands/MatrixInterfaceCommand.ts new file mode 100644 index 00000000..995cd136 --- /dev/null +++ b/src/commands/MatrixInterfaceCommand.ts @@ -0,0 +1,170 @@ +/* +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 { ApplicationCommand, ApplicationFeature, getApplicationFeature } from "./ApplicationCommand"; + +type CommandLookupEntry = Map>; + +type BaseFunction = (...args: any) => Promise; +const FLATTENED_MATRIX_COMMANDS = new Set>(); +const THIS_COMMAND_SYMBOL = Symbol("thisCommand"); + +type ParserSignature Promise> = ( + this: MatrixInterfaceCommand, + mjolnir: Mjolnir, + roomId: string, + event: any, + parts: string[]) => Promise>; + +type RendererSignature> = ( + mjolnir: Mjolnir, + commandRoomId: string, + event: any, + result: Awaited) => Promise; + +/** + * A command that interfaces with a user via Matrix. + * The command wraps an `ApplicationCommand` to make it available to Matrix. + * To do this. A MatrixInterfaceCommand needs to parse an event and the context + * that it was received in with a `parser` and then render the result + * of an `ApplicationCommand` with a `renderer`, which really means + * rendering and sending a matrix event. + * + * Note, matrix interface command can be multi step ie ask for confirmation. + * From the perspective here, confirmation should be a distinct thing that happens + * before the interface command is invoked. + * + * When confirmation is required in the middle of a traditional command ie preview kick + * the preview command should be a distinct command. + */ +class MatrixInterfaceCommand Promise> { + constructor( + public readonly commandParts: string[], + private readonly parser: ParserSignature, + public readonly applicationCommand: ApplicationCommand, + private readonly renderer: RendererSignature> + ) { + + } + + /** + * Parse the context required by the command, call the associated application command and then render the result to a Matrix room. + * The arguments to invoke will be given directly to the parser. + * The executor of the application command will then be applied to whatever is returned by the parser. + * Then the renderer will be applied to the same arguments given to the parser (so it knows which matrix room to respond to) + * along with the result of the executor. + * @param args These will be the arguments to the parser function. + */ + public async invoke(...args: Parameters>): Promise { + const parseResults = await this.parser(...args); + const executorResult: ReturnType = await this.applicationCommand.executor.apply(this, parseResults); + await this.renderer.apply(this, [...args.slice(0, -1), executorResult]); + } +} + +/** + * Define a command to be interfaced via Matrix. + * @param commandParts constant parts used to discriminate the command e.g. "ban" or "config" "get" + * @param parser A function that parses a Matrix Event from a room to be able to invoke an ApplicationCommand. + * @param applicationCommmand The ApplicationCommand this is an interface wrapper for. + * @param renderer Render the result of the application command back to a room. + */ +export function defineMatrixInterfaceCommand Promise>( + commandParts: string[], + parser: ParserSignature, + applicationCommmand: ApplicationCommand, + renderer: RendererSignature>) { + FLATTENED_MATRIX_COMMANDS.add( + new MatrixInterfaceCommand( + commandParts, + parser, + applicationCommmand, + renderer + ) + ); +} + + +/** + * This can be used by mjolnirs or an appservice bot. + */ +export class MatrixCommandTable { + public readonly features: ApplicationFeature[]; + private readonly flattenedCommands: Set>; + private readonly commands: CommandLookupEntry = new Map(); + + constructor(featureNames: string[]) { + this.features = featureNames.map(name => { + const feature = getApplicationFeature(name); + if (feature) { + return feature + } else { + throw new TypeError(`Couldn't find feature with name ${name}`) + } + }); + + const commandHasFeatures = (command: ApplicationCommand) => { + return command.requiredFeatures.every(feature => this.features.includes(feature)) + } + this.flattenedCommands = new Set([...FLATTENED_MATRIX_COMMANDS].filter(interfaceCommand => commandHasFeatures(interfaceCommand.applicationCommand))); + [...this.flattenedCommands].forEach(this.internCommand, this); + } + + public findAMatchingCommand(parts: string[]) { + const getCommand = (table: CommandLookupEntry): undefined|MatrixInterfaceCommand => { + const command = table.get(THIS_COMMAND_SYMBOL); + if (command instanceof Map) { + throw new TypeError("There is an implementation bug, only commands should be stored under the command symbol"); + } + return command; + }; + const tableHelper = (table: CommandLookupEntry, nextParts: string[]): undefined|MatrixInterfaceCommand => { + if (nextParts.length === 0) { + // Then they might be using something like "!mjolnir status" + return getCommand(table); + } + const entry = table.get(nextParts.shift()!); + if (!entry) { + // The reason there's no match is because this is the command arguments, rather than subcommand notation. + return getCommand(table); + } else { + if (!(entry instanceof Map)) { + throw new TypeError("There is an implementation bug, only maps should be stored under arbirtrary keys"); + } + return tableHelper(entry, nextParts); + } + }; + return tableHelper(this.commands, [...parts]); + } + + private internCommand(command: MatrixInterfaceCommand) { + const internCommandHelper = (table: CommandLookupEntry, commandParts: string[]): void => { + if (commandParts.length === 0) { + if (table.has(THIS_COMMAND_SYMBOL)) { + throw new TypeError(`There is already a command for ${JSON.stringify(commandParts)}`) + } + table.set(THIS_COMMAND_SYMBOL, command); + } else { + const nextTable = new Map(); + table.set(commandParts.shift()!, nextTable); + internCommandHelper(nextTable, commandParts); + } + } + + internCommandHelper(this.commands, [...command.commandParts]); + } +} diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index b5c5b907..5a5b2b7c 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -19,6 +19,8 @@ import PolicyList from "../models/PolicyList"; import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk"; import { RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule"; import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand"; +import { defineApplicationCommand } from "./ApplicationCommand"; +import { defineMatrixInterfaceCommand } from "./MatrixInterfaceCommand"; interface Arguments { list: PolicyList | null; @@ -113,25 +115,32 @@ 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[]) { - const bits = await parseArguments(roomId, event, mjolnir, parts); - if (!bits) return; // error already handled - - await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} +const BAN_COMMAND = defineApplicationCommand([], async (list: PolicyList, ruleType: string, entity: string, reason: string): Promise => { + await list.banEntity(ruleType, entity, reason); +}); -// !mjolnir unban [apply:t/f] -export 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 +// !mjolnir ban [reason] [--force] +defineMatrixInterfaceCommand(["ban"], + async function (mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise<[PolicyList, string, string, string]> { + const bits = await parseArguments(roomId, event, mjolnir, parts); + if (bits === null) { + // FIXME + throw new Error("Couldn't parse arguments FIXME - parser needs to be rewritten to reject nulls"); + } + return [bits.list!, bits.ruleType!, bits.entity!, bits.reason!]; + }, + BAN_COMMAND, + async function (mjolnir: Mjolnir, commandRoomId: string, event: any, result: void) { + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅'); + } +); - await bits.list!.unbanEntity(bits.ruleType!, bits.entity); +const UNBAN_COMMAND = defineApplicationCommand([], async (mjolnir: Mjolnir, list: PolicyList, ruleType: string, entity: string, reason: string): Promise => { + await list.unbanEntity(ruleType, entity); const unbanUserFromRooms = async () => { - const rule = new MatrixGlob(bits.entity); - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); + const rule = new MatrixGlob(entity); + await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + entity); let unbannedSomeone = false; for (const protectedRoomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) { const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined); @@ -159,14 +168,27 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol } }; - if (USER_RULE_TYPES.includes(bits.ruleType!)) { - mjolnir.unlistedUserRedactionHandler.removeUser(bits.entity); - if (bits.reason === 'true') { + if (USER_RULE_TYPES.includes(ruleType)) { + mjolnir.unlistedUserRedactionHandler.removeUser(entity); + if (reason === 'true') { await unbanUserFromRooms(); } else { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", "Running unban without `unban true` will not override existing room level bans"); } } +}) - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} +// !mjolnir unban [apply:t/f] +defineMatrixInterfaceCommand(["unban"], + async function (mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise<[Mjolnir, PolicyList, string, string, string]> { + const bits = await parseArguments(roomId, event, mjolnir, parts); + if (bits === null) { + throw new Error("Couldn't parse arguments FIXME"); + } + return [mjolnir, bits.list!, bits.ruleType!, bits.entity!, bits.reason!]; + }, + UNBAN_COMMAND, + async function (mjolnir: Mjolnir, commandRoomId: string, event: any, result: void) { + await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅'); + } +); diff --git a/src/config.ts b/src/config.ts index e62359ac..69ed4d7f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,7 @@ export interface IConfig { allowNoPrefix: boolean; additionalPrefixes: string[]; confirmWildcardBan: boolean; + features: string[]; }; protections: { wordlist: { @@ -136,6 +137,9 @@ const defaultConfig: IConfig = { allowNoPrefix: false, additionalPrefixes: [], confirmWildcardBan: true, + features: [ + "synapse admin", + ] }, protections: { wordlist: { From cddfe58906384bcb1692653e0f1cee9d682a8272 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 17 Nov 2022 18:20:59 +0000 Subject: [PATCH 2/3] ValidationInterface --- src/commands/MatrixInterfaceCommand.ts | 25 ++++- src/commands/Validation.ts | 137 +++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src/commands/Validation.ts diff --git a/src/commands/MatrixInterfaceCommand.ts b/src/commands/MatrixInterfaceCommand.ts index 995cd136..3978825f 100644 --- a/src/commands/MatrixInterfaceCommand.ts +++ b/src/commands/MatrixInterfaceCommand.ts @@ -16,6 +16,8 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { ApplicationCommand, ApplicationFeature, getApplicationFeature } from "./ApplicationCommand"; +import { ValidationError, ValidationResult } from "./Validation"; +import { RichReply, LogService } from "matrix-bot-sdk"; type CommandLookupEntry = Map>; @@ -28,7 +30,7 @@ type ParserSignature Promise> = ( mjolnir: Mjolnir, roomId: string, event: any, - parts: string[]) => Promise>; + parts: string[]) => Promise, ValidationError>>; type RendererSignature> = ( mjolnir: Mjolnir, @@ -56,7 +58,8 @@ class MatrixInterfaceCommand Promise public readonly commandParts: string[], private readonly parser: ParserSignature, public readonly applicationCommand: ApplicationCommand, - private readonly renderer: RendererSignature> + private readonly renderer: RendererSignature>, + private readonly validationErrorHandler?: (mjolnir: Mjolnir, roomId: string, event: any, validationError: ValidationError) => Promise ) { } @@ -71,9 +74,25 @@ class MatrixInterfaceCommand Promise */ public async invoke(...args: Parameters>): Promise { const parseResults = await this.parser(...args); - const executorResult: ReturnType = await this.applicationCommand.executor.apply(this, parseResults); + if (parseResults.isErr()) { + this.reportValidationError.apply(this, [...args.slice(0, -1), parseResults.err]); + return; + } + const executorResult: ReturnType = await this.applicationCommand.executor.apply(this, parseResults.ok); await this.renderer.apply(this, [...args.slice(0, -1), executorResult]); } + + private async reportValidationError(mjolnir: Mjolnir, roomId: string, event: any, validationError: ValidationError): Promise { + LogService.info("MatrixInterfaceCommand", `User input validation error when parsing command ${this.commandParts}: ${validationError.message}`); + if (this.validationErrorHandler) { + await this.validationErrorHandler.apply(this, arguments); + return; + } + const replyMessage = validationError.message; + const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); + } } /** diff --git a/src/commands/Validation.ts b/src/commands/Validation.ts new file mode 100644 index 00000000..bfa43284 --- /dev/null +++ b/src/commands/Validation.ts @@ -0,0 +1,137 @@ +/* +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. +*/ + +type ValidationMatchExpression = { ok?: (ok: Ok) => any, err?: (err: Err) => any}; + +/** + * Why do we need a Result Monad for the parser signiture. + * I (Gnuxie) don't like monadic error handling, simply because + * I'm a strong believer in failing early, yes i may be misinformed. + * The only reason we don't use an exception in this case is because + * these are NOT to be used nilly willy and thrown out of context + * from an unrelated place. The Monad ensures locality (in terms of call chain) + * to the user interface by being infuriating to deal with. + * It also does look different to an exception + * to a naive programmer. Ideally though, if the world had adopted + * condition based error handling i would simply create a condition + * type for validation errors that can be translated/overidden by + * the command handler, and it wouldn't have to look like this. + * It's important to remember the errors we are reporting are to do with user input, + * we're trying to tell the user they did something wrong and what that is. + * This is something completely different to a normal exception, + * where we are saying to ourselves that our assumptions in our code about + * the thing we're doing are completely wrong. The user never + * should see these as there is nothing they can do about it. + * + * OK, it would be too annoying even for me to have a real Monad. + * So this is dumb as hell, no worries though + * + * OK I'm beginning to regret my decision. + * + * TODO: Can we make ValidationResult include ValidationError + */ + export class ValidationResult { + private constructor( + private readonly okValue: Ok|null, + private readonly errValue: Err|null, + ) { + + } + public static Ok(value: Ok): ValidationResult { + return new ValidationResult(value, null); + } + + public static Err(value: Err): ValidationResult { + return new ValidationResult(null, value); + } + + public async match(expression: ValidationMatchExpression) { + return this.okValue ? await expression.ok!(this.ok) : await expression.err!(this.err); + } + + public isOk(): boolean { + return this.okValue !== null; + } + + public isErr(): boolean { + return this.errValue !== null; + } + + public get ok(): Ok { + if (this.isOk()) { + return this.okValue!; + } else { + throw new TypeError("You did not check isOk before accessing ok"); + } + } + + public get err(): Err { + if (this.isErr()) { + return this.errValue!; + } else { + throw new TypeError("You did not check isErr before accessing err"); + } + } +} + +export class ValidationError { + private static readonly ERROR_CODES = new Map(); + + private constructor( + public readonly code: symbol, + public readonly message: string, + ) { + + } + + private static ensureErrorCode(code: string): symbol { + const existingCode = ValidationError.ERROR_CODES.get(code); + if (existingCode) { + return existingCode; + } else { + const newCode = Symbol(code); + ValidationError.ERROR_CODES.set(code, newCode); + return newCode; + } + } + + private static findErrorCode(code: string) { + const existingCode = ValidationError.ERROR_CODES.get(code); + if (existingCode) { + return existingCode; + } else { + throw new TypeError(`No code was registered ${code}`); + } + } + + public static makeValidationError(code: string, message: string) { + return new ValidationError(ValidationError.ensureErrorCode(code), message); + } + + public async match(cases: {[keys: string]: (error: ValidationError) => Promise}): Promise { + for (const [key, handler] of Object.entries(cases)) { + const keySymbol = ValidationError.findErrorCode(key); + if (this.code === keySymbol) { + await handler.call(this); + break; + } + } + const defaultHandler = cases.default; + if (defaultHandler) { + await defaultHandler.call(this); + } + } +} From 3fa1a433eda37145b8312e155a6e65d1f0bf9013 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 23 Nov 2022 11:53:55 +0000 Subject: [PATCH 3/3] Use Ban/Unban command as an example. --- src/commands/UnbanBanCommand.ts | 102 +++++++++++++++++--------------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index 5a5b2b7c..cdd73eca 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -16,21 +16,17 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import PolicyList from "../models/PolicyList"; -import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk"; +import { extractRequestError, LogLevel, LogService, MatrixGlob } from "matrix-bot-sdk"; import { RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule"; import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand"; import { defineApplicationCommand } from "./ApplicationCommand"; import { defineMatrixInterfaceCommand } from "./MatrixInterfaceCommand"; +import { ValidationError, ValidationResult } from "./Validation"; -interface Arguments { - list: PolicyList | null; - entity: string; - ruleType: string | null; - reason: string; -} +type Arguments = Parameters<(mjolnir: Mjolnir, list: PolicyList, ruleType: string, entity: string, reason: string) => void>; // Exported for tests -export async function parseArguments(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise { +export async function parseArguments(mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise> { let defaultShortcode: string | null = null; try { const data: { shortcode: string } = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE); @@ -42,10 +38,19 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni // Assume no default. } + const findList = (mjolnir: Mjolnir, shortcode: string): ValidationResult => { + const foundList = mjolnir.lists.find(b => b.listShortcode.toLowerCase() === shortcode.toLowerCase()); + if (foundList !== undefined) { + return ValidationResult.Ok(foundList); + } else { + return ValidationResult.Err(ValidationError.makeValidationError('shortcode not found', `A list with the shourtcode ${shortcode} could not be found.`)); + } + } + let argumentIndex = 2; let ruleType: string | null = null; let entity: string | null = null; - let list: PolicyList | null = null; + let list: ValidationResult|null = null; let force = false; while (argumentIndex < 7 && argumentIndex < parts.length) { const arg = parts[argumentIndex++]; @@ -61,10 +66,7 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni else if (arg.startsWith("!") && !ruleType) ruleType = RULE_ROOM; else if (!ruleType) ruleType = RULE_SERVER; } else if (!list) { - const foundList = mjolnir.lists.find(b => b.listShortcode.toLowerCase() === arg.toLowerCase()); - if (foundList !== undefined) { - list = foundList; - } + list = findList(mjolnir, arg.toLocaleLowerCase()); } if (entity) break; @@ -88,47 +90,55 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni } if (!list) { - list = mjolnir.lists.find(b => b.listShortcode.toLowerCase() === defaultShortcode) || null; - } - - let replyMessage: string | null = null; - if (!list) replyMessage = "No ban list matching that shortcode was found"; - else if (!ruleType) replyMessage = "Please specify the type as either 'user', 'room', or 'server'"; - else if (!entity) replyMessage = "No entity found"; - - if (mjolnir.config.commands.confirmWildcardBan && /[*?]/.test(entity) && !force) { - replyMessage = "Wildcard bans require an additional `--force` argument to confirm"; + if (defaultShortcode) { + list = await findList(mjolnir, defaultShortcode); + if (list.isErr()) { + return ValidationResult.Err(ValidationError.makeValidationError( + "shortcode not found", + `A shortcode was not provided for this command, and a list couldn't be found with the default shortcode ${defaultShortcode}`)) + } + } else { + // FIXME: should be turned into a utility function to find the default list. + // and in general, why is there a default shortcode instead of a default list? + return ValidationResult.Err(ValidationError.makeValidationError( + "no default shortcode", + `A shortcode was not provided for this command, and a default shortcode was not set either.` + )) + } } - if (replyMessage) { - const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); - return null; + if (list.isErr()) { + return ValidationResult.Err(list.err); + } else if (!ruleType) { + return ValidationResult.Err( + ValidationError.makeValidationError('uknown rule type', "Please specify the type as either 'user', 'room', or 'server'") + ); + } else if (!entity) { + return ValidationResult.Err( + ValidationError.makeValidationError('no entity', "No entity was able to be parsed from this command") + ); + } else if (mjolnir.config.commands.confirmWildcardBan && /[*?]/.test(entity) && !force) { + return ValidationResult.Err( + ValidationError.makeValidationError("wildcard required", "Wildcard bans require an additional `--force` argument to confirm") + ); } - return { - list, - entity, + return ValidationResult.Ok([ + mjolnir, + list.ok, ruleType, - reason: parts.splice(argumentIndex).join(" ").trim(), - }; + entity, + parts.splice(argumentIndex).join(" ").trim(), + ]); } -const BAN_COMMAND = defineApplicationCommand([], async (list: PolicyList, ruleType: string, entity: string, reason: string): Promise => { +const BAN_COMMAND = defineApplicationCommand([], async (mjonlir: Mjolnir, list: PolicyList, ruleType: string, entity: string, reason: string): Promise => { await list.banEntity(ruleType, entity, reason); }); // !mjolnir ban [reason] [--force] defineMatrixInterfaceCommand(["ban"], - async function (mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise<[PolicyList, string, string, string]> { - const bits = await parseArguments(roomId, event, mjolnir, parts); - if (bits === null) { - // FIXME - throw new Error("Couldn't parse arguments FIXME - parser needs to be rewritten to reject nulls"); - } - return [bits.list!, bits.ruleType!, bits.entity!, bits.reason!]; - }, + parseArguments, BAN_COMMAND, async function (mjolnir: Mjolnir, commandRoomId: string, event: any, result: void) { await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅'); @@ -180,13 +190,7 @@ const UNBAN_COMMAND = defineApplicationCommand([], async (mjolnir: Mjolnir, list // !mjolnir unban [apply:t/f] defineMatrixInterfaceCommand(["unban"], - async function (mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise<[Mjolnir, PolicyList, string, string, string]> { - const bits = await parseArguments(roomId, event, mjolnir, parts); - if (bits === null) { - throw new Error("Couldn't parse arguments FIXME"); - } - return [mjolnir, bits.list!, bits.ruleType!, bits.entity!, bits.reason!]; - }, + parseArguments, UNBAN_COMMAND, async function (mjolnir: Mjolnir, commandRoomId: string, event: any, result: void) { await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');