Skip to content

Commit

Permalink
Add spec for the BotCommandHandler
Browse files Browse the repository at this point in the history
  • Loading branch information
Half-Shot committed Oct 15, 2021
1 parent e111f6b commit 2c53fbe
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 13 deletions.
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");
});
});

31 changes: 18 additions & 13 deletions src/components/bot-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ const md = new Markdown();

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

interface BotCommandMetadata {
help: string;
name: string;
requiredArgs: string[],
requiredArgs?: string[],
optionalArgs?: string[],
}

Expand All @@ -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<R> {
request: R;
Expand Down Expand Up @@ -54,7 +55,9 @@ export class BotCommandError extends Error {
}
}

export class BotCommandHandler<T, R extends Record<string, unknown>> {
// 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.
*/
Expand All @@ -70,23 +73,25 @@ export class BotCommandHandler<T, R extends Record<string, unknown>> {
* should **include** any whitspace E.g. `!irc `.
*/
constructor(
prototype: Record<string, BotCommandFunction<R>>,
instance: T,
private readonly prefix?: string) {
let content = "Commands:\n";
const botCommands: {[prefix: string]: BotCommandEntry<R>} = {};
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,
};
Expand Down Expand Up @@ -143,7 +148,7 @@ export class BotCommandHandler<T, R extends Record<string, unknown>> {
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({
Expand Down

0 comments on commit 2c53fbe

Please sign in to comment.