plugins-core/simplecommands/plugin.ts

694 lines
17 KiB
TypeScript

import {
Plugin,
EventListener,
Configurable,
DependencyLoad,
DependencyUnload
} from '@squeebot/core/lib/plugin';
import { EMessageType, IMessage, MessageResolver } from '@squeebot/core/lib/types';
import { fullIDMatcher } from '@squeebot/core/lib/common';
import { logger } from '@squeebot/core/lib/core';
import { IChannel } from '@squeebot/core/lib/channel';
declare type Matcher = (msg: IMessage) => any;
interface CommandSpec {
name: string;
plugin: string;
description?: string;
usage?: string;
source?: string | string[] | Matcher;
tags?: string[];
aliases?: string[];
alias?: string;
match?: string | Matcher;
hidden?: boolean;
permissions?: string[];
tempargv?: any[];
execute(
msg: IMessage,
msr: MessageResolver,
command: CommandSpec,
prefix: string,
...args: any[]): Promise<boolean>;
}
interface RateLimit {
rooms: string[];
rate: number;
cooldown: number;
}
interface Rate {
lastMessage: number;
messages: number;
}
const rates: {[key: string]: Rate} = {};
@Configurable({
prefix: {
'*': '!'
},
rateLimits: [],
channelMatching: false,
})
class SqueebotCommandsAPIPlugin extends Plugin {
private commands: CommandSpec[] = [];
private permissions: any = null;
public getRateLimit(room: string): RateLimit | null {
for (const rm of this.config.get<RateLimit[]>('rateLimits', [])) {
if (rm.rooms && (rm.rooms.indexOf(room) !== -1 || rm.rooms.indexOf('*') !== -1)) {
return rm;
}
}
return null;
}
public doLimiting(room: string, sender: string): boolean {
const rl = this.getRateLimit(room);
if (!rl) {
return false;
}
// Hasn't been limited yet
if (!rates[sender]) {
rates[sender] = {
lastMessage: Date.now(),
messages: 1,
};
return false;
}
const r = rates[sender];
if (r.lastMessage > Date.now() - rl.cooldown) {
if (r.messages >= rl.rate) {
// Rate limited
return true;
}
r.lastMessage = Date.now();
r.messages++;
return false;
}
r.lastMessage = Date.now();
r.messages = 1;
return false;
}
public setRateLimit(room: string, rate: number, cooldown: number): void {
const basis = this.config.get('rateLimits', []) as RateLimit[];
const found = basis.find((item) => item.rooms.includes(room));
const resave = () => {
this.config.set('rateLimits', basis);
this.config.save();
};
const addNew = () => {
const newRate: RateLimit = {
rate, cooldown, rooms: [room]
};
basis.push(newRate);
resave();
};
if (found) {
if (rate === 0 || (rate !== found.rate || cooldown !== found.cooldown)) {
if (rate === 0 || found.rooms.length > 1) {
found.rooms.splice(found.rooms.indexOf(room), 1);
}
if (rate === 0) {
if (found.rooms.length === 0) {
basis.splice(basis.indexOf(found), 1);
}
resave();
return;
}
addNew();
return;
}
if (found.rooms.length > 1) {
addNew();
return;
}
found.rate = rate;
found.cooldown = cooldown;
resave();
return;
}
addNew();
}
private roomMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] {
const roomMatches = [];
for (const spec of specList) {
if (spec.source) {
// This message can't room match
if (!msg.fullRoomID) {
continue;
}
if (typeof spec.source === 'function') {
try {
if (spec.source(msg)) {
roomMatches.push(spec);
}
} catch (e) {}
} else if (typeof spec.source === 'string') {
if (fullIDMatcher(msg.fullRoomID, spec.source)) {
roomMatches.push(spec);
}
} else if (Array.isArray(spec.source)) {
for (const room of spec.source) {
if (fullIDMatcher(msg.fullRoomID, room)) {
roomMatches.push(spec);
break;
}
}
}
continue;
}
// No source requirement
roomMatches.push(spec);
}
return roomMatches;
}
public permissionMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] {
const permitted = [];
for (const spec of specList) {
if (!spec.permissions) {
permitted.push(spec);
continue;
}
if (!this.permissions) {
continue;
}
if (!this.permissions.userPermitted(msg, spec.permissions)) {
continue;
}
permitted.push(spec);
}
return permitted;
}
public async handlePrefix(
msg: IMessage,
prefix: string,
plugins: string[],
msr: MessageResolver
): Promise<void> {
const text = msg.text;
const separate = text.split(' ');
if (separate[0].indexOf(prefix) === 0) {
separate[0] = separate[0].substring(prefix.length);
}
// Iteration 1: Resolve commands by name and by aliases
const withAliases = [];
for (const spec of this.commands) {
if (plugins.length && plugins.indexOf(spec.plugin) === -1) {
continue;
}
if (spec.aliases && spec.aliases.indexOf(separate[0]) !== -1) {
const copy = Object.assign({}, spec);
copy.alias = spec.name;
withAliases.push(copy);
continue;
}
if (spec.name !== separate[0]) {
continue;
}
withAliases.push(spec);
}
// Iteration 2: Match rooms, if needed
const roomMatches = this.roomMatcher(msg, withAliases);
// Nothing matches room requirements
if (!roomMatches.length) {
return;
}
// Iteration 3: Sort the array so that the ones that had room matching come up first
const sorted = [];
for (const spec of roomMatches) {
if (spec.source) {
sorted.push(spec);
continue;
}
sorted.push(spec);
}
// Iteration 4: Match permissions for user
const permitted = this.permissionMatcher(msg, sorted);
// Rate limit check
if (permitted.length
&& msg.fullRoomID
&& msg.fullSenderID
&& this.config.get('rateLimits', []).length
&& this.doLimiting(msg.fullRoomID, msg.fullSenderID)
) {
logger.warn('[%s] User %s rate limited', this.name, msg.fullSenderID);
return;
}
// Start executing
for (const spec of permitted) {
const argv: any[] = this.splitArguments(text).slice(1);
if (spec.plugin === this.name) {
argv.unshift(plugins);
}
const success = await spec.execute(msg, msr, spec, prefix, ...argv);
if (success) {
break;
}
}
// Done
}
@EventListener('message')
public digest(msg: IMessage, chan: IChannel, msr: MessageResolver): void {
if (msg.type !== EMessageType.message) {
return;
}
let allowedPlugins: string[] = [];
if (chan && this.config.get<boolean>('channelMatching', false) === true) {
allowedPlugins = chan.plugins;
}
const text = msg.text;
const prefixes = this.config.config.prefix;
if (!prefixes) {
return;
}
// Attempt to match prefixes, prefers room-specific ones
let prefix = '!';
if (typeof prefixes === 'string') {
prefix = prefixes;
} else if (typeof prefixes === 'object') {
if (msg.fullRoomID) {
for (const idtag in prefixes) {
if (idtag === '*') {
prefix = prefixes[idtag];
continue;
}
if (fullIDMatcher(msg.fullRoomID, idtag)) {
prefix = prefixes[idtag];
break;
}
}
}
}
if (!prefix || text.indexOf(prefix) !== 0) {
return;
}
this.handlePrefix(msg, prefix, allowedPlugins, msr).catch(e =>
logger.error('[%s] Command handler threw an error:', this.name, e.stack));
}
public registerCommand(spec: CommandSpec | CommandSpec[], bulk = false): boolean {
if (Array.isArray(spec)) {
if (!spec.length) {
return false;
}
logger.log('[%s] Plugin %s registered commands %s', this.name,
spec[0].plugin || 'unknown', spec.map(x => x.name).join(', '));
let success = true;
for (const sp of spec) {
if (!this.registerCommand(sp, true)) {
success = false;
}
}
return success;
} else if (!bulk) {
logger.log('[%s] Plugin %s registered command %s', this.name,
spec.plugin, spec.name);
}
if (!spec.name || !spec.execute || !spec.plugin) {
throw new Error('Invalid command specification!');
}
for (const cmd of this.commands) {
if (cmd.name === spec.name && cmd.plugin === spec.plugin) {
return false;
}
}
this.commands.push(spec);
return true;
}
public unregisterPlugin(plugin: string): void {
const remaining: CommandSpec[] = [];
const removed: CommandSpec[] = [];
for (const cmd of this.commands) {
if (cmd.plugin !== plugin) {
remaining.push(cmd);
continue;
}
removed.push(cmd);
}
if (removed.length) {
logger.log('[%s] Plugin %s unregistered command(s):', this.name,
plugin, removed.map(x => x.name).join(', '));
this.commands = remaining;
}
}
private withAliases(msg: IMessage, plugins: string[]): CommandSpec[] {
const withAliases = [];
for (const spec of this.commands) {
if (plugins.length && plugins.indexOf(spec.plugin) === -1) {
continue;
}
if (spec.aliases && spec.aliases.length) {
for (const alias of spec.aliases) {
const copy = Object.assign({}, spec);
copy.name = alias;
copy.alias = spec.name;
withAliases.push(copy);
}
}
withAliases.push(spec);
}
return withAliases;
}
private aliasesCommand(msg: IMessage, prefix: string, args: any[]): void {
// First argument could be a passed plugin array within this plugin
let plugins: string[] = [];
let cmdarg = 0;
if (args && args.length && Array.isArray(args[0])) {
plugins = args[0];
cmdarg += 1;
}
// Iteration 1: Resolve commands (with aliases)
const withAliases = this.withAliases(msg, plugins);
// Iteration 2: Match rooms for message
let matching = this.roomMatcher(msg, withAliases);
// Iteration 3: Match permissions for user
matching = this.permissionMatcher(msg, matching);
const b = (t: string) => msg.source.format.format('bold', t);
if (!args[cmdarg]) {
msg.resolve('A command name is required.');
return;
}
let found: CommandSpec | null = null;
for (const spec of matching) {
if (spec.name === args[cmdarg]) {
found = spec;
break;
}
}
if (!found) {
msg.resolve('aliases: No such command "%s"!', args[cmdarg]);
return;
}
if (!found.aliases || found.aliases.length === 0) {
msg.resolve('%s - No aliases found.', b(prefix + found.name));
return;
}
let list = found.aliases;
let aliasText = '';
if (found.alias) {
aliasText = b(`[alias of ${found.alias}]`);
list = list.filter((x) => x !== found?.name);
}
msg.resolve(b('Aliases of %s:'), prefix + found.name, list.join(', '), aliasText);
}
private helpCommand(msg: IMessage, prefix: string, args: any[]): void {
// First argument could be a passed plugin array within this plugin
let plugins: string[] = [];
let cmdarg = 0;
if (args && args.length && Array.isArray(args[0])) {
plugins = args[0];
cmdarg += 1;
}
// Iteration 1: Resolve commands (with aliases)
const withAliases = this.withAliases(msg, plugins);
// Iteration 2: Match rooms for message
let matching = this.roomMatcher(msg, withAliases);
// Iteration 3: Match permissions for user
matching = this.permissionMatcher(msg, matching);
const b = (t: string) => msg.source.format.format('bold', t);
if (args[cmdarg]) {
let found: CommandSpec | null = null;
for (const spec of matching) {
if (spec.name === args[cmdarg]) {
found = spec;
break;
}
}
if (!found) {
msg.resolve('help: No such command "%s"!', args[cmdarg]);
return;
}
let aliasText = '';
if (found.alias) {
aliasText = b(`[alias of ${found.alias}]`);
}
if (found.usage) {
msg.resolve('%s %s -', b(prefix + found.name), found.usage,
found.description || 'No description :(', aliasText);
return;
}
msg.resolve('%s -', b(prefix + found.name), found.description || 'No description :(', aliasText);
return;
}
msg.resolve('All commands start with a "%s" prefix!\n%s', prefix,
b(`List of commands in ${msg.target?.name}:`),
matching
.filter(x => x.alias == null && x.hidden !== true)
.map(x => x.name).join(', ')
);
}
private splitArguments(str: string): string[] {
const strArray = str.split('');
const strARGS: string[] = [];
let strARGC = 0;
let strChar = '';
let isString = false;
let escape = false;
let escaped = false;
function addToArgs(append: string): void {
if (!strARGS[strARGC]) {
strARGS[strARGC] = '';
}
strARGS[strARGC] += append;
}
for (strChar in strArray) {
if (escaped) {
escaped = false;
}
if (escape) {
escape = false;
escaped = true;
}
switch (strArray[strChar]) {
case '\\':
if (!escaped) {
escape = true;
} else {
addToArgs('\\');
}
break;
case '"':
if (!escaped) {
if (!isString) {
isString = true;
} else {
isString = false;
}
} else {
addToArgs('"');
}
break;
case ' ':
if (!isString) {
strARGC++;
} else if (isString) {
if (escaped) {
addToArgs('\\');
}
addToArgs(' ');
}
break;
default:
if (escaped) {
addToArgs('\\');
}
addToArgs(strArray[strChar]);
}
}
return strARGS;
}
initialize(): void {
this.registerCommand({
plugin: this.name,
name: 'help',
aliases: ['commands'],
usage: '[<command>]',
description: 'Show command usage or list all available commands',
execute: async (
msg: IMessage,
msr: MessageResolver,
spec: CommandSpec,
prefix: string,
...args: any[]
): Promise<boolean> => {
this.helpCommand(msg, prefix, args);
return true;
}
});
this.registerCommand({
plugin: this.name,
name: 'aliases',
aliases: ['alias'],
usage: '<command>',
description: 'Show the list of aliases for command',
execute: async (
msg: IMessage,
msr: MessageResolver,
spec: CommandSpec,
prefix: string,
...args: any[]
): Promise<boolean> => {
this.aliasesCommand(msg, prefix, args);
return true;
}
});
this.registerCommand({
plugin: this.name,
name: 'ratelimit',
usage: '<commands per cooldown> <cooldown in seconds>',
description: 'Set command rate limits in the current room',
hidden: true,
permissions: ['room.rate-limit'],
execute: async (
msg: IMessage,
msr: MessageResolver,
spec: CommandSpec,
prefix: string,
...args: any[]
): Promise<boolean> => {
if (!args.length) {
const rl = this.getRateLimit(msg.target?.id as string);
if (!rl) {
msg.resolve(`There are no rate limits enabled for ${msg.target?.name}.`);
return true;
}
const isGlobal = rl.rooms.includes('*');
msg.resolve(
`Active rate limit in ${msg.target?.name}${isGlobal ? ' (global)' : ''}: ${rl.rate} messages in ${rl.cooldown}`
);
return true;
}
const [count, cooldown] = args;
try {
this.setRateLimit(msg.target?.id as string, parseInt(count, 10), parseInt(cooldown, 10));
} catch (e: any) {
msg.resolve('Setting rate limit failed:', e.message);
return true;
}
msg.resolve('Rate limit has been configured.');
return true;
}
});
}
@DependencyLoad('permissions')
permissionsAdded(plugin: Plugin): void {
this.permissions = plugin;
}
@DependencyUnload('permissions')
permissionsRemoved(): void {
this.permissions = null;
}
@EventListener('pluginUnload')
unloadEventHandler(plugin: string | Plugin): void {
if (plugin === this.name || plugin === this) {
logger.debug('[%s] shutting down..', this.name);
this.emit('pluginUnloaded', this);
}
}
@EventListener('pluginUnloaded')
unloadedPlugin(plugin: string | Plugin): void {
this.unregisterPlugin((typeof plugin === 'string' ? plugin : plugin.manifest.name));
}
}
module.exports = SqueebotCommandsAPIPlugin;