620 lines
16 KiB
TypeScript
620 lines
16 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
import {
|
|
Plugin,
|
|
EventListener,
|
|
Configurable,
|
|
IPluginManifest,
|
|
IPlugin,
|
|
DependencyLoad,
|
|
} from '@squeebot/core/lib/plugin';
|
|
|
|
import { IChannel } from '@squeebot/core/lib/channel';
|
|
import {
|
|
IRepoPluginDef,
|
|
IRepository,
|
|
} from '@squeebot/core/lib/plugin/repository';
|
|
import { ISqueebotCore, logger } from '@squeebot/core/lib/core';
|
|
|
|
import path from 'path';
|
|
import fs from 'fs/promises';
|
|
|
|
interface ControlCommand {
|
|
execute: (p: ControlPlugin, ...args: any[]) => Promise<any>;
|
|
name: string;
|
|
plugin: string;
|
|
}
|
|
|
|
interface SocketMessage {
|
|
[x: string]: unknown;
|
|
command?: string;
|
|
id?: string;
|
|
status?: string;
|
|
arguments?: unknown[];
|
|
list?: unknown[];
|
|
data?: unknown;
|
|
}
|
|
|
|
type ReplyFn = (message: SocketMessage) => void;
|
|
|
|
let controlCommands: ControlCommand[] = [
|
|
{
|
|
name: 'loadPlugin',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
|
if (!plugin) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
p.stream.emitTo('core', 'pluginLoad', plugin);
|
|
},
|
|
},
|
|
{
|
|
name: 'unloadPlugin',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
|
if (!plugin) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
p.stream.emitTo(plugin, 'pluginUnload', plugin);
|
|
},
|
|
},
|
|
{
|
|
name: 'listActivePlugins',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin): Promise<IPluginManifest[]> => {
|
|
return p.core!.pluginManager.getLoaded().map((x: IPlugin) => x.manifest);
|
|
},
|
|
},
|
|
{
|
|
name: 'listInstalledPlugins',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin): Promise<IPluginManifest[]> => {
|
|
return p.core!.pluginManager.availablePlugins;
|
|
},
|
|
},
|
|
{
|
|
name: 'installPlugin',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
plugin: string
|
|
): Promise<IPluginManifest> => {
|
|
if (!plugin) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
return p.core!.repositoryManager.installPlugin(plugin);
|
|
},
|
|
},
|
|
{
|
|
name: 'uninstallPlugin',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
|
if (!plugin) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
return p.core!.repositoryManager.uninstallPlugin(plugin);
|
|
},
|
|
},
|
|
{
|
|
name: 'enablePlugin',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
|
if (!plugin) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
if (!p.core!.pluginManager.getAvailableByName(plugin)) {
|
|
throw new Error('No such plugin is available.');
|
|
}
|
|
|
|
if (!p.core!.config.config.enabled) {
|
|
p.core!.config.config.enabled = [plugin];
|
|
} else if (p.core!.config.config.enabled.indexOf(plugin) === -1) {
|
|
p.core!.config.config.enabled.push(plugin);
|
|
}
|
|
|
|
return p.core!.config.save();
|
|
},
|
|
},
|
|
{
|
|
name: 'disablePlugin',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, plugin: string): Promise<void> => {
|
|
if (!plugin) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
if (!p.core!.pluginManager.getAvailableByName(plugin)) {
|
|
throw new Error('No such plugin is available.');
|
|
}
|
|
if (!p.core!.config.config.enabled) {
|
|
return;
|
|
}
|
|
const indx = p.core!.config.config.enabled.indexOf(plugin);
|
|
|
|
if (indx > -1) {
|
|
p.core!.config.config.enabled.splice(indx, 1);
|
|
}
|
|
|
|
return p.core!.config.save();
|
|
},
|
|
},
|
|
{
|
|
name: 'installRepository',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, url: string): Promise<IRepository> => {
|
|
if (!url) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
return p.core!.repositoryManager.installRepository(url);
|
|
},
|
|
},
|
|
{
|
|
name: 'uninstallRepository',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, repo: string): Promise<void> => {
|
|
if (!repo) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
return p.core!.repositoryManager.uninstallRepository(repo);
|
|
},
|
|
},
|
|
{
|
|
name: 'listRepositoryPlugins',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
repo: string
|
|
): Promise<IRepoPluginDef[]> => {
|
|
if (!repo) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
const repoData = p.core!.repositoryManager.getRepoByName(repo);
|
|
if (!repoData) {
|
|
throw new Error('No such repository found.');
|
|
}
|
|
return repoData.plugins;
|
|
},
|
|
},
|
|
{
|
|
name: 'listRepositories',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin): Promise<IRepository[]> => {
|
|
return p.core!.repositoryManager.getAll();
|
|
},
|
|
},
|
|
{
|
|
name: 'updateRepository',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
repo: string
|
|
): Promise<IPluginManifest[]> => {
|
|
if (!repo) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
const repoData = p.core!.repositoryManager.getRepoByName(repo);
|
|
if (!repoData) {
|
|
throw new Error('No such repository found.');
|
|
}
|
|
return p.core!.repositoryManager.checkForUpdates(repoData);
|
|
},
|
|
},
|
|
{
|
|
name: 'newChannel',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
name: string,
|
|
plugins?: string[]
|
|
): Promise<void> => {
|
|
if (!name) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
if (p.core!.channelManager.getChannelByName(name)) {
|
|
throw new Error('A channel by that name already exists!');
|
|
}
|
|
|
|
p.core!.channelManager.addChannel({
|
|
name,
|
|
plugins: plugins || [],
|
|
enabled: true,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'removeChannel',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, name: string): Promise<void> => {
|
|
if (!name) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
const chan = p.core!.channelManager.getChannelByName(name);
|
|
if (!chan) {
|
|
throw new Error('A channel by that name does not exists!');
|
|
}
|
|
|
|
p.core!.channelManager.removeChannel(chan);
|
|
},
|
|
},
|
|
{
|
|
name: 'enableChannel',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, name: string): Promise<void> => {
|
|
if (!name) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
const chan = p.core!.channelManager.getChannelByName(name);
|
|
if (!chan) {
|
|
throw new Error('A channel by that name does not exists!');
|
|
}
|
|
|
|
chan.enabled = true;
|
|
p.core!.config.config.channels = p.core!.channelManager.getAll();
|
|
await p.core!.config.save();
|
|
},
|
|
},
|
|
{
|
|
name: 'disableChannel',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, name: string): Promise<void> => {
|
|
if (!name) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
const chan = p.core!.channelManager.getChannelByName(name);
|
|
if (!chan) {
|
|
throw new Error('A channel by that name does not exists!');
|
|
}
|
|
|
|
chan.enabled = false;
|
|
p.core!.config.config.channels = p.core!.channelManager.getAll();
|
|
await p.core!.config.save();
|
|
},
|
|
},
|
|
{
|
|
name: 'addChannelPlugin',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
name: string,
|
|
plugins: string | string[]
|
|
): Promise<void> => {
|
|
if (!name || !plugins) {
|
|
throw new Error('This function takes 2 arguments.');
|
|
}
|
|
const chan = p.core!.channelManager.getChannelByName(name);
|
|
if (!chan) {
|
|
throw new Error('A channel by that name does not exists!');
|
|
}
|
|
if (!Array.isArray(plugins)) {
|
|
plugins = [plugins];
|
|
}
|
|
for (const plugin of plugins) {
|
|
if (chan.plugins.indexOf(plugin) === -1) {
|
|
chan.plugins.push(plugin);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'removeChannelPlugin',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
name: string,
|
|
plugins: string | string[]
|
|
): Promise<void> => {
|
|
if (!name || !plugins) {
|
|
throw new Error('This function takes 2 arguments.');
|
|
}
|
|
const chan = p.core!.channelManager.getChannelByName(name);
|
|
if (!chan) {
|
|
throw new Error('A channel by that name does not exists!');
|
|
}
|
|
if (!Array.isArray(plugins)) {
|
|
plugins = [plugins];
|
|
}
|
|
for (const plugin of plugins) {
|
|
const idx = chan.plugins.indexOf(plugin);
|
|
if (idx !== -1) {
|
|
chan.plugins.splice(idx, 1);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'listChannels',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin): Promise<IChannel[]> => {
|
|
return p.core!.channelManager.getAll();
|
|
},
|
|
},
|
|
{
|
|
name: 'getPluginConfig',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, name: string): Promise<any> => {
|
|
if (!name) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
const plugin = p.core!.pluginManager.getLoadedByName(name);
|
|
if (!plugin) {
|
|
throw new Error('No such plugin is currently running!');
|
|
}
|
|
return plugin.config.config;
|
|
},
|
|
},
|
|
{
|
|
name: 'getPluginConfigValue',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
name: string,
|
|
key: string
|
|
): Promise<any> => {
|
|
if (!name || !key) {
|
|
throw new Error('This function takes 2 arguments.');
|
|
}
|
|
const plugin = p.core!.pluginManager.getLoadedByName(name);
|
|
if (!plugin) {
|
|
throw new Error('No such plugin is currently running!');
|
|
}
|
|
return plugin.config.get(key);
|
|
},
|
|
},
|
|
{
|
|
name: 'getPluginConfigSchema',
|
|
plugin: 'control',
|
|
execute: async (p: ControlPlugin, name: string): Promise<any> => {
|
|
if (!name) {
|
|
throw new Error('This function takes 1 argument.');
|
|
}
|
|
return p.getPluginSchema(name);
|
|
},
|
|
},
|
|
{
|
|
name: 'setPluginConfig',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
name: string,
|
|
config: any
|
|
): Promise<any> => {
|
|
if (!name || !config) {
|
|
throw new Error('This function takes 2 arguments.');
|
|
}
|
|
const plugin = p.core!.pluginManager.getLoadedByName(name);
|
|
if (!plugin) {
|
|
throw new Error('No such plugin is currently running!');
|
|
}
|
|
plugin.config.config = config;
|
|
return plugin.config.save();
|
|
},
|
|
},
|
|
{
|
|
name: 'setPluginConfigValue',
|
|
plugin: 'control',
|
|
execute: async (
|
|
p: ControlPlugin,
|
|
name: string,
|
|
key: string,
|
|
value: string
|
|
): Promise<any> => {
|
|
if (!name || !key) {
|
|
throw new Error('This function takes 3 arguments.');
|
|
}
|
|
const plugin = p.core!.pluginManager.getLoadedByName(name);
|
|
if (!plugin) {
|
|
throw new Error('No such plugin is currently running!');
|
|
}
|
|
plugin.config.set(key, value);
|
|
return plugin.config.save();
|
|
},
|
|
},
|
|
];
|
|
|
|
@Configurable({
|
|
authorizedIPs: [],
|
|
tls: {
|
|
enabled: false,
|
|
key: '',
|
|
cert: '',
|
|
rejectUnauthorized: false,
|
|
requestCert: false,
|
|
},
|
|
bind: null,
|
|
})
|
|
class ControlPlugin extends Plugin {
|
|
public core: ISqueebotCore | null = null;
|
|
public plugins = new Map<string, unknown>();
|
|
|
|
public initialize(): void {
|
|
this.addEventListener('core', (core: ISqueebotCore) => (this.core = core));
|
|
this.emitTo('core', 'request-core', this.name);
|
|
this.getPluginSchema(this.name).catch(() =>
|
|
logger.error(
|
|
'[control] How embarrasing! control could not load it\'s own schema!'
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Load a plugin schema from it's schema file, if it exists.
|
|
* @param name Plugin name
|
|
* @returns Plugin schema
|
|
*/
|
|
public async loadPluginSchema(name: string): Promise<unknown> {
|
|
if (!this.core) {
|
|
throw new Error('control could not access the core.');
|
|
}
|
|
|
|
const { pluginsPath } = this.core.environment;
|
|
const schemaPath = path.join(pluginsPath, name, 'schema.json');
|
|
let schema: any;
|
|
|
|
try {
|
|
const fileRead = await fs.readFile(schemaPath, { encoding: 'utf8' });
|
|
schema = JSON.parse(fileRead);
|
|
} catch (e: any) {
|
|
throw new Error(
|
|
'No schema file found, it is not accessible or is not valid JSON.'
|
|
);
|
|
}
|
|
|
|
if (!schema.type) {
|
|
throw new Error(
|
|
'Schema does not specify what type of object it is referencing.'
|
|
);
|
|
}
|
|
|
|
this.plugins.set(name, schema);
|
|
return schema;
|
|
}
|
|
|
|
/**
|
|
* Register a plugin's configuration schema directly instead of reading from file.
|
|
* @param name Plugin name
|
|
* @param confspec Static schema
|
|
*/
|
|
public registerPluginConfigSchema(name: string, confspec?: unknown): void {
|
|
this.plugins.set(name, confspec);
|
|
}
|
|
|
|
/**
|
|
* Load a plugin's configuration schema from memory or from file.
|
|
* @param name Plugin name
|
|
* @returns Schema
|
|
* @throws Error if schema is not found or is invalid
|
|
*/
|
|
public async getPluginSchema(name: string): Promise<unknown> {
|
|
if (this.plugins.has(name)) {
|
|
return this.plugins.get(name);
|
|
}
|
|
|
|
return this.loadPluginSchema(name);
|
|
}
|
|
|
|
/**
|
|
* Execute a registered control command.
|
|
* @param command Control command
|
|
* @param args Control command arguments
|
|
* @returns Control command response
|
|
*/
|
|
public async executeControlCommand(
|
|
command: string,
|
|
args: string[]
|
|
): Promise<unknown> {
|
|
if (!this.core) {
|
|
throw new Error('The control plugin cannot control the bot right now.');
|
|
}
|
|
const cmdobj = controlCommands.find((k) => k.name === command);
|
|
if (!cmdobj || !cmdobj.execute) {
|
|
throw new Error('No such command');
|
|
}
|
|
return cmdobj.execute.call(this, this, ...args);
|
|
}
|
|
|
|
/**
|
|
* Register a new custom control command.
|
|
* @param obj ControlCommand object
|
|
*/
|
|
public registerControlCommand(obj: ControlCommand): void {
|
|
if (!obj.execute || !obj.name || !obj.plugin) {
|
|
throw new Error('Invalid command object.');
|
|
}
|
|
|
|
const exists = controlCommands.find((k) => k.name === obj.name);
|
|
if (exists) {
|
|
throw new Error('Control commands should not be overwritten.');
|
|
}
|
|
|
|
controlCommands.push(obj);
|
|
|
|
logger.log('[%s] registered control command', this.name, obj.name);
|
|
}
|
|
|
|
public listControlCommands(): string[] {
|
|
return controlCommands.map((command) => command.name);
|
|
}
|
|
|
|
@EventListener('pluginUnload')
|
|
public unloadEventHandler(plugin: string | Plugin): void {
|
|
if (plugin === this.name || plugin === this) {
|
|
logger.debug('[%s]', this.name, 'shutting down..');
|
|
|
|
this.plugins.clear();
|
|
this.emit('pluginUnloaded', this);
|
|
}
|
|
}
|
|
|
|
@EventListener('pluginUnloaded')
|
|
public unloadedEventHandler(plugin: string | Plugin): void {
|
|
if (typeof plugin !== 'string') {
|
|
plugin = plugin.manifest.name;
|
|
}
|
|
this.plugins.delete(plugin);
|
|
controlCommands = controlCommands.filter((k) => k.plugin !== plugin);
|
|
}
|
|
|
|
@DependencyLoad('socket')
|
|
public socketDepLoaded(socketAPI: any) {
|
|
const config = this.config.config;
|
|
socketAPI
|
|
.createServer({
|
|
name: 'control',
|
|
plugin: this.name,
|
|
...config,
|
|
})
|
|
.then((server: any) => {
|
|
server.on('message', (...args: [SocketMessage, never, ReplyFn]) =>
|
|
this.handleClientLine(...args)
|
|
);
|
|
})
|
|
.catch((err: Error) =>
|
|
logger.error('[%s] Failed to initialize: ', this.name, err.message)
|
|
);
|
|
}
|
|
|
|
private handleClientLine(
|
|
message: SocketMessage,
|
|
sender: never,
|
|
reply: ReplyFn
|
|
): void {
|
|
if (!message.command) return;
|
|
this.executeControlCommand(
|
|
message.command,
|
|
(message.arguments as string[]) || []
|
|
).then(
|
|
(cmdData) => {
|
|
try {
|
|
const response: SocketMessage = {
|
|
status: 'OK',
|
|
command: message.command,
|
|
id: message.id
|
|
};
|
|
|
|
if (cmdData != null) {
|
|
if (Array.isArray(cmdData)) {
|
|
response.list = cmdData;
|
|
} else {
|
|
response.data = cmdData;
|
|
}
|
|
}
|
|
|
|
reply(response);
|
|
} catch (error) {
|
|
reply({
|
|
status: 'ERROR',
|
|
arguments: [(error as Error).message],
|
|
id: message.id
|
|
});
|
|
}
|
|
},
|
|
(error) =>
|
|
reply({
|
|
status: 'ERROR',
|
|
arguments: [(error as Error).message],
|
|
id: message.id
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = ControlPlugin;
|