Plugin and repository name validation, plugin restarting, optional formatting class for protocols
This commit is contained in:
parent
a86b912520
commit
88467fcee4
@ -114,3 +114,34 @@ export function thousandsSeparator(input: number | string): string {
|
||||
|
||||
return x1 + x2;
|
||||
}
|
||||
|
||||
export function timeSince(date: number): string {
|
||||
const seconds = Math.floor((Date.now() - date) / 1000);
|
||||
let interval = Math.floor(seconds / 31536000);
|
||||
|
||||
if (interval > 1) {
|
||||
return interval + ' years';
|
||||
}
|
||||
|
||||
interval = Math.floor(seconds / 2592000);
|
||||
if (interval > 1) {
|
||||
return interval + ' months';
|
||||
}
|
||||
|
||||
interval = Math.floor(seconds / 86400);
|
||||
if (interval > 1) {
|
||||
return interval + ' days';
|
||||
}
|
||||
|
||||
interval = Math.floor(seconds / 3600);
|
||||
if (interval > 1) {
|
||||
return interval + ' hours';
|
||||
}
|
||||
|
||||
interval = Math.floor(seconds / 60);
|
||||
if (interval > 1) {
|
||||
return interval + ' minutes';
|
||||
}
|
||||
|
||||
return Math.floor(seconds) + ' seconds';
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export class PluginMetaLoader {
|
||||
throw new Error('Plugin metadata does not specify a name, for some reason');
|
||||
}
|
||||
|
||||
if (json.name === 'squeebot') {
|
||||
if (json.name === 'squeebot' || !(/^[a-zA-Z0-9_\-]+$/.test(json.name))) {
|
||||
throw new Error('Illegal name.');
|
||||
}
|
||||
|
||||
|
@ -23,6 +23,8 @@ export function requireNoCache(file: string): object | null {
|
||||
export class PluginManager {
|
||||
private plugins: Map<string, IPlugin> = new Map();
|
||||
private configs: PluginConfigurator = new PluginConfigurator(this.environment);
|
||||
private restartQueue: Map<string, IPluginManifest> = new Map();
|
||||
private stopping = false;
|
||||
|
||||
constructor(
|
||||
public availablePlugins: IPluginManifest[],
|
||||
@ -72,6 +74,10 @@ export class PluginManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(/^[a-zA-Z0-9_\-]+$/.test(manifest.name))) {
|
||||
throw new Error('Illegal name for a plugin!');
|
||||
}
|
||||
|
||||
this.availablePlugins.push(manifest);
|
||||
return true;
|
||||
}
|
||||
@ -112,28 +118,53 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
public async load(plugin: IPluginManifest): Promise<IPlugin> {
|
||||
// Ignore loading when we're shutting down
|
||||
if (this.stopping) {
|
||||
throw new Error('Squeebot is shutting down');
|
||||
}
|
||||
|
||||
// Don't load plugins twice
|
||||
const ready = this.getLoadedByName(plugin.name);
|
||||
if (ready) {
|
||||
return ready;
|
||||
}
|
||||
|
||||
// Check dependencies
|
||||
// Dependencies required to load
|
||||
const requires = [];
|
||||
|
||||
// Dependencies available
|
||||
const available = [];
|
||||
|
||||
logger.debug('Loading plugin', plugin.name);
|
||||
for (const dep of plugin.dependencies) {
|
||||
|
||||
// Check dependencies
|
||||
for (let dep of plugin.dependencies) {
|
||||
let optional = false;
|
||||
if (dep.indexOf('?') !== -1) {
|
||||
dep = dep.replace('?', '');
|
||||
optional = true;
|
||||
}
|
||||
|
||||
if (dep === plugin.name) {
|
||||
throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`);
|
||||
}
|
||||
|
||||
const existing = this.getLoadedByName(dep);
|
||||
if (!existing) {
|
||||
const available = this.getAvailableByName(dep);
|
||||
if (!available) {
|
||||
if (existing) {
|
||||
available.push(existing.manifest);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isLoaded = this.getAvailableByName(dep);
|
||||
if (!isLoaded) {
|
||||
if (optional) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`);
|
||||
}
|
||||
requires.push(available);
|
||||
}
|
||||
|
||||
requires.push(isLoaded);
|
||||
}
|
||||
|
||||
// Load dependencies
|
||||
@ -141,6 +172,7 @@ export class PluginManager {
|
||||
for (const manifest of requires) {
|
||||
try {
|
||||
await this.load(manifest);
|
||||
available.push(manifest);
|
||||
} catch (e) {
|
||||
logger.error(e.stack);
|
||||
throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`);
|
||||
@ -216,13 +248,36 @@ export class PluginManager {
|
||||
this.stream.emit('pluginLoaded', loaded);
|
||||
|
||||
// Inform the new plugin that it's dependencies are available
|
||||
for (const depn of plugin.dependencies) {
|
||||
this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn));
|
||||
for (const depn of available) {
|
||||
this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn.name));
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public async restart(mf: IPluginManifest | IPlugin | string): Promise<void> {
|
||||
let manifest;
|
||||
if (typeof mf === 'string') {
|
||||
manifest = this.getAvailableByName(mf);
|
||||
} else if ('manifest' in mf) {
|
||||
manifest = mf.manifest;
|
||||
} else {
|
||||
manifest = mf;
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error('Plugin not found');
|
||||
}
|
||||
|
||||
if (!this.getLoadedByName(manifest.name)) {
|
||||
this.load(manifest);
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartQueue.set(manifest.name, manifest);
|
||||
this.stream.emitTo(manifest.name, 'pluginUnload', manifest.name);
|
||||
}
|
||||
|
||||
private addEvents(): void {
|
||||
this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => {
|
||||
if (typeof mf === 'string') {
|
||||
@ -253,6 +308,13 @@ export class PluginManager {
|
||||
|
||||
// Remove all listeners created by the plugin
|
||||
this.stream.removeName(mf);
|
||||
|
||||
// Restart, if applicable
|
||||
if (this.restartQueue.has(mf) && !this.stopping) {
|
||||
const manifest = this.restartQueue.get(mf) as IPluginManifest;
|
||||
this.restartQueue.delete(mf);
|
||||
this.load(manifest).catch(e => console.error(e));
|
||||
}
|
||||
});
|
||||
|
||||
this.stream.on('core', 'pluginKill', (mf: IPlugin | string) => {
|
||||
@ -271,6 +333,9 @@ export class PluginManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent loading of new plugins
|
||||
this.stopping = true;
|
||||
|
||||
logger.debug('Shutdown has been received by plugin manager');
|
||||
|
||||
// Shutting down all the plugins
|
||||
|
@ -135,6 +135,10 @@ export class RepositoryManager {
|
||||
throw new Error('Invalid metadata file for repository.');
|
||||
}
|
||||
|
||||
if (!(/^[a-zA-Z0-9_\-\+]+$/.test(meta.name))) {
|
||||
throw new Error('Illegal name for repository!');
|
||||
}
|
||||
|
||||
if (meta.schema > 1) {
|
||||
throw new Error('Unsupported metadata version!');
|
||||
}
|
||||
@ -228,6 +232,11 @@ export class RepositoryManager {
|
||||
if (!contents.name || !contents.url || !contents.plugins) {
|
||||
throw new Error('Invalid repository file ' + rf);
|
||||
}
|
||||
|
||||
if (!(/^[a-zA-Z0-9_\-\+]+$/.test(contents.name))) {
|
||||
throw new Error(`"${rf}" is an illegal name for a repository!`);
|
||||
}
|
||||
|
||||
loaded.push(contents.name);
|
||||
this.repositories.set(contents.name, contents);
|
||||
} catch (e) {
|
||||
|
@ -2,5 +2,6 @@ export * from './config';
|
||||
export * from './environment';
|
||||
export * from './plugin-config';
|
||||
export * from './message';
|
||||
export * from './message-format';
|
||||
export * from './protocol';
|
||||
export * from './service';
|
||||
|
213
src/types/message-format.ts
Normal file
213
src/types/message-format.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/* Recommended usage
|
||||
16-bit colors:
|
||||
black
|
||||
darkblue
|
||||
green
|
||||
red
|
||||
brown
|
||||
purple
|
||||
gold
|
||||
yellow
|
||||
limegreen
|
||||
cyan
|
||||
lightblue
|
||||
blue
|
||||
pink
|
||||
darkgray
|
||||
gray
|
||||
white
|
||||
or any hex value prepended with #
|
||||
|
||||
Formats:
|
||||
bold
|
||||
italic
|
||||
emphasis
|
||||
strike
|
||||
underline
|
||||
*/
|
||||
|
||||
import { thousandsSeparator, timeSince, toHHMMSS } from '../common';
|
||||
|
||||
// Color Utilities
|
||||
|
||||
export function colorDistance(s: string, t: string): number {
|
||||
if (!s.length || !t.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return colorDistance(s.slice(2), t.slice(2)) +
|
||||
Math.abs(parseInt(s.slice(0, 2), 16) - parseInt(t.slice(0, 2), 16));
|
||||
}
|
||||
|
||||
export function approximateB16Color(cl: string): string {
|
||||
const arr = [];
|
||||
let closest = 'white';
|
||||
|
||||
for (const i in b16Colors) {
|
||||
arr.push(b16Colors[i].substring(1));
|
||||
}
|
||||
|
||||
cl = cl.substring(1);
|
||||
|
||||
arr.sort((a, b) => {
|
||||
return colorDistance(a, cl) - colorDistance(b, cl);
|
||||
});
|
||||
|
||||
for (const i in b16Colors) {
|
||||
if (b16Colors[i] === '#' + arr[0]) {
|
||||
closest = i;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
export function b16toHex(name: string): string {
|
||||
if (!b16Colors[name]) {
|
||||
return '#000000';
|
||||
}
|
||||
|
||||
return b16Colors[name];
|
||||
}
|
||||
|
||||
interface IMethod {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
declare type Keyed = {[key: string]: string};
|
||||
declare type Method = {[key: string]: IMethod};
|
||||
|
||||
const b16Colors: Keyed = {
|
||||
black: '#000000',
|
||||
darkblue: '#00007f',
|
||||
green: '#009300',
|
||||
red: '#ff0000',
|
||||
brown: '#7f0000',
|
||||
purple: '#9c009c',
|
||||
gold: '#fc7f00',
|
||||
yellow: '#ffff00',
|
||||
limegreen: '#00fc00',
|
||||
cyan: '#00ffff',
|
||||
lightblue: '#0000fc',
|
||||
blue: '#009393',
|
||||
pink: '#ff00ff',
|
||||
darkgray: '#7f7f7f',
|
||||
gray: '#d2d2d2',
|
||||
white: '#ffffff'
|
||||
};
|
||||
|
||||
export class Formatter {
|
||||
public colors: Keyed = {};
|
||||
public formatting: Method = {};
|
||||
public colorEscape = '';
|
||||
constructor(public supportFormatting = false, public supportColors = false) {}
|
||||
|
||||
public color(color: string, msg: string): string {
|
||||
return msg;
|
||||
}
|
||||
|
||||
public format(method: string, msg: string): string {
|
||||
if (!this.supportFormatting) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (!this.formatting[method]) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (this.formatting[method].start && this.formatting[method].end) {
|
||||
return this.formatting[method].start + msg + this.formatting[method].end;
|
||||
} else {
|
||||
return this.formatting[method] + msg + this.formatting[method];
|
||||
}
|
||||
}
|
||||
|
||||
public strip(msg: string): string {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Object compositor.
|
||||
// This default function turns objects into plain text without any formatting.
|
||||
// Override this in your protocol for the desired effect.
|
||||
/*
|
||||
[
|
||||
['element type', 'text', { param: value }],
|
||||
...
|
||||
]
|
||||
|
||||
Element types:
|
||||
field - A field type
|
||||
Parameters:
|
||||
* label - Label for this field. If an array, the first item is considered an "icon" and wont have ':' appended to it.
|
||||
* type - The type of the field.
|
||||
title - A title field
|
||||
description - Descriptive field
|
||||
metric - A Number value. Requires integer, will be transformed into xxx,xxx,xxx
|
||||
time - Time value. Requires UNIX timestamp.
|
||||
timesince - Time since value. Requires UNIX timestamp, will be transformed into x seconds/minutes/hours/days ago
|
||||
duration - Duration value. Requires integer, will be transformed into HH:MM:SS
|
||||
content - Full message body.
|
||||
* color - The color of the field. Not always supported.
|
||||
bold/b/strong - Bold text
|
||||
i/italic - Italic text
|
||||
color - A colored text. Not always supported.
|
||||
url - An URL.
|
||||
Parameters:
|
||||
* label - Label for this URL
|
||||
image - An Image.
|
||||
*/
|
||||
|
||||
public compose(objs: any): any {
|
||||
const str = [];
|
||||
|
||||
for (const i in objs) {
|
||||
const elem = objs[i];
|
||||
|
||||
const elemType = elem[0];
|
||||
let elemValue = elem[1];
|
||||
const elemParams = elem[2];
|
||||
|
||||
if (!elemValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special types
|
||||
if (elemParams && elemParams.type) {
|
||||
switch (elemParams.type) {
|
||||
case 'time':
|
||||
elemValue = new Date(elemValue).toString();
|
||||
break;
|
||||
case 'metric':
|
||||
elemValue = thousandsSeparator(elemValue);
|
||||
break;
|
||||
case 'timesince':
|
||||
elemValue = timeSince(elemValue);
|
||||
break;
|
||||
case 'duration':
|
||||
elemValue = toHHMMSS(elemValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (elemParams && elemParams.label) {
|
||||
let label = elemParams.label;
|
||||
|
||||
// If the label param is an array, choose the last element
|
||||
// The last element is generally the text version, as opposed to
|
||||
// the first element being an icon.
|
||||
if (typeof label === 'object') {
|
||||
label = elemParams.label[elemParams.label.length - 1];
|
||||
}
|
||||
|
||||
str.push(elemParams.label + ': ' + elemValue);
|
||||
} else {
|
||||
str.push(elemValue);
|
||||
}
|
||||
}
|
||||
|
||||
// May return an object, but your protocol must support it.
|
||||
return str.join(' ');
|
||||
}
|
||||
}
|
||||
|
@ -3,9 +3,28 @@ import { Protocol } from './protocol';
|
||||
|
||||
// TODO: Source specification to support plugin services.
|
||||
|
||||
export enum EMessageType {
|
||||
message = 0,
|
||||
roomJoin = 1,
|
||||
roomLeave = 2,
|
||||
roomKick = 3,
|
||||
nameChange = 4,
|
||||
edit = 5,
|
||||
}
|
||||
|
||||
export interface IMessageTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IMessage {
|
||||
type: EMessageType;
|
||||
data: any;
|
||||
source: IPlugin | Protocol;
|
||||
guest?: boolean;
|
||||
target?: IMessageTarget;
|
||||
sender?: IMessageTarget;
|
||||
time: Date;
|
||||
resolved: boolean;
|
||||
resolve(...args: any[]): void;
|
||||
}
|
||||
|
@ -3,10 +3,14 @@ import { randomBytes } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
import { IPlugin } from '../plugin';
|
||||
import { IMessage } from './message';
|
||||
import { Formatter } from './message-format';
|
||||
|
||||
export class Protocol extends EventEmitter {
|
||||
// override this!
|
||||
public format: Formatter = new Formatter(false, false);
|
||||
|
||||
public id = randomBytes(4).toString('hex');
|
||||
|
||||
// override this!
|
||||
public type = 'GenericProtocol';
|
||||
|
||||
protected running = false;
|
||||
@ -52,6 +56,9 @@ export class Protocol extends EventEmitter {
|
||||
}
|
||||
|
||||
public resolve(message: IMessage, ...data: any[]): void {}
|
||||
public get fullName(): string {
|
||||
return this.plugin.manifest.name + '/' + this.name;
|
||||
}
|
||||
|
||||
protected passEvents(): void {
|
||||
this.on('stop', (force) => this.stop(force));
|
||||
|
Loading…
Reference in New Issue
Block a user