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;
|
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');
|
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.');
|
throw new Error('Illegal name.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +23,8 @@ export function requireNoCache(file: string): object | null {
|
|||||||
export class PluginManager {
|
export class PluginManager {
|
||||||
private plugins: Map<string, IPlugin> = new Map();
|
private plugins: Map<string, IPlugin> = new Map();
|
||||||
private configs: PluginConfigurator = new PluginConfigurator(this.environment);
|
private configs: PluginConfigurator = new PluginConfigurator(this.environment);
|
||||||
|
private restartQueue: Map<string, IPluginManifest> = new Map();
|
||||||
|
private stopping = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public availablePlugins: IPluginManifest[],
|
public availablePlugins: IPluginManifest[],
|
||||||
@ -72,6 +74,10 @@ export class PluginManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(/^[a-zA-Z0-9_\-]+$/.test(manifest.name))) {
|
||||||
|
throw new Error('Illegal name for a plugin!');
|
||||||
|
}
|
||||||
|
|
||||||
this.availablePlugins.push(manifest);
|
this.availablePlugins.push(manifest);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -112,28 +118,53 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async load(plugin: IPluginManifest): Promise<IPlugin> {
|
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
|
// Don't load plugins twice
|
||||||
const ready = this.getLoadedByName(plugin.name);
|
const ready = this.getLoadedByName(plugin.name);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
return ready;
|
return ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check dependencies
|
// Dependencies required to load
|
||||||
const requires = [];
|
const requires = [];
|
||||||
|
|
||||||
|
// Dependencies available
|
||||||
|
const available = [];
|
||||||
|
|
||||||
logger.debug('Loading plugin', plugin.name);
|
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) {
|
if (dep === plugin.name) {
|
||||||
throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`);
|
throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = this.getLoadedByName(dep);
|
const existing = this.getLoadedByName(dep);
|
||||||
if (!existing) {
|
if (existing) {
|
||||||
const available = this.getAvailableByName(dep);
|
available.push(existing.manifest);
|
||||||
if (!available) {
|
continue;
|
||||||
throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`);
|
|
||||||
}
|
|
||||||
requires.push(available);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLoaded = this.getAvailableByName(dep);
|
||||||
|
if (!isLoaded) {
|
||||||
|
if (optional) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
requires.push(isLoaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load dependencies
|
// Load dependencies
|
||||||
@ -141,6 +172,7 @@ export class PluginManager {
|
|||||||
for (const manifest of requires) {
|
for (const manifest of requires) {
|
||||||
try {
|
try {
|
||||||
await this.load(manifest);
|
await this.load(manifest);
|
||||||
|
available.push(manifest);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e.stack);
|
logger.error(e.stack);
|
||||||
throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`);
|
throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`);
|
||||||
@ -216,13 +248,36 @@ export class PluginManager {
|
|||||||
this.stream.emit('pluginLoaded', loaded);
|
this.stream.emit('pluginLoaded', loaded);
|
||||||
|
|
||||||
// Inform the new plugin that it's dependencies are available
|
// Inform the new plugin that it's dependencies are available
|
||||||
for (const depn of plugin.dependencies) {
|
for (const depn of available) {
|
||||||
this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn));
|
this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
return loaded;
|
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 {
|
private addEvents(): void {
|
||||||
this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => {
|
this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => {
|
||||||
if (typeof mf === 'string') {
|
if (typeof mf === 'string') {
|
||||||
@ -253,6 +308,13 @@ export class PluginManager {
|
|||||||
|
|
||||||
// Remove all listeners created by the plugin
|
// Remove all listeners created by the plugin
|
||||||
this.stream.removeName(mf);
|
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) => {
|
this.stream.on('core', 'pluginKill', (mf: IPlugin | string) => {
|
||||||
@ -271,6 +333,9 @@ export class PluginManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent loading of new plugins
|
||||||
|
this.stopping = true;
|
||||||
|
|
||||||
logger.debug('Shutdown has been received by plugin manager');
|
logger.debug('Shutdown has been received by plugin manager');
|
||||||
|
|
||||||
// Shutting down all the plugins
|
// Shutting down all the plugins
|
||||||
|
@ -135,6 +135,10 @@ export class RepositoryManager {
|
|||||||
throw new Error('Invalid metadata file for repository.');
|
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) {
|
if (meta.schema > 1) {
|
||||||
throw new Error('Unsupported metadata version!');
|
throw new Error('Unsupported metadata version!');
|
||||||
}
|
}
|
||||||
@ -228,6 +232,11 @@ export class RepositoryManager {
|
|||||||
if (!contents.name || !contents.url || !contents.plugins) {
|
if (!contents.name || !contents.url || !contents.plugins) {
|
||||||
throw new Error('Invalid repository file ' + rf);
|
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);
|
loaded.push(contents.name);
|
||||||
this.repositories.set(contents.name, contents);
|
this.repositories.set(contents.name, contents);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -2,5 +2,6 @@ export * from './config';
|
|||||||
export * from './environment';
|
export * from './environment';
|
||||||
export * from './plugin-config';
|
export * from './plugin-config';
|
||||||
export * from './message';
|
export * from './message';
|
||||||
|
export * from './message-format';
|
||||||
export * from './protocol';
|
export * from './protocol';
|
||||||
export * from './service';
|
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.
|
// 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 {
|
export interface IMessage {
|
||||||
|
type: EMessageType;
|
||||||
data: any;
|
data: any;
|
||||||
source: IPlugin | Protocol;
|
source: IPlugin | Protocol;
|
||||||
|
guest?: boolean;
|
||||||
|
target?: IMessageTarget;
|
||||||
|
sender?: IMessageTarget;
|
||||||
time: Date;
|
time: Date;
|
||||||
|
resolved: boolean;
|
||||||
resolve(...args: any[]): void;
|
resolve(...args: any[]): void;
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,14 @@ import { randomBytes } from 'crypto';
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { IPlugin } from '../plugin';
|
import { IPlugin } from '../plugin';
|
||||||
import { IMessage } from './message';
|
import { IMessage } from './message';
|
||||||
|
import { Formatter } from './message-format';
|
||||||
|
|
||||||
export class Protocol extends EventEmitter {
|
export class Protocol extends EventEmitter {
|
||||||
// override this!
|
public format: Formatter = new Formatter(false, false);
|
||||||
|
|
||||||
public id = randomBytes(4).toString('hex');
|
public id = randomBytes(4).toString('hex');
|
||||||
|
|
||||||
|
// override this!
|
||||||
public type = 'GenericProtocol';
|
public type = 'GenericProtocol';
|
||||||
|
|
||||||
protected running = false;
|
protected running = false;
|
||||||
@ -52,6 +56,9 @@ export class Protocol extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public resolve(message: IMessage, ...data: any[]): void {}
|
public resolve(message: IMessage, ...data: any[]): void {}
|
||||||
|
public get fullName(): string {
|
||||||
|
return this.plugin.manifest.name + '/' + this.name;
|
||||||
|
}
|
||||||
|
|
||||||
protected passEvents(): void {
|
protected passEvents(): void {
|
||||||
this.on('stop', (force) => this.stop(force));
|
this.on('stop', (force) => this.stop(force));
|
||||||
|
Loading…
Reference in New Issue
Block a user