2020-11-29 18:54:25 +00:00
|
|
|
import {
|
|
|
|
Plugin,
|
|
|
|
EventListener,
|
|
|
|
Configurable,
|
|
|
|
DependencyLoad,
|
|
|
|
DependencyUnload
|
|
|
|
} from '@squeebot/core/lib/plugin';
|
|
|
|
|
|
|
|
import { EMessageType, IMessage, IMessageTarget } from '@squeebot/core/lib/types';
|
|
|
|
|
|
|
|
import { fullIDMatcher } from '@squeebot/core/lib/common';
|
|
|
|
|
|
|
|
import { logger } from '@squeebot/core/lib/core';
|
2020-12-05 11:22:37 +00:00
|
|
|
import { IChannel } from '@squeebot/core/lib/channel';
|
2020-11-29 18:54:25 +00:00
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
declare type Matcher = (msg: IMessage) => any;
|
2020-11-29 18:54:25 +00:00
|
|
|
|
|
|
|
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[];
|
2020-12-05 11:22:37 +00:00
|
|
|
tempargv?: any[];
|
2020-11-29 18:54:25 +00:00
|
|
|
execute(msg: IMessage, command: CommandSpec, prefix: string, ...args: any[]): Promise<boolean>;
|
|
|
|
}
|
|
|
|
|
2020-12-05 10:26:38 +00:00
|
|
|
interface RateLimit {
|
|
|
|
rooms: string[];
|
|
|
|
perSecond: number;
|
|
|
|
cooldown: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Rate {
|
|
|
|
lastMessage: number;
|
|
|
|
messages: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rates: {[key: string]: Rate} = {};
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
@Configurable({
|
|
|
|
prefix: {
|
|
|
|
'*': '!'
|
|
|
|
},
|
2020-12-05 10:26:38 +00:00
|
|
|
keywords: ['squeebot'],
|
|
|
|
rateLimits: [],
|
2020-12-05 11:22:37 +00:00
|
|
|
channelMatching: false,
|
2020-11-29 18:54:25 +00:00
|
|
|
})
|
|
|
|
class SqueebotCommandsAPIPlugin extends Plugin {
|
|
|
|
private commands: CommandSpec[] = [];
|
|
|
|
private permissions: any = null;
|
|
|
|
|
2020-12-05 10:26:38 +00:00
|
|
|
public getRateLimit(room: string): RateLimit | null {
|
|
|
|
for (const rm of this.config.get('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.perSecond) {
|
|
|
|
// Rate limited
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
r.lastMessage = Date.now();
|
|
|
|
r.messages++;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
r.lastMessage = Date.now();
|
|
|
|
r.messages = 1;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
public async handlePrefix(msg: IMessage, prefix: string, plugins: string[]): Promise<void> {
|
|
|
|
const text = msg.text;
|
2020-11-29 18:54:25 +00:00
|
|
|
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) {
|
2020-12-05 11:22:37 +00:00
|
|
|
if (plugins.length && plugins.indexOf(spec.plugin) === -1) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
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);
|
|
|
|
|
2020-12-05 10:26:38 +00:00
|
|
|
// Rate limit check
|
|
|
|
if (permitted.length &&
|
|
|
|
this.config.get('rateLimits', []).length &&
|
|
|
|
this.doLimiting(msg.fullRoomID!, msg.fullSenderID!)) {
|
|
|
|
logger.warn('[%s] User %s rate limited', this.name, msg.fullSenderID);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
// Start executing
|
|
|
|
for (const spec of permitted) {
|
2020-12-05 11:22:37 +00:00
|
|
|
// Help command needs access to plugin list in the channel
|
|
|
|
// Very dirty
|
|
|
|
if (spec.name === 'help') {
|
|
|
|
spec.tempargv = plugins;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
const success = await spec.execute(msg, spec, prefix, ...separate.slice(1));
|
|
|
|
if (success) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Done
|
|
|
|
}
|
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
public async handleKeywords(msg: IMessage, keyword: string, plugins: string[]): Promise<void> {
|
|
|
|
const text = msg.text.toLowerCase();
|
2020-11-29 18:54:25 +00:00
|
|
|
|
|
|
|
// Only pass command specs which have `match` and match rooms
|
|
|
|
let matching = [];
|
|
|
|
for (const spec of this.commands) {
|
|
|
|
if (!spec.match) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
if (plugins.length && plugins.indexOf(spec.plugin) === -1) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
if (typeof spec.match === 'function') {
|
|
|
|
try {
|
2020-12-05 11:22:37 +00:00
|
|
|
const match = spec.match(msg);
|
|
|
|
if (match == null) {
|
2020-11-29 18:54:25 +00:00
|
|
|
continue;
|
|
|
|
}
|
2020-12-05 11:22:37 +00:00
|
|
|
spec.tempargv = match;
|
2020-11-29 18:54:25 +00:00
|
|
|
} catch (e) {}
|
|
|
|
} else {
|
2020-12-05 11:22:37 +00:00
|
|
|
const rgx = text.match(spec.match);
|
|
|
|
if (rgx == null) {
|
2020-11-29 18:54:25 +00:00
|
|
|
continue;
|
|
|
|
}
|
2020-12-05 11:22:37 +00:00
|
|
|
spec.tempargv = rgx.slice(1);
|
2020-11-29 18:54:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
matching.push(spec);
|
|
|
|
}
|
|
|
|
|
|
|
|
matching = this.roomMatcher(msg, matching);
|
|
|
|
|
|
|
|
// Nothing matches room requirements
|
|
|
|
if (!matching.length) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Iteration 2: Sort the array so that the ones that had room matching come up first
|
|
|
|
const sorted = [];
|
|
|
|
for (const spec of matching) {
|
|
|
|
if (spec.source) {
|
|
|
|
sorted.push(spec);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
sorted.push(spec);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Iteration 3: Match permissions for user
|
|
|
|
const permitted = this.permissionMatcher(msg, sorted);
|
|
|
|
|
2020-12-05 10:26:38 +00:00
|
|
|
// Rate limit check
|
|
|
|
if (permitted.length &&
|
|
|
|
this.config.get('rateLimits', []).length &&
|
|
|
|
this.doLimiting(msg.fullRoomID!, msg.fullSenderID!)) {
|
|
|
|
logger.warn('[%s] User %s rate limited', this.name, msg.fullSenderID);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
// Start executing
|
|
|
|
for (const spec of permitted) {
|
2020-12-05 11:22:37 +00:00
|
|
|
const success = await spec.execute(msg, spec, keyword,
|
|
|
|
...(spec.tempargv ? spec.tempargv : []));
|
2020-11-29 18:54:25 +00:00
|
|
|
if (success) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Done
|
|
|
|
}
|
|
|
|
|
2020-11-29 19:07:06 +00:00
|
|
|
@EventListener('message')
|
2020-12-05 11:22:37 +00:00
|
|
|
public digest(msg: IMessage, chan: IChannel): void {
|
2020-11-29 18:54:25 +00:00
|
|
|
if (msg.type !== EMessageType.message) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
let allowedPlugins: string[] = [];
|
|
|
|
if (chan && this.config.get('channelMatching', false) === true) {
|
|
|
|
allowedPlugins = chan.plugins;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
const text = msg.data.text ? msg.data.text : msg.data;
|
|
|
|
const prefixes = this.config.config.prefix;
|
|
|
|
const keywords = this.config.config.keywords;
|
|
|
|
|
|
|
|
// Attempt to match keywords
|
2020-12-05 11:22:37 +00:00
|
|
|
if (keywords && keywords.length) {
|
2020-11-29 18:54:25 +00:00
|
|
|
for (const kw of keywords) {
|
2020-12-05 11:22:37 +00:00
|
|
|
if (text.toLowerCase().match(kw) != null) {
|
|
|
|
this.handleKeywords(msg, kw, allowedPlugins).catch(e =>
|
2020-11-29 18:54:25 +00:00
|
|
|
logger.error('[%s] Command handler threw an error:', this.name, e.stack));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
this.handlePrefix(msg, prefix, allowedPlugins).catch(e =>
|
2020-11-29 18:54:25 +00:00
|
|
|
logger.error('[%s] Command handler threw an error:', this.name, e.stack));
|
|
|
|
}
|
|
|
|
|
2020-12-05 10:00:55 +00:00
|
|
|
public registerCommand(spec: CommandSpec | CommandSpec[], bulk = false): boolean {
|
2020-11-29 18:54:25 +00:00
|
|
|
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) {
|
2020-12-05 10:00:55 +00:00
|
|
|
if (!this.registerCommand(sp, true)) {
|
2020-11-29 18:54:25 +00:00
|
|
|
success = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return success;
|
2020-12-05 10:00:55 +00:00
|
|
|
} else if (!bulk) {
|
2020-11-29 18:54:25 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-05 11:22:37 +00:00
|
|
|
private helpCommand(msg: IMessage, prefix: string, plugins: string[]): void {
|
2020-11-29 18:54:25 +00:00
|
|
|
// Iteration 1: Resolve commands by name and by aliases
|
|
|
|
const withAliases = [];
|
|
|
|
for (const spec of this.commands) {
|
2020-12-05 11:22:37 +00:00
|
|
|
if (plugins.length && plugins.indexOf(spec.plugin) === -1) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2020-11-29 18:54:25 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2020-11-29 19:07:06 +00:00
|
|
|
// Iteration 2: Match rooms for message
|
|
|
|
let matching = this.roomMatcher(msg, withAliases);
|
2020-11-29 18:54:25 +00:00
|
|
|
|
2020-11-29 19:07:06 +00:00
|
|
|
// Iteration 3: Match permissions for user
|
|
|
|
matching = this.permissionMatcher(msg, matching);
|
2020-11-29 18:54:25 +00:00
|
|
|
|
|
|
|
const text = msg.data.text ? msg.data.text : msg.data;
|
|
|
|
const argv = text.toLowerCase().split(' ');
|
|
|
|
const b = (t: string) => {
|
|
|
|
return msg.source.format.format('bold', t);
|
|
|
|
};
|
|
|
|
|
|
|
|
if (argv[1]) {
|
|
|
|
let found: CommandSpec | null = null;
|
|
|
|
for (const spec of matching) {
|
|
|
|
if (spec.name === argv[1]) {
|
|
|
|
found = spec;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!found) {
|
|
|
|
msg.resolve('help: No such command "%s"!', argv[1]);
|
|
|
|
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(', ')
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
initialize(): void {
|
|
|
|
this.registerCommand({
|
|
|
|
plugin: this.name,
|
|
|
|
name: 'help',
|
|
|
|
aliases: ['commands'],
|
|
|
|
usage: '[<command>]',
|
|
|
|
description: 'Show command usage or list all commands',
|
|
|
|
execute: async (msg: IMessage, spec: CommandSpec, prefix: string): Promise<boolean> => {
|
2020-12-05 11:22:37 +00:00
|
|
|
this.helpCommand(msg, prefix, spec.tempargv as string[]);
|
2020-11-29 18:54:25 +00:00
|
|
|
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.config.save().then(() =>
|
|
|
|
this.emit('pluginUnloaded', this));
|
|
|
|
}
|
|
|
|
}
|
2020-12-05 10:00:55 +00:00
|
|
|
|
|
|
|
@EventListener('pluginUnloaded')
|
|
|
|
unloadedPlugin(plugin: string | Plugin): void {
|
|
|
|
this.unregisterPlugin((typeof plugin === 'string' ? plugin : plugin.manifest.name));
|
|
|
|
}
|
2020-11-29 18:54:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = SqueebotCommandsAPIPlugin;
|