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

Refactor command-line handling #335

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"start:dev": "yarn build && node --async-stack-traces lib/index.js",
"test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts",
"test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",
"test:YORIC": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/roomMembersTest.ts\"",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oopsie

"test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts",
"version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py"
},
Expand Down Expand Up @@ -45,7 +46,7 @@
"jsdom": "^16.6.0",
"matrix-bot-sdk": "^0.5.19",
"parse-duration": "^1.0.2",
"shell-quote": "^1.7.3"
"tokenizr": "^1.6.7"
},
"engines": {
"node": ">=16.0.0"
Expand Down
95 changes: 50 additions & 45 deletions src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import { execRedactCommand } from "./RedactCommand";
import { execImportCommand } from "./ImportCommand";
import { execSetDefaultListCommand } from "./SetDefaultBanListCommand";
import { execDeactivateCommand } from "./DeactivateCommand";
import { execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection,
execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection } from "./ProtectionsCommands";
import {
execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection,
execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection
} from "./ProtectionsCommands";
import { execListProtectedRooms } from "./ListProtectedRoomsCommand";
import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand";
import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand";
Expand All @@ -38,90 +40,93 @@ import { execShutdownRoomCommand } from "./ShutdownRoomCommand";
import { execAddAliasCommand, execMoveAliasCommand, execRemoveAliasCommand, execResolveCommand } from "./AliasCommands";
import { execKickCommand } from "./KickCommand";
import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand";
import { parse as tokenize } from "shell-quote";
import { execSinceCommand } from "./SinceCommand";

import { Lexer } from "./Lexer";

export const COMMAND_PREFIX = "!mjolnir";

export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) {
const cmd = event['content']['body'];
const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0);
const line = event['content']['body'];
const parts = line.trim().split(' ').filter(p => p.trim().length > 0);

// A shell-style parser that can parse `"a b c"` (with quotes) as a single argument.
// We do **not** want to parse `#` as a comment start, though.
const tokens = tokenize(cmd.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2);
const lexer = new Lexer(line);
lexer.token("command"); // Consume `!mjolnir`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens when the lexer fails then? how different is it from what happens now?

// Extract command.
const cmd = lexer.alternatives(
() => lexer.token("id").text,
() => null
);

try {
if (parts.length === 1 || parts[1] === 'status') {
if (cmd === null || cmd === 'status') {
return await execStatusCommand(roomId, event, mjolnir, parts.slice(2));
} else if (parts[1] === 'ban' && parts.length > 2) {
} else if (cmd === 'ban' && parts.length > 2) {
return await execBanCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'unban' && parts.length > 2) {
} else if (cmd === 'unban' && parts.length > 2) {
return await execUnbanCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') {
} else if (cmd === 'rules' && parts.length === 4 && parts[2] === 'matching') {
return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3])
} else if (parts[1] === 'rules') {
} else if (cmd === 'rules') {
return await execDumpRulesCommand(roomId, event, mjolnir);
} else if (parts[1] === 'sync') {
} else if (cmd === 'sync') {
return await execSyncCommand(roomId, event, mjolnir);
} else if (parts[1] === 'verify') {
} else if (cmd === 'verify') {
return await execPermissionCheckCommand(roomId, event, mjolnir);
} else if (parts.length >= 5 && parts[1] === 'list' && parts[2] === 'create') {
} else if (parts.length >= 5 && cmd === 'list' && parts[2] === 'create') {
return await execCreateListCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'watch' && parts.length > 1) {
} else if (cmd === 'watch' && parts.length > 1) {
return await execWatchCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'unwatch' && parts.length > 1) {
} else if (cmd === 'unwatch' && parts.length > 1) {
return await execUnwatchCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'redact' && parts.length > 1) {
} else if (cmd === 'redact' && parts.length > 1) {
return await execRedactCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'import' && parts.length > 2) {
} else if (cmd === 'import' && parts.length > 2) {
return await execImportCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'default' && parts.length > 2) {
} else if (cmd === 'default' && parts.length > 2) {
return await execSetDefaultListCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'deactivate' && parts.length > 2) {
} else if (cmd === 'deactivate' && parts.length > 2) {
return await execDeactivateCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'protections') {
} else if (cmd === 'protections') {
return await execListProtections(roomId, event, mjolnir, parts);
} else if (parts[1] === 'enable' && parts.length > 1) {
} else if (cmd === 'enable' && parts.length > 1) {
return await execEnableProtection(roomId, event, mjolnir, parts);
} else if (parts[1] === 'disable' && parts.length > 1) {
} else if (cmd === 'disable' && parts.length > 1) {
return await execDisableProtection(roomId, event, mjolnir, parts);
} else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) {
} else if (cmd === 'config' && parts[2] === 'set' && parts.length > 3) {
return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) {
} else if (cmd === 'config' && parts[2] === 'add' && parts.length > 3) {
return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) {
} else if (cmd === 'config' && parts[2] === 'remove' && parts.length > 3) {
return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'config' && parts[2] === 'get') {
} else if (cmd === 'config' && parts[2] === 'get') {
return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'add') {
} else if (cmd === 'rooms' && parts.length > 3 && parts[2] === 'add') {
return await execAddProtectedRoom(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') {
} else if (cmd === 'rooms' && parts.length > 3 && parts[2] === 'remove') {
return await execRemoveProtectedRoom(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rooms' && parts.length === 2) {
} else if (cmd === 'rooms' && parts.length === 2) {
return await execListProtectedRooms(roomId, event, mjolnir);
} else if (parts[1] === 'move' && parts.length > 3) {
} else if (cmd === 'move' && parts.length > 3) {
return await execMoveAliasCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'directory' && parts.length > 3 && parts[2] === 'add') {
} else if (cmd === 'directory' && parts.length > 3 && parts[2] === 'add') {
return await execAddRoomToDirectoryCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'directory' && parts.length > 3 && parts[2] === 'remove') {
} else if (cmd === 'directory' && parts.length > 3 && parts[2] === 'remove') {
return await execRemoveRoomFromDirectoryCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'alias' && parts.length > 4 && parts[2] === 'add') {
} else if (cmd === 'alias' && parts.length > 4 && parts[2] === 'add') {
return await execAddAliasCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'alias' && parts.length > 3 && parts[2] === 'remove') {
} else if (cmd === 'alias' && parts.length > 3 && parts[2] === 'remove') {
return await execRemoveAliasCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'resolve' && parts.length > 2) {
} else if (cmd === 'resolve' && parts.length > 2) {
return await execResolveCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'powerlevel' && parts.length > 3) {
} else if (cmd === 'powerlevel' && parts.length > 3) {
return await execSetPowerLevelCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'shutdown' && parts[2] === 'room' && parts.length > 3) {
} else if (cmd === 'shutdown' && parts[2] === 'room' && parts.length > 3) {
return await execShutdownRoomCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'since') {
return await execSinceCommand(roomId, event, mjolnir, tokens);
} else if (parts[1] === 'kick' && parts.length > 2) {
} else if (cmd === 'since') {
return await execSinceCommand(roomId, event, mjolnir, lexer);
} else if (cmd === 'kick' && parts.length > 2) {
return await execKickCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === 'make' && parts[2] === 'admin' && parts.length > 3) {
} else if (cmd === 'make' && parts[2] === 'admin' && parts.length > 3) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we should certainly figure out how to get rid of this giant switch statement and use a table to dispatch commands instead. We'd also be able to isolate commands and stop editing this file each time we need to add/remove one too by doing this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, that's the objective of the refactoring.

return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts);
} else {
// Help menu
Expand Down
110 changes: 110 additions & 0 deletions src/commands/Lexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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_]+/;
Yoric marked this conversation as resolved.
Show resolved Hide resolved
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 = /.*$/;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why keep these separate from the lexer rules?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda expect that we're going to need some of these regexps in other places, but I may be wrong.


/**
* 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` ...
Yoric marked this conversation as resolved.
Show resolved Hide resolved
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();
}
}
Loading