Skip to content

Commit fe93c22

Browse files
committed
Tidy up bot commands
1 parent bc8309b commit fe93c22

File tree

4 files changed

+198
-66
lines changed

4 files changed

+198
-66
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@
3434
"extend": "^3.0.2",
3535
"is-my-json-valid": "^2.20.5",
3636
"js-yaml": "^4.0.0",
37+
"markdown-it": "^12.2.0",
3738
"matrix-appservice": "^0.8.0",
3839
"matrix-bot-sdk": "^0.6.0-beta.2",
3940
"matrix-js-sdk": "^9.9.0",
4041
"nedb": "^1.8.0",
4142
"nopt": "^5.0.0",
4243
"p-queue": "^6.6.2",
4344
"prom-client": "^13.1.0",
45+
"reflect-metadata": "^0.1.13",
46+
"string-argv": "^0.3.1",
4447
"winston": "^3.3.3",
4548
"winston-daily-rotate-file": "^4.5.1"
4649
},
@@ -49,6 +52,7 @@
4952
"@types/extend": "^3.0.1",
5053
"@types/jasmine": "^3.8.2",
5154
"@types/js-yaml": "^4.0.0",
55+
"@types/markdown-it": "^12.2.3",
5256
"@types/nedb": "^1.8.11",
5357
"@types/node": "^12",
5458
"@types/nopt": "^3.0.29",

src/components/bot-commands.ts

Lines changed: 131 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,157 @@
1-
import markdown from "markdown-it";
1+
import "reflect-metadata";
2+
import Markdown from "markdown-it";
23
import stringArgv from "string-argv";
3-
import { MatrixMessageContent } from "./MatrixEvent";
4+
import { TextualMessageEventContent } from "matrix-bot-sdk";
45

5-
const md = new markdown();
6+
const md = new Markdown();
67

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[];
1612
}
1713

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;
2217
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>;
2639

2740
/**
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.
3142
*/
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+
}
3884
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`;
4187
// 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),
4490
requiredArgs: b.requiredArgs,
4591
optionalArgs: b.optionalArgs,
46-
includeUserId: b.includeUserId,
4792
};
93+
});
94+
if (Object.keys(botCommands).length === 0) {
95+
throw Error('Prototype did not have any bot commands bound');
4896
}
49-
});
50-
return {
51-
helpMessage: (cmdPrefix?: string) => ({
97+
this.helpMessage = {
5298
msgtype: "m.notice",
5399
body: content,
54-
formatted_body: md.render(content).replace(/££PREFIX££/g, cmdPrefix || ""),
100+
formatted_body: md.render(content),
55101
format: "org.matrix.custom.html"
56-
}),
57-
botCommands,
102+
};
103+
this.botCommands = botCommands;
58104
}
59-
}
60105

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;
78122
}
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;
82144
}
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");
88148
}
149+
await command.fn({
150+
request,
151+
args: parts.slice(i),
152+
});
153+
return true;
89154
}
155+
return false;
90156
}
91-
return {handled: false};
92157
}

tsconfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"outDir": "./lib",
1111
"composite": false,
1212
"strict": true,
13+
"experimentalDecorators": true,
14+
"emitDecoratorMetadata": true,
1315
"esModuleInterop": true,
1416
"strictNullChecks": true,
1517
"skipLibCheck": true, /* matrix-js-sdk throws up errors */

yarn.lock

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,24 @@
368368
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"
369369
integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
370370

371+
"@types/linkify-it@*":
372+
version "3.0.2"
373+
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9"
374+
integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==
375+
376+
"@types/markdown-it@^12.2.3":
377+
version "12.2.3"
378+
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51"
379+
integrity sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==
380+
dependencies:
381+
"@types/linkify-it" "*"
382+
"@types/mdurl" "*"
383+
384+
"@types/mdurl@*":
385+
version "1.0.2"
386+
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9"
387+
integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
388+
371389
"@types/mime@^1":
372390
version "1.3.2"
373391
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
@@ -1236,6 +1254,11 @@ entities@^2.0.0:
12361254
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
12371255
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
12381256

1257+
entities@~2.1.0:
1258+
version "2.1.0"
1259+
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
1260+
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
1261+
12391262
es6-error@^4.0.1:
12401263
version "4.1.1"
12411264
resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d"
@@ -2269,6 +2292,13 @@ [email protected]:
22692292
dependencies:
22702293
immediate "~3.0.5"
22712294

2295+
linkify-it@^3.0.1:
2296+
version "3.0.3"
2297+
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.3.tgz#a98baf44ce45a550efb4d49c769d07524cc2fa2e"
2298+
integrity sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==
2299+
dependencies:
2300+
uc.micro "^1.0.1"
2301+
22722302
localforage@^1.3.0:
22732303
version "1.9.0"
22742304
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1"
@@ -2366,6 +2396,17 @@ make-error@^1.1.1:
23662396
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
23672397
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
23682398

2399+
markdown-it@^12.2.0:
2400+
version "12.2.0"
2401+
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.2.0.tgz#091f720fd5db206f80de7a8d1f1a7035fd0d38db"
2402+
integrity sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg==
2403+
dependencies:
2404+
argparse "^2.0.1"
2405+
entities "~2.1.0"
2406+
linkify-it "^3.0.1"
2407+
mdurl "^1.0.1"
2408+
uc.micro "^1.0.5"
2409+
23692410
marked@~2.0.3:
23702411
version "2.0.7"
23712412
resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.7.tgz#bc5b857a09071b48ce82a1f7304913a993d4b7d1"
@@ -2421,6 +2462,11 @@ matrix-js-sdk@^9.9.0:
24212462
request "^2.88.2"
24222463
unhomoglyph "^1.0.6"
24232464

2465+
mdurl@^1.0.1:
2466+
version "1.0.1"
2467+
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
2468+
integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=
2469+
24242470
24252471
version "0.3.0"
24262472
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -3015,6 +3061,11 @@ rechoir@^0.6.2:
30153061
dependencies:
30163062
resolve "^1.1.6"
30173063

3064+
reflect-metadata@^0.1.13:
3065+
version "0.1.13"
3066+
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
3067+
integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==
3068+
30183069
regenerator-runtime@^0.13.4:
30193070
version "0.13.7"
30203071
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
@@ -3358,6 +3409,11 @@ steno@^0.4.1:
33583409
dependencies:
33593410
graceful-fs "^4.1.3"
33603411

3412+
string-argv@^0.3.1:
3413+
version "0.3.1"
3414+
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
3415+
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
3416+
33613417
string-width@^1.0.1:
33623418
version "1.0.2"
33633419
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
@@ -3651,6 +3707,11 @@ typescript@^4.2.3:
36513707
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
36523708
integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
36533709

3710+
uc.micro@^1.0.1, uc.micro@^1.0.5:
3711+
version "1.0.6"
3712+
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
3713+
integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
3714+
36543715
uglify-js@^3.1.4:
36553716
version "3.13.4"
36563717
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.4.tgz#592588bb9f47ae03b24916e2471218d914955574"

0 commit comments

Comments
 (0)