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

First attempt at refactoring commands. #403

Open
wants to merge 3 commits into
base: main
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
7 changes: 6 additions & 1 deletion src/Mjolnir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import ManagementRoomOutput from "./ManagementRoomOutput";
import { ProtectionManager } from "./protections/ProtectionManager";
import { RoomMemberManager } from "./RoomMembers";
import ProtectedRoomsConfig from "./ProtectedRoomsConfig";
import { MatrixCommandTable } from "./commands/MatrixInterfaceCommand";

export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
Expand Down Expand Up @@ -86,6 +87,8 @@ export class Mjolnir {
*/
public readonly reportManager: ReportManager;

private readonly matrixCommandTable: MatrixCommandTable;

/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client
Expand Down Expand Up @@ -168,6 +171,7 @@ export class Mjolnir {
public readonly ruleServer: RuleServer | null,
) {
this.protectedRoomsConfig = new ProtectedRoomsConfig(client);
this.matrixCommandTable = new MatrixCommandTable(config.commands.features);

// Setup bot.

Expand Down Expand Up @@ -204,7 +208,8 @@ export class Mjolnir {
LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`);

await client.sendReadReceipt(roomId, event['event_id']);
return handleCommand(roomId, event, this);

return handleCommand(roomId, event, this, this.matrixCommandTable);
Copy link
Contributor

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 of matrixCommandTable?

Copy link
Contributor Author

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

}
});

Expand Down
69 changes: 69 additions & 0 deletions src/commands/ApplicationCommand.ts
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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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 key.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The 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
});
14 changes: 8 additions & 6 deletions src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);

Expand All @@ -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') {
Expand Down Expand Up @@ -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));
Copy link
Contributor

Choose a reason for hiding this comment

The 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 handleCommand? Sounds like a good idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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" +
Expand Down
189 changes: 189 additions & 0 deletions src/commands/MatrixInterfaceCommand.ts
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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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[];
Copy link
Contributor

Choose a reason for hiding this comment

The 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]);
}
}
Loading