From 63e0a24e6ecb90bef873a3d3bdef7d4dc1d9be32 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 16 Nov 2022 12:15:40 +0000 Subject: [PATCH] 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 baa7ca71..04d9e04b 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -39,6 +39,7 @@ import { ProtectionManager } from "./protections/ProtectionManager"; import { RoomMemberManager } from "./RoomMembers"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; +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 {MatrixSendClient} client @@ -171,6 +174,7 @@ export class Mjolnir { public readonly ruleServer: RuleServer | null, ) { this.protectedRoomsConfig = new ProtectedRoomsConfig(client); + this.matrixCommandTable = new MatrixCommandTable(config.commands.features); // Setup bot. @@ -207,7 +211,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 11c7133f..9df9570b 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 fa37e090..f50d1414 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 }; } +const BAN_COMMAND = defineApplicationCommand([], async (list: PolicyList, ruleType: string, entity: string, reason: string): Promise => { + await list.banEntity(ruleType, entity, reason); +}); + // !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 +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!.banEntity(bits.ruleType!, bits.entity, bits.reason); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} - -// !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 - - 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 c716aa8c..3d507608 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,6 +73,7 @@ export interface IConfig { allowNoPrefix: boolean; additionalPrefixes: string[]; confirmWildcardBan: boolean; + features: string[]; }; protections: { wordlist: { @@ -151,6 +152,9 @@ const defaultConfig: IConfig = { allowNoPrefix: false, additionalPrefixes: [], confirmWildcardBan: true, + features: [ + "synapse admin", + ] }, protections: { wordlist: {