First stage of commands refactor.

This commit is contained in:
gnuxie
2022-12-09 20:08:34 +00:00
parent dafbd20393
commit 63e0a24e6e
6 changed files with 299 additions and 27 deletions
+6 -1
View File
@@ -39,6 +39,7 @@ import { ProtectionManager } from "./protections/ProtectionManager";
import { RoomMemberManager } from "./RoomMembers";
import ProtectedRoomsConfig from "./ProtectedRoomsConfig";
import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter";
import { MatrixCommandTable } from "./commands/MatrixInterfaceCommand";
export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@@ -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 {MatrixSendClient} client
@@ -171,6 +174,7 @@ export class Mjolnir {
public readonly ruleServer: RuleServer | null,
) {
this.protectedRoomsConfig = new ProtectedRoomsConfig(client);
this.matrixCommandTable = new MatrixCommandTable(config.commands.features);
// Setup bot.
@@ -207,7 +211,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);
}
});
+69
View File
@@ -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 {
name: string,
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 {
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",
description: "Requires that the mjolnir account has Synapse admin"
});
+8 -6
View File
@@ -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));
if (command) {
return await command.invoke(mjolnir, roomId, event, parts);
}
// Help menu
const menu = "" +
"!mjolnir - Print status information\n" +
+170
View File
@@ -0,0 +1,170 @@
/*
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";
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<Parameters<ExecutorType>>;
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.
* 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>>
) {
}
/**
* 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);
const executorResult: ReturnType<ExecutorType> = await this.applicationCommand.executor.apply(this, parseResults);
await this.renderer.apply(this, [...args.slice(0, -1), executorResult]);
}
}
/**
* 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[];
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]);
}
}
+42 -20
View File
@@ -19,6 +19,8 @@ import PolicyList from "../models/PolicyList";
import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk";
import { RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule";
import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand";
import { defineApplicationCommand } from "./ApplicationCommand";
import { defineMatrixInterfaceCommand } from "./MatrixInterfaceCommand";
interface Arguments {
list: PolicyList | null;
@@ -113,25 +115,32 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni
};
}
const BAN_COMMAND = defineApplicationCommand([], async (list: PolicyList, ruleType: string, entity: string, reason: string): Promise<void> => {
await list.banEntity(ruleType, entity, reason);
});
// !mjolnir ban <shortcode> <user|server|room> <glob> [reason] [--force]
export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const bits = await parseArguments(roomId, event, mjolnir, parts);
if (!bits) return; // error already handled
defineMatrixInterfaceCommand(["ban"],
async function (mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise<[PolicyList, string, string, string]> {
const bits = await parseArguments(roomId, event, mjolnir, parts);
if (bits === null) {
// FIXME
throw new Error("Couldn't parse arguments FIXME - parser needs to be rewritten to reject nulls");
}
return [bits.list!, bits.ruleType!, bits.entity!, bits.reason!];
},
BAN_COMMAND,
async function (mjolnir: Mjolnir, commandRoomId: string, event: any, result: void) {
await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');
}
);
await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
}
// !mjolnir unban <shortcode> <user|server|room> <glob> [apply:t/f]
export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const bits = await parseArguments(roomId, event, mjolnir, parts);
if (!bits) return; // error already handled
await bits.list!.unbanEntity(bits.ruleType!, bits.entity);
const UNBAN_COMMAND = defineApplicationCommand([], async (mjolnir: Mjolnir, list: PolicyList, ruleType: string, entity: string, reason: string): Promise<void> => {
await list.unbanEntity(ruleType, entity);
const unbanUserFromRooms = async () => {
const rule = new MatrixGlob(bits.entity);
await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity);
const rule = new MatrixGlob(entity);
await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + entity);
let unbannedSomeone = false;
for (const protectedRoomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined);
@@ -159,14 +168,27 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
}
};
if (USER_RULE_TYPES.includes(bits.ruleType!)) {
mjolnir.unlistedUserRedactionHandler.removeUser(bits.entity);
if (bits.reason === 'true') {
if (USER_RULE_TYPES.includes(ruleType)) {
mjolnir.unlistedUserRedactionHandler.removeUser(entity);
if (reason === 'true') {
await unbanUserFromRooms();
} else {
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", "Running unban without `unban <list> <user> true` will not override existing room level bans");
}
}
})
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
}
// !mjolnir unban <shortcode> <user|server|room> <glob> [apply:t/f]
defineMatrixInterfaceCommand(["unban"],
async function (mjolnir: Mjolnir, roomId: string, event: any, parts: string[]): Promise<[Mjolnir, PolicyList, string, string, string]> {
const bits = await parseArguments(roomId, event, mjolnir, parts);
if (bits === null) {
throw new Error("Couldn't parse arguments FIXME");
}
return [mjolnir, bits.list!, bits.ruleType!, bits.entity!, bits.reason!];
},
UNBAN_COMMAND,
async function (mjolnir: Mjolnir, commandRoomId: string, event: any, result: void) {
await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');
}
);
+4
View File
@@ -73,6 +73,7 @@ export interface IConfig {
allowNoPrefix: boolean;
additionalPrefixes: string[];
confirmWildcardBan: boolean;
features: string[];
};
protections: {
wordlist: {
@@ -151,6 +152,9 @@ const defaultConfig: IConfig = {
allowNoPrefix: false,
additionalPrefixes: [],
confirmWildcardBan: true,
features: [
"synapse admin",
]
},
protections: {
wordlist: {