mirror of
https://github.com/the-draupnir-project/Draupnir.git
synced 2026-08-29 07:08:50 +00:00
First stage of commands refactor.
This commit is contained in:
+6
-1
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
@@ -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" +
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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'], '✅');
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user