Compare commits

...

2 Commits

Author SHA1 Message Date
Evert Prants b27f094b45
More descriptive comments 2024-04-07 22:06:01 +03:00
Evert Prants d2b58fac6f
Refactor logger 2024-04-07 21:26:00 +03:00
11 changed files with 322 additions and 54 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@squeebot/core", "name": "@squeebot/core",
"version": "3.6.3", "version": "3.7.0",
"description": "Squeebot v3 core for the execution environment", "description": "Squeebot v3 core for the execution environment",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",

View File

@ -17,9 +17,9 @@ export interface IChannel {
* list are the handlers. * list are the handlers.
*/ */
export class ChannelManager { export class ChannelManager {
private channels: IChannel[] = []; protected channels: IChannel[] = [];
constructor(private stream: ScopedEventEmitter) {} constructor(protected stream: ScopedEventEmitter) {}
/** /**
* Ensure that the message or event source is a plugin * Ensure that the message or event source is a plugin
@ -68,8 +68,10 @@ export class ChannelManager {
} }
for (const pl of chan.plugins) { for (const pl of chan.plugins) {
if (pl !== source && if (
!(pl.indexOf('/') !== -1 && pl.split('/')[0] === source)) { pl !== source &&
!(pl.indexOf('/') !== -1 && pl.split('/')[0] === source)
) {
this.stream.emitTo(pl, event, data, chan, msr); this.stream.emitTo(pl, event, data, chan, msr);
} }
} }
@ -84,7 +86,10 @@ export class ChannelManager {
* @param source Source protocol of the event * @param source Source protocol of the event
* @returns List of channels to send to * @returns List of channels to send to
*/ */
private getChannelsByPluginName(plugin: string, source: Protocol): IChannel[] { protected getChannelsByPluginName(
plugin: string,
source: Protocol
): IChannel[] {
const list = []; const list = [];
for (const chan of this.channels) { for (const chan of this.channels) {
if (chan.enabled === false) { if (chan.enabled === false) {
@ -108,7 +113,7 @@ export class ChannelManager {
* Validate a preconfigured channel list and add them to the list * Validate a preconfigured channel list and add them to the list
* @param channels Preconfigured channel list * @param channels Preconfigured channel list
*/ */
private addPreconfiguredChannels(channels: IChannel[]): void { protected addPreconfiguredChannels(channels: IChannel[]): void {
for (const chan of channels) { for (const chan of channels) {
if (!chan.name) { if (!chan.name) {
throw new Error('Channel name is mandatory.'); throw new Error('Channel name is mandatory.');
@ -128,7 +133,7 @@ export class ChannelManager {
* @returns Channel or undefined * @returns Channel or undefined
*/ */
public getChannelByName(name: string): IChannel | undefined { public getChannelByName(name: string): IChannel | undefined {
return this.channels.find(c => c.name === name); return this.channels.find((c) => c.name === name);
} }
/** /**

View File

@ -1,6 +1,9 @@
/** /**
* Compare full IDs of rooms or users. * Compare full IDs of rooms or users.
* @example
* // returns true
* fullIDMatcher('foo/bar/#test', 'foo/bar/*')
* @param compare ID to compare * @param compare ID to compare
* @param id ID to compare against * @param id ID to compare against
*/ */

View File

@ -10,15 +10,45 @@ import { ScopedEventEmitter } from '../util';
* Recommended implementation of a squeebot runner implements this interface. * Recommended implementation of a squeebot runner implements this interface.
*/ */
export interface ISqueebotCore { export interface ISqueebotCore {
/**
* Squeebot environment information, mainly paths
*/
environment: IEnvironment; environment: IEnvironment;
/**
* NPM executor. Used to install and upgrade packages programmatically.
*/
npm: NPMExecutor; npm: NPMExecutor;
/**
* Main scoped event stream.
*/
stream: ScopedEventEmitter; stream: ScopedEventEmitter;
/**
* Squeebot plugin manager. Hot load/unload/restart functionality.
*/
pluginManager: PluginManager; pluginManager: PluginManager;
/**
* Squeebot plugin repository manager. Hot install/uninstall/update functionality.
*/
repositoryManager: RepositoryManager; repositoryManager: RepositoryManager;
/**
* Squeebot message channel manager. Used to route events between plugins.
*/
channelManager: ChannelManager; channelManager: ChannelManager;
/**
* Squeebot plugin metadata/manifest loader.
*/
pluginLoader: PluginMetaLoader; pluginLoader: PluginMetaLoader;
/**
* Squeebot main configuration file.
*/
config: Configuration; config: Configuration;
/**
* Initialize all Squeebot components.
* @param autostart Automatically start everything (load plugins, etc)
*/
initialize(autostart: boolean): Promise<void>; initialize(autostart: boolean): Promise<void>;
/**
* Trigger a graceful shutdown.
*/
shutdown(): void; shutdown(): void;
} }

View File

@ -3,7 +3,7 @@ import * as path from 'path';
import { IEnvironment } from '../types/environment'; import { IEnvironment } from '../types/environment';
const dirs: {[key: string]: string} = { const dirs: { [key: string]: string } = {
configurationPath: 'configs', configurationPath: 'configs',
pluginsPath: 'plugins', pluginsPath: 'plugins',
repositoryPath: 'repos', repositoryPath: 'repos',
@ -15,8 +15,11 @@ const dirs: {[key: string]: string} = {
* @param chroot Change bot root to this instead of the path in the environment * @param chroot Change bot root to this instead of the path in the environment
* @returns Squeebot environment * @returns Squeebot environment
*/ */
export async function loadEnvironment(enviroFile = 'squeebot.env.json', chroot?: string): Promise<IEnvironment> { export async function loadEnvironment(
if (!await fs.pathExists(enviroFile)) { enviroFile = 'squeebot.env.json',
chroot?: string
): Promise<IEnvironment> {
if (!(await fs.pathExists(enviroFile))) {
throw new Error('Environment file does not exist.'); throw new Error('Environment file does not exist.');
} }
@ -31,7 +34,7 @@ export async function loadEnvironment(enviroFile = 'squeebot.env.json', chroot?:
env.path = chroot; env.path = chroot;
} }
if (!await fs.pathExists(root)) { if (!(await fs.pathExists(root))) {
throw new Error('Root path does not exist.'); throw new Error('Root path does not exist.');
} }

View File

@ -1,26 +1,78 @@
import util from 'util'; import { format } from 'util';
type LogType = 'info' | 'debug' | 'warn' | 'error';
/** /**
* Logger for all of Squeebot. Use this instead of console.log/warn/error! * Log level
*/
export enum LogLevel {
INFO,
WARN,
ERROR,
DEBUG,
}
export type LogListener = (
logLevel: LogLevel,
...data: any[]
) => void | Promise<void>;
export type ConsoleFunction = (...data: any[]) => void;
export type ConsoleLogFunction = ConsoleFunction;
export type ConsoleWarnFunction = ConsoleFunction;
export type ConsoleErrorFunction = ConsoleFunction;
/**
* Logger for all of Squeebot. Use this instead of console.log/warn/error in your plugins!
*/ */
export class Logger { export class Logger {
private console = [console.log, console.warn, console.error]; /**
* The `console.*` functions used for logging convenience.
*
* Defaults to `console.log`, `console.warn`, `console.error`.
*
* Can really be anything that accepts the input for `node:util.format`.
*/
public console: [
ConsoleLogFunction,
ConsoleWarnFunction,
ConsoleErrorFunction
] = [console.log, console.warn, console.error];
constructor( /**
public timestamp = 'dd/mm/yy HH:MM:ss' * External Logger log event listeners.
) {} */
protected listeners: LogListener[] = [];
public dateFmt(date: Date) { constructor(public timestamp = 'dd/mm/yy HH:MM:ss') {}
return date.toISOString()
.replace(/T/, ' ') public formatDate(date: Date): string {
.replace(/\..+/, ''); return date.toISOString().replace(/T/, ' ').replace(/\..+/, '');
} }
/** /**
* Set node.js readline consideration * Add a logger listener, useful for redirecting logger output somewhere else.
* Will not await any Promises but it will catch unhandled rejections - please do not depend on this.
* @param fn Log listener
*/
public listen(fn: LogListener): void {
this.listeners.push(fn);
}
/**
* Remove a logger listener.
* @param fn Log listener
* @returns nothing
*/
public unlisten(fn: LogListener): void {
const inx = this.listeners.indexOf(fn);
if (inx === -1) return;
this.listeners.splice(inx, 1);
}
/**
* Set node.js readline consideration.
* @param rl Readline instance * @param rl Readline instance
* @see Logger.console - the array modified by this function
* @see Logger.resetConsole - the "undo" to this function
*/ */
public setReadline(rl: any): void { public setReadline(rl: any): void {
for (const index in this.console) { for (const index in this.console) {
@ -34,32 +86,41 @@ export class Logger {
} }
/** /**
* Write out to log * Reset output consoles to default. Useful for dynamically detaching readline.
* @param ltype Logger level * @see Logger.console
* @param data Data to log
*/ */
private write(ltype: LogType, ...data: any[]): void { public resetConsole() {
this.console = [console.log, console.warn, console.error];
}
/**
* Write out to log
* @param logLevel Logger level
* @param data Data to log. Will be sent to `node:util.format`
* @see util.format
*/
public write(logLevel: LogLevel, ...data: any[]): void {
const message = []; const message = [];
let cfunc = this.console[0]; let outputFunction = this.console[0];
if (this.timestamp) { if (this.timestamp) {
message.push(`[${this.dateFmt(new Date())}]`); message.push(`[${this.formatDate(new Date())}]`);
} }
switch (ltype) { switch (logLevel) {
case 'info': case LogLevel.INFO:
message.push('[ INFO]'); message.push('[ INFO]');
break; break;
case 'debug': case LogLevel.DEBUG:
message.push('[DEBUG]'); message.push('[DEBUG]');
break; break;
case 'warn': case LogLevel.WARN:
message.push('[ WARN]'); message.push('[ WARN]');
cfunc = this.console[1]; outputFunction = this.console[1];
break; break;
case 'error': case LogLevel.ERROR:
message.push('[ERROR]'); message.push('[ERROR]');
cfunc = this.console[2]; outputFunction = this.console[2];
break; break;
} }
@ -67,10 +128,13 @@ export class Logger {
let final = data[0]; let final = data[0];
if (data.length > 1) { if (data.length > 1) {
const fargs = data.slice(1); const fargs = data.slice(1);
final = util.format(data[0], ...fargs); final = format(data[0], ...fargs);
} }
message.push(final); message.push(final);
cfunc(...message);
// Notify listeners and output
this.notify(logLevel, ...message);
outputFunction(...message);
} }
/** /**
@ -79,7 +143,7 @@ export class Logger {
* See `console.log` for more information. * See `console.log` for more information.
*/ */
public log(...data: any[]): void { public log(...data: any[]): void {
this.write('info', ...data); this.write(LogLevel.INFO, ...data);
} }
/** /**
@ -88,7 +152,7 @@ export class Logger {
* See `console.warn` for more information. * See `console.warn` for more information.
*/ */
public warn(...data: any[]): void { public warn(...data: any[]): void {
this.write('warn', ...data); this.write(LogLevel.WARN, ...data);
} }
/** /**
@ -97,7 +161,7 @@ export class Logger {
* See `console.log` for more information. * See `console.log` for more information.
*/ */
public info(...data: any[]): void { public info(...data: any[]): void {
this.write('info', ...data); this.write(LogLevel.INFO, ...data);
} }
/** /**
@ -106,7 +170,7 @@ export class Logger {
* See `console.error` for more information. * See `console.error` for more information.
*/ */
public error(...data: any[]): void { public error(...data: any[]): void {
this.write('error', ...data); this.write(LogLevel.ERROR, ...data);
} }
/** /**
@ -115,10 +179,42 @@ export class Logger {
* See `console.log` for more information. * See `console.log` for more information.
*/ */
public debug(...data: any[]): void { public debug(...data: any[]): void {
this.write('debug', ...data); this.write(LogLevel.DEBUG, ...data);
}
/**
* Notify logger listeners about new lines. Catches uncaught errors.
* @param level Log level
* @param data Log data
*/
protected notify(level: LogLevel, ...data: any): void {
for (const listener of this.listeners) {
try {
const resp = listener.call(null, level, ...data);
// Catch Promise errors
if (resp instanceof Promise) {
resp.catch((err) =>
process.stderr.write(
`A Logger listener threw an unhandled rejection: ${err.stack}\r\n`
)
);
}
} catch (err) {
process.stderr.write(
`A Logger listener threw an unhandled error: ${
(err as Error).stack
}\r\n`
);
}
}
} }
} }
// Create singleton for Logger to be used anywhere // Create singleton for Logger to be used anywhere
/**
* Logger for all of Squeebot. Use this instead of console.log/warn/error in your plugins!
*
* This is a global singleton.
*/
const logger = new Logger(); const logger = new Logger();
export { logger }; export { logger };

View File

@ -5,16 +5,19 @@ import { IEnvironment } from '../types/environment';
import { spawnProcess } from '../util/run'; import { spawnProcess } from '../util/run';
/** /**
* Execute NPM commands * Execute NPM commands. Can be extended to implement different package managers.
*/ */
export class NPMExecutor { export class NPMExecutor {
private installed: Record<string, string> = {}; protected installed: Record<string, string> = {};
private packageFile: string = path.join( protected packageFile: string = path.join(
this.environment.path, this.environment.path,
'package.json' 'package.json'
); );
constructor(private environment: IEnvironment, private coreModule: string) {} constructor(
protected environment: IEnvironment,
protected coreModule: string
) {}
/** /**
* Create a package.json file and install the core. * Create a package.json file and install the core.
@ -126,7 +129,7 @@ export class NPMExecutor {
await this.loadPackageFile(); await this.loadPackageFile();
} }
private removeVersionWildcard(str?: string): string | undefined { protected removeVersionWildcard(str?: string): string | undefined {
return str?.replace(/\^|>|=/, ''); return str?.replace(/\^|>|=/, '');
} }
} }

View File

@ -2,9 +2,23 @@ import { PluginConfiguration, Service } from '../types';
import { ScopedEventEmitter } from '../util/events'; import { ScopedEventEmitter } from '../util/events';
export interface IPlugin { export interface IPlugin {
/**
* Plugin manifest information.
*/
manifest: IPluginManifest; manifest: IPluginManifest;
/**
* Core scoped event stream.
* This is how you talk to other plugins and the core using events.
*/
stream: ScopedEventEmitter; stream: ScopedEventEmitter;
/**
* Plugin configuration file.
* Note: `@Configurable` decorator is required for the configuration file.
*/
config: PluginConfiguration; config: PluginConfiguration;
/**
* Service provider, only used for Protocol service plugins, such as `irc`.
*/
service: Service | null; service: Service | null;
} }
@ -18,44 +32,102 @@ export class Plugin implements IPlugin {
constructor( constructor(
public manifest: IPluginManifest, public manifest: IPluginManifest,
public stream: ScopedEventEmitter, public stream: ScopedEventEmitter,
public config: PluginConfiguration) {} public config: PluginConfiguration
) {}
/** /**
* Called when plugin first starts. * Called when plugin first starts.
* Please use this instead of the constructor. *
* Please use this instead of the constructor to set up your plugin,
* as this will be called at the appropriate time for initialization.
*/ */
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
public initialize(): void {} public initialize(): void {}
/**
* Shortcut to the plugin's manifest name.
*/
public get name(): string { public get name(): string {
return this.manifest.name; return this.manifest.name;
} }
/**
* Shortcut to the plugin's manifest version.
*/
public get version(): string { public get version(): string {
return this.manifest.version; return this.manifest.version;
} }
/**
* Shortcut to the core event stream.
* @param name Event name
* @param fn Listener
*/
protected addEventListener(name: string, fn: any): void { protected addEventListener(name: string, fn: any): void {
this.stream.on(this.name, name, fn); this.stream.on(this.name, name, fn);
} }
/**
* Shortcut to the core stream event emitter.
* @param event Event name
* @param data Data
*/
protected emit(event: string, data: any): void { protected emit(event: string, data: any): void {
this.stream.emit.call(this.stream, event, data); this.stream.emit.call(this.stream, event, data);
} }
/**
* Shortcut to the core stream event emitter, named variant.
* Emit events to a particular recipient.
* @param name Scope name
* @param event Event name
* @param data Data
*/
protected emitTo(name: string, event: string, data: any): void { protected emitTo(name: string, event: string, data: any): void {
this.stream.emitTo.call(this.stream, name, event, data); this.stream.emitTo.call(this.stream, name, event, data);
} }
} }
/**
* A plugin's manifest JSON.
*/
export interface IPluginManifest { export interface IPluginManifest {
/**
* Source repository name.
* Populated automatically, not present in file.
*/
repository: string; repository: string;
/**
* Full path for the plugin directory.
* Populated automatically, not present in file.
*/
fullPath: string; fullPath: string;
/**
* Main file name.
*/
main: string; main: string;
/**
* Plugin name.
*/
name: string; name: string;
/**
* Plugin tags for organization.
*/
tags?: string[]; tags?: string[];
/**
* Plugin version (semver).
*/
version: string; version: string;
/**
* Plugin description.
*/
description: string; description: string;
/**
* Other plugins this plugin depends on.
*/
dependencies: string[]; dependencies: string[];
/**
* NPM packages, including version number, the plugin depends on.
*/
npmDependencies: string[]; npmDependencies: string[];
} }

View File

@ -1,8 +1,26 @@
/**
* Squeebot environment configuration and paths.
*/
export interface IEnvironment { export interface IEnvironment {
/**
* Root path of the current environment.
*/
path: string; path: string;
/**
* Plugins directory path.
*/
pluginsPath: string; pluginsPath: string;
/**
* Repositories directory path.
*/
repositoryPath: string; repositoryPath: string;
/**
* Configuration files directory path.
*/
configurationPath: string; configurationPath: string;
/**
* Environment name.
*/
environment: string; environment: string;
} }

View File

@ -15,6 +15,7 @@ export type IMessageHandler = (data: IMessageData | string, reject: (error: Erro
/** /**
* Staged messaging handler * Staged messaging handler
* @deprecated Do not use for new plugins.
*/ */
export class MessageResolver { export class MessageResolver {
private handlers: IMessageHandler[] = []; private handlers: IMessageHandler[] = [];

View File

@ -4,13 +4,29 @@ import { logger } from '../core';
* Event emitter that can be scoped by plugin name or core. * Event emitter that can be scoped by plugin name or core.
*/ */
export class ScopedEventEmitter { export class ScopedEventEmitter {
private listeners: {[key: string]: any[]}; protected listeners: {[key: string]: any[]};
/**
* Add an event listener on `name` scope for event `event`. Alias of `on`.
* @param name Event scope
* @param event Event name
* @param func Listener
* @param once Listen once
* @see on
*/
public addEventListener = this.on; public addEventListener = this.on;
constructor() { constructor() {
this.listeners = {}; this.listeners = {};
} }
/**
* Add an event listener on `name` scope for event `event`.
* @param name Event scope
* @param event Event name
* @param func Listener
* @param once Listen once
*/
public on(name: string, event: string, func: any, once = false): void { public on(name: string, event: string, func: any, once = false): void {
if (!func || !event || !name) { if (!func || !event || !name) {
throw new Error('missing arguments'); throw new Error('missing arguments');
@ -25,10 +41,21 @@ export class ScopedEventEmitter {
}); });
} }
/**
* Add an one-off event listener on `name` scope for event `event`.
* @param name Event scope
* @param event Event name
* @param func Listener
*/
public once(name: string, event: string, func: any): void { public once(name: string, event: string, func: any): void {
this.on(name, event, func, true); this.on(name, event, func, true);
} }
/**
* Emit an event `event` to all listeners in all scopes.
* @param event Event name
* @param args Data
*/
public emit(event: string, ...args: any[]): void { public emit(event: string, ...args: any[]): void {
for (const name in this.listeners) { for (const name in this.listeners) {
for (const i in this.listeners[name]) { for (const i in this.listeners[name]) {
@ -53,6 +80,12 @@ export class ScopedEventEmitter {
} }
} }
/**
* Emit an event `event` to listeners listening for scope `name`.
* @param name Scope name
* @param event Event name
* @param args Data
*/
public emitTo(name: string, event: string, ...args: any[]): void { public emitTo(name: string, event: string, ...args: any[]): void {
if (!this.listeners[name]) { if (!this.listeners[name]) {
return; return;
@ -80,6 +113,10 @@ export class ScopedEventEmitter {
} }
} }
/**
* Remove all listeners for scope `name`.
* @param name Scope name
*/
public removeName(name: string): void { public removeName(name: string): void {
if (this.listeners[name]) { if (this.listeners[name]) {
delete this.listeners[name]; delete this.listeners[name];