|
1 | | -import markdown from "markdown-it"; |
| 1 | +import "reflect-metadata"; |
| 2 | +import Markdown from "markdown-it"; |
2 | 3 | import stringArgv from "string-argv"; |
3 | | -import { MatrixMessageContent } from "./MatrixEvent"; |
| 4 | +import { TextualMessageEventContent } from "matrix-bot-sdk"; |
4 | 5 |
|
5 | | -const md = new markdown(); |
| 6 | +const md = new Markdown(); |
6 | 7 |
|
7 | | -export const botCommandSymbol = Symbol("botCommandMetadata"); |
8 | | -export function botCommand(prefix: string, help: string, requiredArgs: string[] = [], optionalArgs: string[] = [], includeUserId = false) { |
9 | | - return Reflect.metadata(botCommandSymbol, { |
10 | | - prefix, |
11 | | - help, |
12 | | - requiredArgs, |
13 | | - optionalArgs, |
14 | | - includeUserId, |
15 | | - }); |
| 8 | +interface BotCommandEntry<R> { |
| 9 | + fn: BotCommandFunction<R>; |
| 10 | + requiredArgs: string[]; |
| 11 | + optionalArgs?: string[]; |
16 | 12 | } |
17 | 13 |
|
18 | | -type BotCommandFunction = (...args: string[]) => Promise<{status: boolean}>; |
19 | | - |
20 | | -export type BotCommands = {[prefix: string]: { |
21 | | - fn: BotCommandFunction, |
| 14 | +interface BotCommandMetadata { |
| 15 | + help: string; |
| 16 | + name: string; |
22 | 17 | requiredArgs: string[], |
23 | | - optionalArgs: string[], |
24 | | - includeUserId: boolean, |
25 | | -}}; |
| 18 | + optionalArgs?: string[], |
| 19 | +} |
| 20 | + |
| 21 | +const botCommandSymbol = Symbol("botCommandMetadata"); |
| 22 | + |
| 23 | +/** |
| 24 | + * Expose a function as a command. The arugments of the function *must* take a single |
| 25 | + * `CommandArguments` parameter. |
| 26 | + * @param options Metadata about the command. |
| 27 | + */ |
| 28 | +export function BotCommand(options: BotCommandMetadata): void { |
| 29 | + Reflect.metadata(botCommandSymbol, options); |
| 30 | +} |
| 31 | +export interface CommandArguments<R> { |
| 32 | + request: R; |
| 33 | + /** |
| 34 | + * Arguments supplied to the function, in the order of requiredArgs, optionalArgs. |
| 35 | + */ |
| 36 | + args: string[]; |
| 37 | +} |
| 38 | +export type BotCommandFunction<R> = (args: CommandArguments<R>) => Promise<void>; |
26 | 39 |
|
27 | 40 | /** |
28 | | - * Compile a prototype with a set of bot command functions (functions that are decorated with `botCommand`) |
29 | | - * @param prototype A class prototype containing a set of `botCommand` decorated functions. |
30 | | - * @returns |
| 41 | + * Error to be thrown by commands that could not complete a request. |
31 | 42 | */ |
32 | | -export function compileBotCommands(prototype: Record<string, BotCommandFunction>): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} { |
33 | | - let content = "Commands:\n"; |
34 | | - const botCommands: BotCommands = {}; |
35 | | - Object.getOwnPropertyNames(prototype).forEach(propetyKey => { |
36 | | - const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); |
37 | | - if (b) { |
| 43 | +export class BotCommandError extends Error { |
| 44 | + /** |
| 45 | + * Construct a `BotCommandError` instance. |
| 46 | + * @param error The inner error |
| 47 | + * @param humanText The error to be shown to the user. |
| 48 | + */ |
| 49 | + constructor(error: Error|string, public readonly humanText: string) { |
| 50 | + super(typeof error === "string" ? error : error.message); |
| 51 | + if (typeof error !== "string") { |
| 52 | + this.stack = error.stack; |
| 53 | + } |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +export class BotCommandHandler<T, R extends Record<string, unknown>> { |
| 58 | + /** |
| 59 | + * The body of a Matrix message to be sent to users when they ask for help. |
| 60 | + */ |
| 61 | + public readonly helpMessage: TextualMessageEventContent; |
| 62 | + private readonly botCommands: {[name: string]: BotCommandEntry<R>}; |
| 63 | + |
| 64 | + /** |
| 65 | + * Construct a new command helper. |
| 66 | + * @param prototype The prototype of the class to bind to for bot commands. |
| 67 | + * It should contain at least one `BotCommand`. |
| 68 | + * @param instance The instance of the above prototype to bind to for function calls. |
| 69 | + * @param prefix A prefix to be stripped from commands (useful if using multiple handlers). The prefix |
| 70 | + * should **include** any whitspace E.g. `!irc `. |
| 71 | + */ |
| 72 | + constructor( |
| 73 | + prototype: Record<string, BotCommandFunction<R>>, |
| 74 | + instance: T, |
| 75 | + private readonly prefix?: string) { |
| 76 | + let content = "Commands:\n"; |
| 77 | + const botCommands: {[prefix: string]: BotCommandEntry<R>} = {}; |
| 78 | + Object.getOwnPropertyNames(prototype).forEach(propetyKey => { |
| 79 | + const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey) as BotCommandMetadata; |
| 80 | + if (!b) { |
| 81 | + // Not a bot command function. |
| 82 | + return; |
| 83 | + } |
38 | 84 | const requiredArgs = b.requiredArgs.join(" "); |
39 | | - const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" "); |
40 | | - content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; |
| 85 | + const optionalArgs = b.optionalArgs?.map((arg: string) => `[${arg}]`).join(" ") || ""; |
| 86 | + content += ` - \`${this.prefix || ""}${b.name}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; |
41 | 87 | // We know that this is safe. |
42 | | - botCommands[b.prefix as string] = { |
43 | | - fn: prototype[propetyKey], |
| 88 | + botCommands[b.name as string] = { |
| 89 | + fn: prototype[propetyKey].bind(instance), |
44 | 90 | requiredArgs: b.requiredArgs, |
45 | 91 | optionalArgs: b.optionalArgs, |
46 | | - includeUserId: b.includeUserId, |
47 | 92 | }; |
| 93 | + }); |
| 94 | + if (Object.keys(botCommands).length === 0) { |
| 95 | + throw Error('Prototype did not have any bot commands bound'); |
48 | 96 | } |
49 | | - }); |
50 | | - return { |
51 | | - helpMessage: (cmdPrefix?: string) => ({ |
| 97 | + this.helpMessage = { |
52 | 98 | msgtype: "m.notice", |
53 | 99 | body: content, |
54 | | - formatted_body: md.render(content).replace(/££PREFIX££/g, cmdPrefix || ""), |
| 100 | + formatted_body: md.render(content), |
55 | 101 | format: "org.matrix.custom.html" |
56 | | - }), |
57 | | - botCommands, |
| 102 | + }; |
| 103 | + this.botCommands = botCommands; |
58 | 104 | } |
59 | | -} |
60 | 105 |
|
61 | | -export async function handleCommand( |
62 | | - userId: string, command: string, botCommands: BotCommands, obj: unknown, prefix?: string |
63 | | -): Promise<{error?: string, handled?: boolean, humanError?: string}> { |
64 | | - if (prefix) { |
65 | | - if (!command.startsWith(prefix)) { |
66 | | - return {handled: false}; |
67 | | - } |
68 | | - command = command.substring(prefix.length); |
69 | | - } |
70 | | - const parts = stringArgv(command); |
71 | | - for (let i = parts.length; i > 0; i--) { |
72 | | - const cmdPrefix = parts.slice(0, i).join(" ").toLowerCase(); |
73 | | - // We have a match! |
74 | | - const command = botCommands[prefix]; |
75 | | - if (command) { |
76 | | - if (command.requiredArgs.length > parts.length - i) { |
77 | | - return {error: "Missing args"}; |
| 106 | + /** |
| 107 | + * Process a command given by a user. |
| 108 | + * @param userCommand The command string given by the user in it's entireity. Should be plain text. |
| 109 | + * @throws With a `BotCommandError` if the command didn't contain enough arugments. Any errors thrown |
| 110 | + * from the handler function will be passed through. |
| 111 | + * @returns `true` if the command was handled by this handler instance. |
| 112 | + */ |
| 113 | + public async handleCommand( |
| 114 | + userCommand: string, request: R, |
| 115 | + ): Promise<boolean> { |
| 116 | + |
| 117 | + // The processor may require a prefix (like `!github `). Check for it |
| 118 | + // and strip away if found. |
| 119 | + if (this.prefix) { |
| 120 | + if (!userCommand.startsWith(this.prefix)) { |
| 121 | + return false; |
78 | 122 | } |
79 | | - const args = parts.slice(i); |
80 | | - if (command.includeUserId) { |
81 | | - args.splice(0,0, userId); |
| 123 | + userCommand = userCommand.substring(this.prefix.length); |
| 124 | + } |
| 125 | + |
| 126 | + const parts = stringArgv(userCommand); |
| 127 | + |
| 128 | + // This loop is a little complex: |
| 129 | + // We want to find the most likely candiate for handling this command |
| 130 | + // which we do so by joining together the whole command string and |
| 131 | + // matching against any commands with the same name. |
| 132 | + // If we can't find any, we strip away the last arg and try again. |
| 133 | + // E.g. In the case of `add one + two`, we would search for: |
| 134 | + // - `add one + two` |
| 135 | + // - `add one +` |
| 136 | + // - `add one` |
| 137 | + // - `add` |
| 138 | + // We iterate backwards so that command trees can be respected. |
| 139 | + for (let i = parts.length; i > 0; i--) { |
| 140 | + const cmdPrefix = parts.slice(0, i).join(" ").toLowerCase(); |
| 141 | + const command = this.botCommands[cmdPrefix]; |
| 142 | + if (!command) { |
| 143 | + continue; |
82 | 144 | } |
83 | | - try { |
84 | | - await botCommands[prefix].fn.apply(obj, args); |
85 | | - return {handled: true}; |
86 | | - } catch (ex) { |
87 | | - return {handled: true, error: ex.message, humanError: ex.humanError}; |
| 145 | + // We have a match! |
| 146 | + if (command.requiredArgs.length > parts.length - i) { |
| 147 | + throw new BotCommandError("Missing arguments", "Missing required arguments for this command"); |
88 | 148 | } |
| 149 | + await command.fn({ |
| 150 | + request, |
| 151 | + args: parts.slice(i), |
| 152 | + }); |
| 153 | + return true; |
89 | 154 | } |
| 155 | + return false; |
90 | 156 | } |
91 | | - return {handled: false}; |
92 | 157 | } |
0 commit comments