From bc8309bd23696406993dcba75414f0fceb1779df Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 11 Oct 2021 12:30:37 +0100 Subject: [PATCH 1/5] Initial stab at support bot commands --- src/components/bot-commands.ts | 92 ++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/components/bot-commands.ts diff --git a/src/components/bot-commands.ts b/src/components/bot-commands.ts new file mode 100644 index 00000000..dee3d524 --- /dev/null +++ b/src/components/bot-commands.ts @@ -0,0 +1,92 @@ +import markdown from "markdown-it"; +import stringArgv from "string-argv"; +import { MatrixMessageContent } from "./MatrixEvent"; + +const md = new markdown(); + +export const botCommandSymbol = Symbol("botCommandMetadata"); +export function botCommand(prefix: string, help: string, requiredArgs: string[] = [], optionalArgs: string[] = [], includeUserId = false) { + return Reflect.metadata(botCommandSymbol, { + prefix, + help, + requiredArgs, + optionalArgs, + includeUserId, + }); +} + +type BotCommandFunction = (...args: string[]) => Promise<{status: boolean}>; + +export type BotCommands = {[prefix: string]: { + fn: BotCommandFunction, + requiredArgs: string[], + optionalArgs: string[], + includeUserId: boolean, +}}; + +/** + * Compile a prototype with a set of bot command functions (functions that are decorated with `botCommand`) + * @param prototype A class prototype containing a set of `botCommand` decorated functions. + * @returns + */ +export function compileBotCommands(prototype: Record): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} { + let content = "Commands:\n"; + const botCommands: BotCommands = {}; + Object.getOwnPropertyNames(prototype).forEach(propetyKey => { + const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); + if (b) { + const requiredArgs = b.requiredArgs.join(" "); + const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" "); + content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; + // We know that this is safe. + botCommands[b.prefix as string] = { + fn: prototype[propetyKey], + requiredArgs: b.requiredArgs, + optionalArgs: b.optionalArgs, + includeUserId: b.includeUserId, + }; + } + }); + return { + helpMessage: (cmdPrefix?: string) => ({ + msgtype: "m.notice", + body: content, + formatted_body: md.render(content).replace(/££PREFIX££/g, cmdPrefix || ""), + format: "org.matrix.custom.html" + }), + botCommands, + } +} + +export async function handleCommand( + userId: string, command: string, botCommands: BotCommands, obj: unknown, prefix?: string +): Promise<{error?: string, handled?: boolean, humanError?: string}> { + if (prefix) { + if (!command.startsWith(prefix)) { + return {handled: false}; + } + command = command.substring(prefix.length); + } + const parts = stringArgv(command); + for (let i = parts.length; i > 0; i--) { + const cmdPrefix = parts.slice(0, i).join(" ").toLowerCase(); + // We have a match! + const command = botCommands[prefix]; + if (command) { + if (command.requiredArgs.length > parts.length - i) { + return {error: "Missing args"}; + } + const args = parts.slice(i); + if (command.includeUserId) { + args.splice(0,0, userId); + } + try { + await botCommands[prefix].fn.apply(obj, args); + return {handled: true}; + } catch (ex) { + return {handled: true, error: ex.message, humanError: ex.humanError}; + } + } + } + return {handled: false}; +} From fe93c223e7d7fb36220bde39e7e926c3cd2b8a23 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 11 Oct 2021 13:50:11 +0100 Subject: [PATCH 2/5] Tidy up bot commands --- package.json | 4 + src/components/bot-commands.ts | 197 ++++++++++++++++++++++----------- tsconfig.json | 2 + yarn.lock | 61 ++++++++++ 4 files changed, 198 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index 24f42f46..9e2256fc 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "extend": "^3.0.2", "is-my-json-valid": "^2.20.5", "js-yaml": "^4.0.0", + "markdown-it": "^12.2.0", "matrix-appservice": "^0.8.0", "matrix-bot-sdk": "^0.6.0-beta.2", "matrix-js-sdk": "^9.9.0", @@ -41,6 +42,8 @@ "nopt": "^5.0.0", "p-queue": "^6.6.2", "prom-client": "^13.1.0", + "reflect-metadata": "^0.1.13", + "string-argv": "^0.3.1", "winston": "^3.3.3", "winston-daily-rotate-file": "^4.5.1" }, @@ -49,6 +52,7 @@ "@types/extend": "^3.0.1", "@types/jasmine": "^3.8.2", "@types/js-yaml": "^4.0.0", + "@types/markdown-it": "^12.2.3", "@types/nedb": "^1.8.11", "@types/node": "^12", "@types/nopt": "^3.0.29", diff --git a/src/components/bot-commands.ts b/src/components/bot-commands.ts index dee3d524..964026e3 100644 --- a/src/components/bot-commands.ts +++ b/src/components/bot-commands.ts @@ -1,92 +1,157 @@ -import markdown from "markdown-it"; +import "reflect-metadata"; +import Markdown from "markdown-it"; import stringArgv from "string-argv"; -import { MatrixMessageContent } from "./MatrixEvent"; +import { TextualMessageEventContent } from "matrix-bot-sdk"; -const md = new markdown(); +const md = new Markdown(); -export const botCommandSymbol = Symbol("botCommandMetadata"); -export function botCommand(prefix: string, help: string, requiredArgs: string[] = [], optionalArgs: string[] = [], includeUserId = false) { - return Reflect.metadata(botCommandSymbol, { - prefix, - help, - requiredArgs, - optionalArgs, - includeUserId, - }); +interface BotCommandEntry { + fn: BotCommandFunction; + requiredArgs: string[]; + optionalArgs?: string[]; } -type BotCommandFunction = (...args: string[]) => Promise<{status: boolean}>; - -export type BotCommands = {[prefix: string]: { - fn: BotCommandFunction, +interface BotCommandMetadata { + help: string; + name: string; requiredArgs: string[], - optionalArgs: string[], - includeUserId: boolean, -}}; + optionalArgs?: string[], +} + +const botCommandSymbol = Symbol("botCommandMetadata"); + +/** + * Expose a function as a command. The arugments of the function *must* take a single + * `CommandArguments` parameter. + * @param options Metadata about the command. + */ +export function BotCommand(options: BotCommandMetadata): void { + Reflect.metadata(botCommandSymbol, options); +} +export interface CommandArguments { + request: R; + /** + * Arguments supplied to the function, in the order of requiredArgs, optionalArgs. + */ + args: string[]; +} +export type BotCommandFunction = (args: CommandArguments) => Promise; /** - * Compile a prototype with a set of bot command functions (functions that are decorated with `botCommand`) - * @param prototype A class prototype containing a set of `botCommand` decorated functions. - * @returns + * Error to be thrown by commands that could not complete a request. */ -export function compileBotCommands(prototype: Record): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} { - let content = "Commands:\n"; - const botCommands: BotCommands = {}; - Object.getOwnPropertyNames(prototype).forEach(propetyKey => { - const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); - if (b) { +export class BotCommandError extends Error { + /** + * Construct a `BotCommandError` instance. + * @param error The inner error + * @param humanText The error to be shown to the user. + */ + constructor(error: Error|string, public readonly humanText: string) { + super(typeof error === "string" ? error : error.message); + if (typeof error !== "string") { + this.stack = error.stack; + } + } +} + +export class BotCommandHandler> { + /** + * The body of a Matrix message to be sent to users when they ask for help. + */ + public readonly helpMessage: TextualMessageEventContent; + private readonly botCommands: {[name: string]: BotCommandEntry}; + + /** + * Construct a new command helper. + * @param prototype The prototype of the class to bind to for bot commands. + * It should contain at least one `BotCommand`. + * @param instance The instance of the above prototype to bind to for function calls. + * @param prefix A prefix to be stripped from commands (useful if using multiple handlers). The prefix + * should **include** any whitspace E.g. `!irc `. + */ + constructor( + prototype: Record>, + instance: T, + private readonly prefix?: string) { + let content = "Commands:\n"; + const botCommands: {[prefix: string]: BotCommandEntry} = {}; + Object.getOwnPropertyNames(prototype).forEach(propetyKey => { + const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey) as BotCommandMetadata; + if (!b) { + // Not a bot command function. + return; + } const requiredArgs = b.requiredArgs.join(" "); - const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" "); - content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; + const optionalArgs = b.optionalArgs?.map((arg: string) => `[${arg}]`).join(" ") || ""; + content += ` - \`${this.prefix || ""}${b.name}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; // We know that this is safe. - botCommands[b.prefix as string] = { - fn: prototype[propetyKey], + botCommands[b.name as string] = { + fn: prototype[propetyKey].bind(instance), requiredArgs: b.requiredArgs, optionalArgs: b.optionalArgs, - includeUserId: b.includeUserId, }; + }); + if (Object.keys(botCommands).length === 0) { + throw Error('Prototype did not have any bot commands bound'); } - }); - return { - helpMessage: (cmdPrefix?: string) => ({ + this.helpMessage = { msgtype: "m.notice", body: content, - formatted_body: md.render(content).replace(/££PREFIX££/g, cmdPrefix || ""), + formatted_body: md.render(content), format: "org.matrix.custom.html" - }), - botCommands, + }; + this.botCommands = botCommands; } -} -export async function handleCommand( - userId: string, command: string, botCommands: BotCommands, obj: unknown, prefix?: string -): Promise<{error?: string, handled?: boolean, humanError?: string}> { - if (prefix) { - if (!command.startsWith(prefix)) { - return {handled: false}; - } - command = command.substring(prefix.length); - } - const parts = stringArgv(command); - for (let i = parts.length; i > 0; i--) { - const cmdPrefix = parts.slice(0, i).join(" ").toLowerCase(); - // We have a match! - const command = botCommands[prefix]; - if (command) { - if (command.requiredArgs.length > parts.length - i) { - return {error: "Missing args"}; + /** + * Process a command given by a user. + * @param userCommand The command string given by the user in it's entireity. Should be plain text. + * @throws With a `BotCommandError` if the command didn't contain enough arugments. Any errors thrown + * from the handler function will be passed through. + * @returns `true` if the command was handled by this handler instance. + */ + public async handleCommand( + userCommand: string, request: R, + ): Promise { + + // The processor may require a prefix (like `!github `). Check for it + // and strip away if found. + if (this.prefix) { + if (!userCommand.startsWith(this.prefix)) { + return false; } - const args = parts.slice(i); - if (command.includeUserId) { - args.splice(0,0, userId); + userCommand = userCommand.substring(this.prefix.length); + } + + const parts = stringArgv(userCommand); + + // This loop is a little complex: + // We want to find the most likely candiate for handling this command + // which we do so by joining together the whole command string and + // matching against any commands with the same name. + // If we can't find any, we strip away the last arg and try again. + // E.g. In the case of `add one + two`, we would search for: + // - `add one + two` + // - `add one +` + // - `add one` + // - `add` + // We iterate backwards so that command trees can be respected. + for (let i = parts.length; i > 0; i--) { + const cmdPrefix = parts.slice(0, i).join(" ").toLowerCase(); + const command = this.botCommands[cmdPrefix]; + if (!command) { + continue; } - try { - await botCommands[prefix].fn.apply(obj, args); - return {handled: true}; - } catch (ex) { - return {handled: true, error: ex.message, humanError: ex.humanError}; + // We have a match! + if (command.requiredArgs.length > parts.length - i) { + throw new BotCommandError("Missing arguments", "Missing required arguments for this command"); } + await command.fn({ + request, + args: parts.slice(i), + }); + return true; } + return false; } - return {handled: false}; } diff --git a/tsconfig.json b/tsconfig.json index c8254cc4..9e4781a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,8 @@ "outDir": "./lib", "composite": false, "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "esModuleInterop": true, "strictNullChecks": true, "skipLibCheck": true, /* matrix-js-sdk throws up errors */ diff --git a/yarn.lock b/yarn.lock index ec5f69e6..d3102f0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -368,6 +368,24 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== +"@types/linkify-it@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9" + integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA== + +"@types/markdown-it@^12.2.3": + version "12.2.3" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51" + integrity sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ== + dependencies: + "@types/linkify-it" "*" + "@types/mdurl" "*" + +"@types/mdurl@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" + integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -1236,6 +1254,11 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" + integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== + es6-error@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" @@ -2269,6 +2292,13 @@ lie@3.1.1: dependencies: immediate "~3.0.5" +linkify-it@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.3.tgz#a98baf44ce45a550efb4d49c769d07524cc2fa2e" + integrity sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ== + dependencies: + uc.micro "^1.0.1" + localforage@^1.3.0: version "1.9.0" resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1" @@ -2366,6 +2396,17 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +markdown-it@^12.2.0: + version "12.2.0" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.2.0.tgz#091f720fd5db206f80de7a8d1f1a7035fd0d38db" + integrity sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg== + dependencies: + argparse "^2.0.1" + entities "~2.1.0" + linkify-it "^3.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + marked@~2.0.3: version "2.0.7" resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.7.tgz#bc5b857a09071b48ce82a1f7304913a993d4b7d1" @@ -2421,6 +2462,11 @@ matrix-js-sdk@^9.9.0: request "^2.88.2" unhomoglyph "^1.0.6" +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -3015,6 +3061,11 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + regenerator-runtime@^0.13.4: version "0.13.7" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" @@ -3358,6 +3409,11 @@ steno@^0.4.1: dependencies: graceful-fs "^4.1.3" +string-argv@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" + integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== + string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -3651,6 +3707,11 @@ typescript@^4.2.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + uglify-js@^3.1.4: version "3.13.4" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.4.tgz#592588bb9f47ae03b24916e2471218d914955574" From e111f6b71207a87ed32993d50d8b2fc0da88a860 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 14 Oct 2021 23:33:59 +0100 Subject: [PATCH 3/5] Add bot commands to index --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 1afed51e..25e9ceea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ export * from "./components/bridge-context"; export * from "matrix-appservice"; export * from "./components/prometheusmetrics"; export * from "./components/agecounters"; +export * from "./components/bot-commands"; export * from "./components/membership-cache"; export * from "./components/membership-queue"; export * as Logging from "./components/logging"; From 2c53fbe014194b8a34cc679f89d4cfc3e19bf691 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 15 Oct 2021 16:22:32 +0100 Subject: [PATCH 4/5] Add spec for the BotCommandHandler --- spec/unit/bot-command.spec.ts | 127 +++++++++++++++++++++++++++++++++ src/components/bot-commands.ts | 31 ++++---- 2 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 spec/unit/bot-command.spec.ts diff --git a/spec/unit/bot-command.spec.ts b/spec/unit/bot-command.spec.ts new file mode 100644 index 00000000..315e6b47 --- /dev/null +++ b/spec/unit/bot-command.spec.ts @@ -0,0 +1,127 @@ +import "jasmine"; +import { ActivityTracker, BotCommand, BotCommandHandler, CommandArguments } from "../../src/index"; +import { WhoisInfo, PresenceEventContent, MatrixClient } from "matrix-bot-sdk"; + + +describe("BotCommands", () => { + it("does not construct without commands", () => { + expect(() => new BotCommandHandler({}, undefined)).toThrowError('Prototype did not have any bot commands bound'); + }); + + it("to process a simple command", async () => { + let called = false; + + class SimpleBotCommander { + @BotCommand({ help: "Some help", name: "simple-command"}) + public simpleCommand(data: CommandArguments): void { + called = true; + } + } + + const handler = new BotCommandHandler(new SimpleBotCommander()); + await handler.handleCommand("simple-command", null); + expect(called).toBeTrue(); + }); + + it("to process a simple command with augments", async () => { + let called: any = undefined; + + class SimpleBotCommander { + @BotCommand({ help: "Some help", name: "simple-command", requiredArgs: ["foo", "bar"]}) + public simpleCommand(data: CommandArguments<{some: string}>): void { + called = data; + } + } + + const handler = new BotCommandHandler(new SimpleBotCommander()); + await handler.handleCommand("simple-command abc def", {some: "context"}); + const expectedResult = { + args: ["abc", "def"], + request: { + some: "context", + } + } + expect(called).toEqual(expectedResult); + }); + + it("to process a simple command with optional parameters", async () => { + let called: any = undefined; + + class SimpleBotCommander { + @BotCommand({ help: "Some help", name: "simple-command", requiredArgs: ["foo", "bar"], optionalArgs: ["baz"]}) + public simpleCommand(data: CommandArguments<{some: string}>): void { + called = data; + } + } + + const handler = new BotCommandHandler(new SimpleBotCommander()); + await handler.handleCommand("simple-command abc def", {some: "context"}); + expect(called).toEqual({ + args: ["abc", "def"], + request: { + some: "context", + } + }); + + await handler.handleCommand("simple-command abc def ghi", {some: "context"}); + expect(called).toEqual({ + args: ["abc", "def", "ghi"], + request: { + some: "context", + } + }); + }); + + it("to process a command and a subcommand", async () => { + let commandCalled: any = undefined; + let subCommandCalled: any = undefined; + + class SimpleBotCommander { + @BotCommand({ help: "Some help", name: "simple-command", requiredArgs: ["foo"]}) + public simpleCommand(data: CommandArguments<{some: string}>): void { + commandCalled = data; + } + @BotCommand({ help: "Some help", name: "simple-command with-a-subcommand", requiredArgs: ["foo"]}) + public simpleSubCommand(data: CommandArguments<{some: string}>): void { + subCommandCalled = data; + } + } + + const handler = new BotCommandHandler(new SimpleBotCommander()); + await handler.handleCommand("simple-command abc", undefined); + expect(commandCalled).toEqual({ + args: ["abc"], + request: undefined, + }); + + await handler.handleCommand("simple-command with-a-subcommand def", undefined); + expect(subCommandCalled).toEqual({ + args: ["def"], + request: undefined, + }); + }); + + it("should produce useful help output", async () => { + class SimpleBotCommander { + @BotCommand({ help: "No help at all", name: "very-simple-command"}) + public verySimpleCommand(data: CommandArguments<{some: string}>): void { + } + @BotCommand({ help: "Some help", name: "simple-command", requiredArgs: ["requiredArg1"]}) + public simpleCommand(data: CommandArguments<{some: string}>): void { + } + @BotCommand({ help: "Even better help", name: "simple-command with-a-subcommand", requiredArgs: ["requiredArg1"], optionalArgs: ["optionalArg1"]}) + public simpleSubCommand(data: CommandArguments<{some: string}>): void { + } + } + + const handler = new BotCommandHandler(new SimpleBotCommander()); + expect(handler.helpMessage.format).toEqual("org.matrix.custom.html"); + expect(handler.helpMessage.msgtype).toEqual("m.notice"); + expect(handler.helpMessage.body).toContain("Commands:"); + // Rough formatting match + expect(handler.helpMessage.body).toContain("- `very-simple-command` - No help at all"); + expect(handler.helpMessage.body).toContain("- `simple-command` requiredArg1 - Some help"); + expect(handler.helpMessage.body).toContain("- `simple-command with-a-subcommand` requiredArg1 [optionalArg1] - Even better help"); + }); +}); + diff --git a/src/components/bot-commands.ts b/src/components/bot-commands.ts index 964026e3..612d94fb 100644 --- a/src/components/bot-commands.ts +++ b/src/components/bot-commands.ts @@ -7,14 +7,14 @@ const md = new Markdown(); interface BotCommandEntry { fn: BotCommandFunction; - requiredArgs: string[]; + requiredArgs?: string[]; optionalArgs?: string[]; } interface BotCommandMetadata { help: string; name: string; - requiredArgs: string[], + requiredArgs?: string[], optionalArgs?: string[], } @@ -25,8 +25,9 @@ const botCommandSymbol = Symbol("botCommandMetadata"); * `CommandArguments` parameter. * @param options Metadata about the command. */ -export function BotCommand(options: BotCommandMetadata): void { - Reflect.metadata(botCommandSymbol, options); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function BotCommand(options: BotCommandMetadata): any { + return Reflect.metadata(botCommandSymbol, options); } export interface CommandArguments { request: R; @@ -54,7 +55,9 @@ export class BotCommandError extends Error { } } -export class BotCommandHandler> { +// Typescript doesn't understand that classes are indexable. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class BotCommandHandler, R extends Record|null|undefined> { /** * The body of a Matrix message to be sent to users when they ask for help. */ @@ -70,23 +73,25 @@ export class BotCommandHandler> { * should **include** any whitspace E.g. `!irc `. */ constructor( - prototype: Record>, instance: T, private readonly prefix?: string) { let content = "Commands:\n"; const botCommands: {[prefix: string]: BotCommandEntry} = {}; - Object.getOwnPropertyNames(prototype).forEach(propetyKey => { - const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey) as BotCommandMetadata; + const proto = Object.getPrototypeOf(instance); + Object.getOwnPropertyNames(proto).forEach(propetyKey => { + const b = Reflect.getMetadata(botCommandSymbol, instance, propetyKey) as BotCommandMetadata; if (!b) { // Not a bot command function. return; } - const requiredArgs = b.requiredArgs.join(" "); - const optionalArgs = b.optionalArgs?.map((arg: string) => `[${arg}]`).join(" ") || ""; - content += ` - \`${this.prefix || ""}${b.name}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; + const optionalArgs = b.optionalArgs?.map((arg: string) => `[${arg}]`) || []; + const args = [...(b.requiredArgs || []), ...optionalArgs].join(" "); + + content += ` - \`${this.prefix || ""}${b.name}\`${args && " "}${args} - ${b.help}\n`; // We know that this is safe. + const fn = instance[propetyKey]; botCommands[b.name as string] = { - fn: prototype[propetyKey].bind(instance), + fn: fn.bind(instance), requiredArgs: b.requiredArgs, optionalArgs: b.optionalArgs, }; @@ -143,7 +148,7 @@ export class BotCommandHandler> { continue; } // We have a match! - if (command.requiredArgs.length > parts.length - i) { + if ((command.requiredArgs?.length || 0) > parts.length - i) { throw new BotCommandError("Missing arguments", "Missing required arguments for this command"); } await command.fn({ From f0fb0b23e8afb0b9f598984ff8682c07cbbca1d5 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 15 Oct 2021 16:27:57 +0100 Subject: [PATCH 5/5] changelog --- changelog.d/363.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/363.feature diff --git a/changelog.d/363.feature b/changelog.d/363.feature new file mode 100644 index 00000000..4dd631e3 --- /dev/null +++ b/changelog.d/363.feature @@ -0,0 +1 @@ +Add `BotCommandHandler` component to handle bot commands in rooms. \ No newline at end of file