Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bot command processing #363

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/363.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `BotCommandHandler` component to handle bot commands in rooms.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@
"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",
"nedb": "^1.8.0",
"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"
},
Expand All @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions spec/unit/bot-command.spec.ts
Original file line number Diff line number Diff line change
@@ -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<null>): 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");
});
});

162 changes: 162 additions & 0 deletions src/components/bot-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import "reflect-metadata";
import Markdown from "markdown-it";
import stringArgv from "string-argv";
import { TextualMessageEventContent } from "matrix-bot-sdk";

const md = new Markdown();

interface BotCommandEntry<R> {
fn: BotCommandFunction<R>;
requiredArgs?: string[];
optionalArgs?: string[];
}

interface BotCommandMetadata {
help: string;
name: string;
requiredArgs?: string[],
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.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function BotCommand(options: BotCommandMetadata): any {
return Reflect.metadata(botCommandSymbol, options);
}
export interface CommandArguments<R> {
request: R;
/**
* Arguments supplied to the function, in the order of requiredArgs, optionalArgs.
*/
args: string[];
}
export type BotCommandFunction<R> = (args: CommandArguments<R>) => Promise<void>;

/**
* Error to be thrown by commands that could not complete a request.
*/
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;
}
}
}

// Typescript doesn't understand that classes are indexable.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class BotCommandHandler<T extends Record<string, any>, R extends Record<string, unknown>|null|undefined> {
/**
* 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<R>};

/**
* 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 `.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* should **include** any whitspace E.g. `!irc `.
* should **include** any whitespace E.g. `!irc `.

*/
constructor(
instance: T,
private readonly prefix?: string) {
let content = "Commands:\n";
const botCommands: {[prefix: string]: BotCommandEntry<R>} = {};
const proto = Object.getPrototypeOf(instance);
Object.getOwnPropertyNames(proto).forEach(propetyKey => {
const b = Reflect.getMetadata(botCommandSymbol, instance, propetyKey) as BotCommandMetadata;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this? Metadata or a BotCommand?
Please pick a better variable name.

if (!b) {
// Not a bot command function.
return;
}
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];
Comment on lines +81 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo

Suggested change
Object.getOwnPropertyNames(proto).forEach(propetyKey => {
const b = Reflect.getMetadata(botCommandSymbol, instance, propetyKey) as BotCommandMetadata;
if (!b) {
// Not a bot command function.
return;
}
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];
Object.getOwnPropertyNames(proto).forEach(propertyKey => {
const b = Reflect.getMetadata(botCommandSymbol, instance, propertyKey) as BotCommandMetadata;
if (!b) {
// Not a bot command function.
return;
}
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[propertyKey];

botCommands[b.name as string] = {
fn: fn.bind(instance),
requiredArgs: b.requiredArgs,
optionalArgs: b.optionalArgs,
};
});
if (Object.keys(botCommands).length === 0) {
throw Error('Prototype did not have any bot commands bound');
}
this.helpMessage = {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
format: "org.matrix.custom.html"
};
this.botCommands = botCommands;
}

/**
* 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<boolean> {

// 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;
}
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;
}
// We have a match!
if ((command.requiredArgs?.length || 0) > 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;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading