-
Notifications
You must be signed in to change notification settings - Fork 72
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
Half-Shot
wants to merge
5
commits into
develop
Choose a base branch
from
hs/bot-commands
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add `BotCommandHandler` component to handle bot commands in rooms. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 `. | ||||||||||||||||||||||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||||||||||||||||||||||
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; | ||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is this? Metadata or a BotCommand? |
||||||||||||||||||||||||||||||||||||||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. typo
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
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; | ||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.