mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 05:36:06 +00:00
WIP
This commit is contained in:
parent
da4edb8854
commit
0cc6862e11
330
src/commands/Command.ts
Normal file
330
src/commands/Command.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -38,10 +38,9 @@ import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } fro
|
|||||||
import { execSetPowerLevelCommand } from "./SetPowerLevelCommand";
|
import { execSetPowerLevelCommand } from "./SetPowerLevelCommand";
|
||||||
import { execShutdownRoomCommand } from "./ShutdownRoomCommand";
|
import { execShutdownRoomCommand } from "./ShutdownRoomCommand";
|
||||||
import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands";
|
import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands";
|
||||||
import { execKickCommand } from "./KickCommand";
|
|
||||||
import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand";
|
import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand";
|
||||||
import { execSinceCommand } from "./SinceCommand";
|
import { execSinceCommand } from "./SinceCommand";
|
||||||
import { Lexer } from "./Lexer";
|
import { Lexer } from "./Command";
|
||||||
|
|
||||||
export const COMMAND_PREFIX = "!mjolnir";
|
export const COMMAND_PREFIX = "!mjolnir";
|
||||||
|
|
||||||
|
@ -17,63 +17,80 @@ limitations under the License.
|
|||||||
import { Mjolnir } from "../Mjolnir";
|
import { Mjolnir } from "../Mjolnir";
|
||||||
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
|
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
|
||||||
import config from "../config";
|
import config from "../config";
|
||||||
|
import { Command, Lexer } from "./Command";
|
||||||
|
import { Token } from "tokenizr";
|
||||||
|
|
||||||
// !mjolnir kick <user|filter> [room] [reason]
|
export class KickCommand implements Command {
|
||||||
export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
command: "kick";
|
||||||
let force = false;
|
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];
|
// Parse command-line args.
|
||||||
let rooms = [...Object.keys(mjolnir.protectedRooms)];
|
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") {
|
const ARG_FORCE = "--force";
|
||||||
force = true;
|
let hasForce = !config.commands.confirmWildcardBan;
|
||||||
parts.pop();
|
if (reason.endsWith(ARG_FORCE)) {
|
||||||
}
|
reason = reason.slice(undefined, ARG_FORCE.length);
|
||||||
|
hasForce = true;
|
||||||
if (config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) {
|
}
|
||||||
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
|
if (reason.trim().length == 0) {
|
||||||
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
|
reason = "<no reason supplied>";
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
reason = parts.slice(reasonIndex).join(' ') || '<no reason supplied>';
|
|
||||||
}
|
|
||||||
if (!reason) reason = '<none supplied>';
|
|
||||||
|
|
||||||
for (const protectedRoomId of rooms) {
|
// Validate args.
|
||||||
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
|
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) {
|
// Compute list of rooms.
|
||||||
const victim = member.membershipFor;
|
let rooms;
|
||||||
|
if (roomAliasOrIDToken) {
|
||||||
|
rooms = [await mjolnir.client.resolveRoom(roomAliasOrIDToken.text)];
|
||||||
|
} else {
|
||||||
|
rooms = [...Object.keys(mjolnir.protectedRooms)];
|
||||||
|
}
|
||||||
|
|
||||||
if (kickRule.test(victim)) {
|
// Proceed.
|
||||||
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
|
const kickRule = new MatrixGlob(globUserID);
|
||||||
|
|
||||||
if (!config.noop) {
|
for (const protectedRoomId of rooms) {
|
||||||
try {
|
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
|
||||||
await mjolnir.taskQueue.push(async () => {
|
|
||||||
return mjolnir.client.kickUser(victim, protectedRoomId, reason);
|
for (const member of members) {
|
||||||
});
|
const victim = member.membershipFor;
|
||||||
} catch (e) {
|
|
||||||
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
|
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'], '✅');
|
|
||||||
}
|
}
|
||||||
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,7 +19,7 @@ import { LogLevel, LogService, RichReply } from "matrix-bot-sdk";
|
|||||||
import { htmlEscape } from "../utils";
|
import { htmlEscape } from "../utils";
|
||||||
import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts";
|
import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts";
|
||||||
import { Join } from "../RoomMembers";
|
import { Join } from "../RoomMembers";
|
||||||
import { Lexer } from "./Lexer";
|
import { Lexer } from "./Command";
|
||||||
|
|
||||||
const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage();
|
const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage();
|
||||||
const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE);
|
const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE);
|
||||||
|
Loading…
Reference in New Issue
Block a user