This commit is contained in:
David Teller 2022-07-22 11:33:49 +02:00
parent da4edb8854
commit 0cc6862e11
5 changed files with 395 additions and 159 deletions

330
src/commands/Command.ts Normal file
View File

@ -0,0 +1,330 @@
import { Mjolnir } from '../Mjolnir';
import Tokenizr from "tokenizr";
// For some reason, different versions of TypeScript seem
// to disagree on how to import Tokenizr
import * as TokenizrModule from "tokenizr";
import { htmlEscape, parseDuration } from "../utils";
import { COMMAND_PREFIX } from './CommandHandler';
import config from '../config';
import { LogService, RichReply } from 'matrix-bot-sdk';
const TokenizrClass = Tokenizr || TokenizrModule;
const WHITESPACE = /\s+/;
const COMMAND = /[a-zA-Z_]+/;
const USER_ID = /@[a-zA-Z0-9_.=\-/]+:\S+/;
const GLOB_USER_ID = /@[a-zA-Z0-9_.=\-?*/]+:\S+/;
const ROOM_ID = /![a-zA-Z0-9_.=\-/]+:\S+/;
const ROOM_ALIAS = /#[a-zA-Z0-9_.=\-/]+:\S+/;
const ROOM_ALIAS_OR_ID = /[#!][a-zA-Z0-9_.=\-/]+:\S+/;
const INT = /[+-]?[0-9]+/;
const STRING = /"((?:\\"|[^\r\n])*)"/;
const DATE_OR_DURATION = /(?:"([^"]+)")|([^"]\S+)/;
const STAR = /\*/;
const ETC = /.*$/;
/**
* A lexer for command parsing.
*
* Recommended use is `lexer.token("state")`.
*/
export class Lexer extends TokenizrClass {
constructor(string: string) {
super();
// Ignore whitespace.
this.rule(WHITESPACE, (ctx) => {
ctx.ignore()
})
// Identifier rules, used e.g. for subcommands `get`, `set` ...
this.rule("command", COMMAND, (ctx) => {
ctx.accept("command");
});
// Users
this.rule("userID", USER_ID, (ctx) => {
ctx.accept("userID");
});
this.rule("globUserID", GLOB_USER_ID, (ctx) => {
ctx.accept("globUserID");
});
// Rooms
this.rule("roomID", ROOM_ID, (ctx) => {
ctx.accept("roomID");
});
this.rule("roomAlias", ROOM_ALIAS, (ctx) => {
ctx.accept("roomAlias");
});
this.rule("roomAliasOrID", ROOM_ALIAS_OR_ID, (ctx) => {
ctx.accept("roomAliasOrID");
});
// Numbers.
this.rule("int", INT, (ctx, match) => {
ctx.accept("int", parseInt(match[0]))
});
// Quoted strings.
this.rule("string", STRING, (ctx, match) => {
ctx.accept("string", match[1].replace(/\\"/g, "\""))
});
// Dates and durations.
this.rule("dateOrDuration", DATE_OR_DURATION, (ctx, match) => {
let content = match[1] || match[2];
let date = new Date(content);
if (!date || Number.isNaN(date.getDate())) {
let duration = parseDuration(content);
if (!duration || Number.isNaN(duration)) {
ctx.reject();
} else {
ctx.accept("duration", duration);
}
} else {
ctx.accept("date", date);
}
});
// Jokers.
this.rule("STAR", STAR, (ctx) => {
ctx.accept("STAR");
});
// Everything left in the string.
this.rule("ETC", ETC, (ctx, match) => {
ctx.accept("ETC", match[0].trim());
});
this.input(string);
}
public token(state?: string): TokenizrModule.Token {
if (typeof state === "string") {
this.state(state);
}
return super.token();
}
}
export interface Command {
/**
* The name for the command, e.g. "get".
*/
readonly command: string;
/**
* A human-readable help for the command.
*/
readonly helpDescription: string;
/**
* A human-readable description for the arguments.
*/
readonly helpArgs: string;
/**
* Execute the command.
*
* @param mjolnir The owning instance of Mjolnir.
* @param roomID The command room. Used mainly to display responses.
* @param lexer The lexer holding the command-line. Both `!mjolnir` (or equivalent) and `this.command`
* have already been consumed. This `Command` is responsible for validating the contents
* of this command-line.
* @param event The original event. Used mainly to post response.
*/
exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise<void>;
}
export class CommandManager {
/**
* All commands, in the order of registration.
*/
private readonly commands: Command[];
/**
* A map of command string (e.g. `status`) to `Command`.
*/
private readonly commandsPerCommand: Map<string, Command>;
/**
* The command used when no command is given.
*/
private defaultCommand: Command | null;
/**
* The command used to display the help message.
*/
private readonly helpCommand: Command;
/**
* The callback used to process messages.
*/
private readonly onMessageCallback: (roomId: string, event: any) => Promise<void>;
/**
* All the prefixes this bot needs to answer to.
*/
private PREFIXES: string[] = [];
/**
* Register a new command.
*/
public add(command: Command, options: { isDefault?: boolean } = {}) {
const isDefault = options?.isDefault || false;
this.commands.push(command);
this.commandsPerCommand.set(command.command, command);
if (isDefault) {
this.defaultCommand = command;
}
}
public constructor(
/**
* A list of command-prefixes to answer to, e.g. `mjolnir`.
*/
public readonly prefixes: string[],
private readonly managementRoomId: string,
private readonly mjolnir: Mjolnir
) {
this.onMessageCallback = this.handleMessage.bind(this);
// Prepare prefixes.
// Prepare help message.
const commands = this.commands;
class HelpCommand implements Command {
command: "help";
helpDescription: "This help message";
// For the time being we don't support `!mjolnir help <command>`.
helpArgs: "";
async exec(mjolnir: Mjolnir, roomID: string, lexer: Lexer, event: any): Promise<void> {
// Inject the help at the end of commands.
let allCommands = [...commands, this];
let prefixes = [];
let width = 0;
// Compute width to display the help properly.
for (let command of allCommands) {
let prefix = `${this.c} ${command.command} ${command.helpArgs} `;
width = Math.max(width, prefix.length);
prefixes.push(prefix);
}
// Now build actual help message.
let lines = [];
for (let i = 0; i < prefixes.length; ++i) {
let prefix = prefixes[i].padEnd(width);
let line = `${prefix} - ${allCommands[i].helpDescription}`;
lines.push(line);
}
let message = lines.join("\n");
const html = `<b>Mjolnir help:</b><br><pre><code>${htmlEscape(message)}</code></pre>`;
const text = `Mjolnir help:\n${message}`;
const reply = RichReply.createFor(roomID, event, text, html);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomID, reply);
}
}
this.helpCommand = new HelpCommand();
}
public async init() {
// Initialize the list of prefixes to which the bot will respond.
// We perform lowercase-comparison,
const userId = await (await this.mjolnir.client.getUserId()).toLowerCase();
const profile = await this.mjolnir.client.getUserProfile(userId);
const localpart = userId.split(':')[0].substring(1);
this.PREFIXES = [
COMMAND_PREFIX.toLowerCase(),
localpart + ":",
localpart + " ",
];
const displayName = profile['displayName']?.toLowerCase();
if (displayName) {
this.PREFIXES.push(displayName + ":");
this.PREFIXES.push(displayName + " ");
}
for (let additionalPrefix of config.commands.additionalPrefixes || []) {
const lowercase = additionalPrefix.toLowerCase();
for (let prefix of [
`!${lowercase}`,
`${lowercase}:`,
`!${lowercase} `
]) {
this.PREFIXES.push(prefix);
}
}
if (config.commands.allowNoPrefix) {
this.PREFIXES.push("!");
}
// Initialize listening to messages.
this.mjolnir.client.on("room.message", this.onMessageCallback);
}
public async dispose() {
this.mjolnir.client.removeListener("room.message", this.onMessageCallback);
}
/**
* Handle messages in any room to which we belong.
*
* @param roomId The room in which the message is received.
* @param event An untrusted event.
*/
private async handleMessage(roomId: string, event: any) {
try {
if (roomId != this.managementRoomId) {
// Security-critical: We only ever accept commands from our management room.
return;
}
const content = event['content'];
if (!content || content['msgtype'] !== "m.text" || content['body']) {
return;
}
const body = content['body'];
const lowercaseBody = body.toLowerCase();
const prefixUsed = this.PREFIXES.find(p => lowercaseBody.startsWith(p));
if (!prefixUsed) {
// Not a message for the bot.
return;
}
// Consume the prefix.
// Note: We're making the assumption that uppercase and lowercase have the
// same length. This might not be true in some locales.
const line = body.substring(prefixUsed.length).trim();
LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`);
/* No need to await */ this.mjolnir.client.sendReadReceipt(roomId, event['event_id']);
// Lookup the command. As some commands contain spaces, we cannot
// simply use the lexer and a lookup in a map.
let cmd = line.length === 0 ?
this.defaultCommand
: this.commands.find(cmd => line.startsWith(cmd.command));
let lexer;
if (cmd) {
lexer = new Lexer(line.substring(cmd.command.length).trim());
} else {
// Fallback to help.
// Don't attempt to parse line.
cmd = this.helpCommand;
lexer = new Lexer("");
}
await cmd.exec(this.mjolnir, roomId, lexer, event);
} catch (ex) {
LogService.error("Mjolnir", `Error while processing command: ${ex}`);
const text = `There was an error processing your command: ${htmlEscape(ex.message)}`;
const reply = RichReply.createFor(roomId, event, text, text);
reply["msgtype"] = "m.notice";
await this.mjolnir.client.sendMessage(roomId, reply);
}
}
}

View File

@ -38,10 +38,9 @@ import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } fro
import { execSetPowerLevelCommand } from "./SetPowerLevelCommand";
import { execShutdownRoomCommand } from "./ShutdownRoomCommand";
import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands";
import { execKickCommand } from "./KickCommand";
import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand";
import { execSinceCommand } from "./SinceCommand";
import { Lexer } from "./Lexer";
import { Lexer } from "./Command";
export const COMMAND_PREFIX = "!mjolnir";

View File

@ -17,63 +17,80 @@ limitations under the License.
import { Mjolnir } from "../Mjolnir";
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import config from "../config";
import { Command, Lexer } from "./Command";
import { Token } from "tokenizr";
// !mjolnir kick <user|filter> [room] [reason]
export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
let force = false;
export class KickCommand implements Command {
command: "kick";
helpArgs: "<glob> [room alias/ID] [reason]";
helpDescription: "Kicks a user or all of those matching a glob in a particular room or all protected rooms";
async exec(mjolnir: Mjolnir, commandRoomId: string, lexer: Lexer, event: any): Promise<void> {
const glob = parts[2];
let rooms = [...Object.keys(mjolnir.protectedRooms)];
// Parse command-line args.
let globUserID = lexer.token("globUserID").text;
let roomAliasOrIDToken: Token | null = lexer.alternatives(
() => lexer.token("roomAliasOrID"),
() => null,
);
let reason = lexer.alternatives(
() => lexer.token("string"),
() => lexer.token("ETC")
).text as string;
if (parts[parts.length - 1] === "--force") {
force = true;
parts.pop();
}
if (config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) {
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
return;
}
const kickRule = new MatrixGlob(glob);
let reason: string | undefined;
if (parts.length > 3) {
let reasonIndex = 3;
if (parts[3].startsWith("#") || parts[3].startsWith("!")) {
rooms = [await mjolnir.client.resolveRoom(parts[3])];
reasonIndex = 4;
const ARG_FORCE = "--force";
let hasForce = !config.commands.confirmWildcardBan;
if (reason.endsWith(ARG_FORCE)) {
reason = reason.slice(undefined, ARG_FORCE.length);
hasForce = true;
}
if (reason.trim().length == 0) {
reason = "<no reason supplied>";
}
reason = parts.slice(reasonIndex).join(' ') || '<no reason supplied>';
}
if (!reason) reason = '<none supplied>';
for (const protectedRoomId of rooms) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
// Validate args.
if (!hasForce && /[*?]/.test(globUserID)) {
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
const reply = RichReply.createFor(commandRoomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(commandRoomId, reply);
return;
}
for (const member of members) {
const victim = member.membershipFor;
// Compute list of rooms.
let rooms;
if (roomAliasOrIDToken) {
rooms = [await mjolnir.client.resolveRoom(roomAliasOrIDToken.text)];
} else {
rooms = [...Object.keys(mjolnir.protectedRooms)];
}
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
// Proceed.
const kickRule = new MatrixGlob(globUserID);
if (!config.noop) {
try {
await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(victim, protectedRoomId, reason);
});
} catch (e) {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
for (const protectedRoomId of rooms) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
for (const member of members) {
const victim = member.membershipFor;
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) {
try {
await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(victim, protectedRoomId, reason);
});
} catch (e) {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
}
}
}
await mjolnir.client.unstableApis.addReactionToEvent(commandRoomId, event['event_id'], '✅');
}
return mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
}

View File

@ -1,110 +0,0 @@
import Tokenizr from "tokenizr";
// For some reason, different versions of TypeScript seem
// to disagree on how to import Tokenizr
import * as TokenizrModule from "tokenizr";
import { parseDuration } from "../utils";
const TokenizrClass = Tokenizr || TokenizrModule;
const WHITESPACE = /\s+/;
const COMMAND = /![a-zA-Z_]+/;
const IDENTIFIER = /[a-zA-Z_]+/;
const USER_ID = /@[a-zA-Z0-9_.=\-/]+:\S+/;
const GLOB_USER_ID = /@[a-zA-Z0-9_.=\-?*/]+:\S+/;
const ROOM_ID = /![a-zA-Z0-9_.=\-/]+:\S+/;
const ROOM_ALIAS = /#[a-zA-Z0-9_.=\-/]+:\S+/;
const ROOM_ALIAS_OR_ID = /[#!][a-zA-Z0-9_.=\-/]+:\S+/;
const INT = /[+-]?[0-9]+/;
const STRING = /"((?:\\"|[^\r\n])*)"/;
const DATE_OR_DURATION = /(?:"([^"]+)")|([^"]\S+)/;
const STAR = /\*/;
const ETC = /.*$/;
/**
* A lexer for command parsing.
*
* Recommended use is `lexer.token("state")`.
*/
export class Lexer extends TokenizrClass {
constructor(string: string) {
super();
// Ignore whitespace.
this.rule(WHITESPACE, (ctx) => {
ctx.ignore()
})
// Command rules, e.g. `!mjolnir`
this.rule("command", COMMAND, (ctx) => {
ctx.accept("command");
});
// Identifier rules, used e.g. for subcommands `get`, `set` ...
this.rule("id", IDENTIFIER, (ctx) => {
ctx.accept("id");
});
// Users
this.rule("userID", USER_ID, (ctx) => {
ctx.accept("userID");
});
this.rule("globUserID", GLOB_USER_ID, (ctx) => {
ctx.accept("globUserID");
});
// Rooms
this.rule("roomID", ROOM_ID, (ctx) => {
ctx.accept("roomID");
});
this.rule("roomAlias", ROOM_ALIAS, (ctx) => {
ctx.accept("roomAlias");
});
this.rule("roomAliasOrID", ROOM_ALIAS_OR_ID, (ctx) => {
ctx.accept("roomAliasOrID");
});
// Numbers.
this.rule("int", INT, (ctx, match) => {
ctx.accept("int", parseInt(match[0]))
});
// Quoted strings.
this.rule("string", STRING, (ctx, match) => {
ctx.accept("string", match[1].replace(/\\"/g, "\""))
});
// Dates and durations.
this.rule("dateOrDuration", DATE_OR_DURATION, (ctx, match) => {
let content = match[1] || match[2];
let date = new Date(content);
if (!date || Number.isNaN(date.getDate())) {
let duration = parseDuration(content);
if (!duration || Number.isNaN(duration)) {
ctx.reject();
} else {
ctx.accept("duration", duration);
}
} else {
ctx.accept("date", date);
}
});
// Jokers.
this.rule("STAR", STAR, (ctx) => {
ctx.accept("STAR");
});
// Everything left in the string.
this.rule("ETC", ETC, (ctx, match) => {
ctx.accept("ETC", match[0].trim());
});
this.input(string);
}
public token(state?: string): TokenizrModule.Token {
if (typeof state === "string") {
this.state(state);
}
return super.token();
}
}

View File

@ -19,7 +19,7 @@ import { LogLevel, LogService, RichReply } from "matrix-bot-sdk";
import { htmlEscape } from "../utils";
import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts";
import { Join } from "../RoomMembers";
import { Lexer } from "./Lexer";
import { Lexer } from "./Command";
const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage();
const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE);