-
Notifications
You must be signed in to change notification settings - Fork 56
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
First attempt at refactoring commands. #403
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
/* | ||
Copyright 2022 The Matrix.org Foundation C.I.C. | ||
|
||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
|
||
http://www.apache.org/licenses/LICENSE-2.0 | ||
|
||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
*/ | ||
|
||
/** | ||
* A feature that an application supports. | ||
* Used by ApplicationCommands as required feature flags they depend on to function. | ||
*/ | ||
export interface ApplicationFeature { | ||
Yoric marked this conversation as resolved.
Show resolved
Hide resolved
|
||
name: string, | ||
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. Doc: Is this a human-readable name? A unique key? If the latter, possibly rename to |
||
description: string, | ||
} | ||
|
||
|
||
/** | ||
* These are features that have been defined using `defineApplicationCommand`. | ||
* you can access them using `getApplicationFeature`. | ||
*/ | ||
const APPLICATION_FEATURES = new Map<string/*feature name*/, ApplicationFeature>(); | ||
|
||
export function defineApplicationFeature(feature: ApplicationFeature): void { | ||
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. Nit: Doc needed |
||
if (APPLICATION_FEATURES.has(feature.name)) { | ||
throw new TypeError(`Application feature has already been defined ${feature.name}`); | ||
} | ||
APPLICATION_FEATURES.set(feature.name, feature); | ||
} | ||
|
||
export function getApplicationFeature(name: string): ApplicationFeature|undefined { | ||
return APPLICATION_FEATURES.get(name); | ||
} | ||
|
||
export class ApplicationCommand<ExecutorType extends (...args: any) => Promise<any>> { | ||
constructor( | ||
public readonly requiredFeatures: ApplicationFeature[], | ||
public readonly executor: ExecutorType | ||
) { | ||
} | ||
} | ||
|
||
export function defineApplicationCommand<ExecutorType extends (...args: any) => Promise<any>>( | ||
requiredFeatureNames: string[], | ||
executor: ExecutorType) { | ||
const features = requiredFeatureNames.map(name => { | ||
const feature = getApplicationFeature(name); | ||
if (feature) { | ||
return feature | ||
} else { | ||
throw new TypeError(`Can't find a feature called ${name}`); | ||
} | ||
}) | ||
return new ApplicationCommand<ExecutorType>(features, executor); | ||
} | ||
|
||
defineApplicationFeature({ | ||
name: "synapse admin", | ||
Yoric marked this conversation as resolved.
Show resolved
Hide resolved
|
||
description: "Requires that the mjolnir account has Synapse admin" | ||
Yoric marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,7 +16,7 @@ limitations under the License. | |
|
||
import { Mjolnir } from "../Mjolnir"; | ||
import { execStatusCommand } from "./StatusCommand"; | ||
import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand"; | ||
import "./UnbanBanCommand"; | ||
import { execDumpRulesCommand, execRulesMatchingCommand } from "./DumpRulesCommand"; | ||
import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk"; | ||
import { htmlEscape } from "../utils"; | ||
|
@@ -42,11 +42,12 @@ import { execKickCommand } from "./KickCommand"; | |
import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; | ||
import { parse as tokenize } from "shell-quote"; | ||
import { execSinceCommand } from "./SinceCommand"; | ||
import { MatrixCommandTable } from "./MatrixInterfaceCommand"; | ||
|
||
|
||
export const COMMAND_PREFIX = "!mjolnir"; | ||
|
||
export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) { | ||
export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir, commandTable: MatrixCommandTable) { | ||
const cmd = event['content']['body']; | ||
const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0); | ||
|
||
|
@@ -57,10 +58,6 @@ export async function handleCommand(roomId: string, event: { content: { body: st | |
try { | ||
if (parts.length === 1 || parts[1] === 'status') { | ||
return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); | ||
} else if (parts[1] === 'ban' && parts.length > 2) { | ||
return await execBanCommand(roomId, event, mjolnir, parts); | ||
} else if (parts[1] === 'unban' && parts.length > 2) { | ||
return await execUnbanCommand(roomId, event, mjolnir, parts); | ||
} else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') { | ||
return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) | ||
} else if (parts[1] === 'rules') { | ||
|
@@ -126,6 +123,11 @@ export async function handleCommand(roomId: string, event: { content: { body: st | |
} else if (parts[1] === 'make' && parts[2] === 'admin' && parts.length > 3) { | ||
return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); | ||
} else { | ||
const command = commandTable.findAMatchingCommand(parts.slice(1)); | ||
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. So the idea is to apply it gradually, by progressively moving commands from 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. Yep |
||
if (command) { | ||
return await command.invoke(mjolnir, roomId, event, parts); | ||
} | ||
|
||
// Help menu | ||
const menu = "" + | ||
"!mjolnir - Print status information\n" + | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
/* | ||
Copyright 2022 The Matrix.org Foundation C.I.C. | ||
|
||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
|
||
http://www.apache.org/licenses/LICENSE-2.0 | ||
|
||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
*/ | ||
|
||
import { Mjolnir } from "../Mjolnir"; | ||
import { ApplicationCommand, ApplicationFeature, getApplicationFeature } from "./ApplicationCommand"; | ||
import { ValidationError, ValidationResult } from "./Validation"; | ||
import { RichReply, LogService } from "matrix-bot-sdk"; | ||
|
||
type CommandLookupEntry = Map<string|symbol, CommandLookupEntry|MatrixInterfaceCommand<BaseFunction>>; | ||
|
||
type BaseFunction = (...args: any) => Promise<any>; | ||
const FLATTENED_MATRIX_COMMANDS = new Set<MatrixInterfaceCommand<BaseFunction>>(); | ||
const THIS_COMMAND_SYMBOL = Symbol("thisCommand"); | ||
|
||
type ParserSignature<ExecutorType extends (...args: any) => Promise<any>> = ( | ||
this: MatrixInterfaceCommand<ExecutorType>, | ||
mjolnir: Mjolnir, | ||
roomId: string, | ||
event: any, | ||
parts: string[]) => Promise<ValidationResult<Parameters<ExecutorType>, ValidationError>>; | ||
|
||
type RendererSignature<ExecutorReturnType extends Promise<any>> = ( | ||
mjolnir: Mjolnir, | ||
commandRoomId: string, | ||
event: any, | ||
result: Awaited<ExecutorReturnType>) => Promise<void>; | ||
|
||
/** | ||
* A command that interfaces with a user via Matrix. | ||
* The command wraps an `ApplicationCommand` to make it available to Matrix. | ||
* To do this. A MatrixInterfaceCommand needs to parse an event and the context | ||
* that it was received in with a `parser` and then render the result | ||
* of an `ApplicationCommand` with a `renderer`, which really means | ||
* rendering and sending a matrix event. | ||
* | ||
* Note, matrix interface command can be multi step ie ask for confirmation. | ||
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. Nit: Generally speaking, documentation for a class should start with a description of what the class does, before any note. Here and for other classes. |
||
* From the perspective here, confirmation should be a distinct thing that happens | ||
* before the interface command is invoked. | ||
* | ||
* When confirmation is required in the middle of a traditional command ie preview kick | ||
* the preview command should be a distinct command. | ||
*/ | ||
class MatrixInterfaceCommand<ExecutorType extends (...args: any) => Promise<any>> { | ||
constructor( | ||
public readonly commandParts: string[], | ||
private readonly parser: ParserSignature<ExecutorType>, | ||
public readonly applicationCommand: ApplicationCommand<ExecutorType>, | ||
private readonly renderer: RendererSignature<ReturnType<ExecutorType>>, | ||
private readonly validationErrorHandler?: (mjolnir: Mjolnir, roomId: string, event: any, validationError: ValidationError) => Promise<void> | ||
) { | ||
|
||
} | ||
|
||
/** | ||
* Parse the context required by the command, call the associated application command and then render the result to a Matrix room. | ||
* The arguments to invoke will be given directly to the parser. | ||
* The executor of the application command will then be applied to whatever is returned by the parser. | ||
* Then the renderer will be applied to the same arguments given to the parser (so it knows which matrix room to respond to) | ||
* along with the result of the executor. | ||
* @param args These will be the arguments to the parser function. | ||
*/ | ||
public async invoke(...args: Parameters<ParserSignature<ExecutorType>>): Promise<void> { | ||
const parseResults = await this.parser(...args); | ||
if (parseResults.isErr()) { | ||
this.reportValidationError.apply(this, [...args.slice(0, -1), parseResults.err]); | ||
return; | ||
} | ||
const executorResult: ReturnType<ExecutorType> = await this.applicationCommand.executor.apply(this, parseResults.ok); | ||
await this.renderer.apply(this, [...args.slice(0, -1), executorResult]); | ||
} | ||
|
||
private async reportValidationError(mjolnir: Mjolnir, roomId: string, event: any, validationError: ValidationError): Promise<void> { | ||
LogService.info("MatrixInterfaceCommand", `User input validation error when parsing command ${this.commandParts}: ${validationError.message}`); | ||
if (this.validationErrorHandler) { | ||
await this.validationErrorHandler.apply(this, arguments); | ||
return; | ||
} | ||
const replyMessage = validationError.message; | ||
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage); | ||
reply["msgtype"] = "m.notice"; | ||
await mjolnir.client.sendMessage(roomId, reply); | ||
} | ||
} | ||
|
||
/** | ||
* Define a command to be interfaced via Matrix. | ||
* @param commandParts constant parts used to discriminate the command e.g. "ban" or "config" "get" | ||
* @param parser A function that parses a Matrix Event from a room to be able to invoke an ApplicationCommand. | ||
* @param applicationCommmand The ApplicationCommand this is an interface wrapper for. | ||
* @param renderer Render the result of the application command back to a room. | ||
*/ | ||
export function defineMatrixInterfaceCommand<ExecutorType extends (...args: any) => Promise<any>>( | ||
commandParts: string[], | ||
parser: ParserSignature<ExecutorType>, | ||
applicationCommmand: ApplicationCommand<ExecutorType>, | ||
renderer: RendererSignature<ReturnType<ExecutorType>>) { | ||
FLATTENED_MATRIX_COMMANDS.add( | ||
new MatrixInterfaceCommand( | ||
commandParts, | ||
parser, | ||
applicationCommmand, | ||
renderer | ||
) | ||
); | ||
} | ||
|
||
|
||
/** | ||
* This can be used by mjolnirs or an appservice bot. | ||
*/ | ||
export class MatrixCommandTable { | ||
public readonly features: ApplicationFeature[]; | ||
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. Is this the list of active features? |
||
private readonly flattenedCommands: Set<MatrixInterfaceCommand<BaseFunction>>; | ||
private readonly commands: CommandLookupEntry = new Map(); | ||
|
||
constructor(featureNames: string[]) { | ||
this.features = featureNames.map(name => { | ||
const feature = getApplicationFeature(name); | ||
if (feature) { | ||
return feature | ||
} else { | ||
throw new TypeError(`Couldn't find feature with name ${name}`) | ||
} | ||
}); | ||
|
||
const commandHasFeatures = (command: ApplicationCommand<BaseFunction>) => { | ||
return command.requiredFeatures.every(feature => this.features.includes(feature)) | ||
} | ||
this.flattenedCommands = new Set([...FLATTENED_MATRIX_COMMANDS].filter(interfaceCommand => commandHasFeatures(interfaceCommand.applicationCommand))); | ||
[...this.flattenedCommands].forEach(this.internCommand, this); | ||
} | ||
|
||
public findAMatchingCommand(parts: string[]) { | ||
const getCommand = (table: CommandLookupEntry): undefined|MatrixInterfaceCommand<BaseFunction> => { | ||
const command = table.get(THIS_COMMAND_SYMBOL); | ||
if (command instanceof Map) { | ||
throw new TypeError("There is an implementation bug, only commands should be stored under the command symbol"); | ||
} | ||
return command; | ||
}; | ||
const tableHelper = (table: CommandLookupEntry, nextParts: string[]): undefined|MatrixInterfaceCommand<BaseFunction> => { | ||
if (nextParts.length === 0) { | ||
// Then they might be using something like "!mjolnir status" | ||
return getCommand(table); | ||
} | ||
const entry = table.get(nextParts.shift()!); | ||
if (!entry) { | ||
// The reason there's no match is because this is the command arguments, rather than subcommand notation. | ||
return getCommand(table); | ||
} else { | ||
if (!(entry instanceof Map)) { | ||
throw new TypeError("There is an implementation bug, only maps should be stored under arbirtrary keys"); | ||
} | ||
return tableHelper(entry, nextParts); | ||
} | ||
}; | ||
return tableHelper(this.commands, [...parts]); | ||
} | ||
|
||
private internCommand(command: MatrixInterfaceCommand<BaseFunction>) { | ||
const internCommandHelper = (table: CommandLookupEntry, commandParts: string[]): void => { | ||
if (commandParts.length === 0) { | ||
if (table.has(THIS_COMMAND_SYMBOL)) { | ||
throw new TypeError(`There is already a command for ${JSON.stringify(commandParts)}`) | ||
} | ||
table.set(THIS_COMMAND_SYMBOL, command); | ||
} else { | ||
const nextTable = new Map(); | ||
table.set(commandParts.shift()!, nextTable); | ||
internCommandHelper(nextTable, commandParts); | ||
} | ||
} | ||
|
||
internCommandHelper(this.commands, [...command.commandParts]); | ||
} | ||
} |
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.
Nit: Shouldn't
handleCommand
be a method ofmatrixCommandTable
?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.
Yep, but can't be until we have moved all the commands to the new table