import util from 'util'; import { Plugin, EventListener, Configurable, InjectService, Auto } from '@squeebot/core/lib/plugin'; import { EMessageType, Formatter, IMessage, IMessageTarget, Protocol, ProtocolFeatureFlag } from '@squeebot/core/lib/types'; import { logger } from '@squeebot/core/lib/core'; import { IIRCMessage, IRC } from './irc'; import { IRCFormatter } from './format'; class IRCMessage implements IMessage { public time: Date = new Date(); public resolved = false; public direct = false; constructor( public type: EMessageType, public data: any, public source: Protocol, public sender: IMessageTarget, public target?: IMessageTarget, public guest = true) {} public get fullSenderID(): string { return this.source.fullName + '/' + this.sender.id; } public get fullRoomID(): string { if (!this.target) { return this.source.fullName; } return this.source.fullName + '/' + this.target.id; } public get text(): string { return this.data; } public resolve(...args: any[]): void { this.resolved = true; this.source.resolve(this, ...args); } public reject(error: Error): void { this.resolved = true; this.source.resolve(this, error.message); } public mention(user: IMessageTarget): string { return user.name; } } class IRCProtocol extends Protocol { public static Features = [ ProtocolFeatureFlag.SHORT_FORM_MESSAGING, ProtocolFeatureFlag.ACCOUNT_VERIFICATION, ProtocolFeatureFlag.COLORS, ProtocolFeatureFlag.FORMATTING, ProtocolFeatureFlag.PLAIN, ProtocolFeatureFlag.MENTION, ProtocolFeatureFlag.EVENT_MESSAGE, ProtocolFeatureFlag.EVENT_ROOM_JOIN, ProtocolFeatureFlag.EVENT_ROOM_LEAVE, ProtocolFeatureFlag.EVENT_ROOM_NAME_CHANGE, ProtocolFeatureFlag.KICK, ProtocolFeatureFlag.BAN, ProtocolFeatureFlag.MUTE, ]; public format: Formatter = new IRCFormatter(true, true); public type = 'IRCProtocol'; private irc: IRC = new IRC(this.config.irc); private eventsAttached = false; public start(...args: any[]): void { this.runEvents(); this.me.id = this.me.name = this.irc.options.nick; this.irc.connect(); this.running = true; this.emit('running'); } public stop(force = false): void { if (!this.running) { return; } if (this.irc.alive) { this.irc.disconnect(); } this.running = false; this.stopped = true; if (force) { this.failed = true; } this.emit('stopped'); } private async validateNick(nickname: string): Promise { if (!this.config.irc.nickserv || !this.config.irc.nickserv.enabled) { return true; // Assume the user is authentic } return new Promise((resolve) => { let stop = false; const promiseTimeout = setTimeout(() => { stop = true; resolve(false); }, 4000); this.irc.emit('testnick', { nickname, func: (result: boolean) => { clearTimeout(promiseTimeout); if (stop) { return; } resolve(result); } }); }); } private runEvents(): void { if (this.eventsAttached) { return; } this.eventsAttached = true; this.irc.on('authenticated', () => { logger.log('[%s] Instance started successfully.', this.fullName); this.me.id = this.me.name = this.irc.options.nick; }); this.irc.on('error', (errdat) => { if (errdat.fatal) { this.stop(); } logger.error('[%s] Instance error:', this.fullName, errdat.error.message); }); this.irc.on('disconnect', (data) => { logger.warn('[%s] Instance disconnected:', this.fullName, data.message); this.stop(); }); // Pass events from IRC to the main channel, where it will then be routed // to the list of handler plugins within a configured channel. this.irc.on('leave', (data) => { const left = data.channel ? { id: data.channel, name: data.channel } : undefined; const newMessage = new IRCMessage( EMessageType.roomLeave, {}, this, { id: data.nickname, name: data.nickname }, left); this.plugin.stream.emitTo('channel', 'event', newMessage); }); this.irc.on('join', (data) => { const newMessage = new IRCMessage( EMessageType.roomJoin, {}, this, { id: data.nickname, name: data.nickname }, { id: data.channel, name: data.channel }); this.plugin.stream.emitTo('channel', 'event', newMessage); }); this.irc.on('nick', (data) => { const newMessage = new IRCMessage( EMessageType.nameChange, data.oldNick, this, { id: data.newNick, name: data.newNick }); this.plugin.stream.emitTo('channel', 'event', newMessage); }); this.irc.on('message', (msg: IIRCMessage) => { this.validateNick(msg.nickname).then((valid: boolean) => { const to = msg.to === this.irc.options.nick ? msg.nickname : msg.to; const newMessage = new IRCMessage( EMessageType.message, msg.message, this, { id: msg.nickname, name: msg.nickname }, { id: to, name: to }, valid === false); if (msg.to === this.irc.options.nick) { newMessage.direct = true; } this.plugin.stream.emitTo('channel', 'message', newMessage); }); }); } public resolve(message: IMessage, ...data: any[]): void { const response = this.parseMessage(...data); if (!this.irc.alive || !response || !message.target) { return; } this.irc.message(message.target.id, response); } public async sendTo(target: string, ...data: any[]): Promise { const response = this.parseMessage(...data); if (!this.irc.alive || !response) { return false; } const rxSplit = target.split('/'); if (rxSplit.length !== 3 || rxSplit[0] !== 'irc' || rxSplit[1] !== this.name) { return false; } this.irc.message(rxSplit[2], response); return true; } private parseMessage(...data: any[]): string | null { let response = util.format(data[0], ...data.slice(1)); if (!response) { return null; } if (Array.isArray(data[0])) { try { response = this.format.compose(data[0]); } catch (e: any) { logger.error('[%s] Failed to compose message:', this.fullName, e.message); return null; } } return response; } } @InjectService(IRCProtocol) @Configurable({ instances: [] }) class IRCServicePlugin extends Plugin { @Auto() initialize(): void { const protoList = this.validateConfiguration(); this.startAll(protoList); } private startAll(list: any[]): void { for (const ins of list) { const newProto = new IRCProtocol(this, ins, IRCProtocol.Features); logger.log('[%s] Starting IRC service "%s".', this.name, ins.name); this.monitor(newProto); this.service?.use(newProto, true); } } private monitor(proto: Protocol): void { proto.on('running', () => this.emit('protocolNew', proto)); proto.on('stopped', () => this.emit('protocolExit', proto)); } private validateConfiguration(): any[] { if (!this.config.config.instances) { throw new Error('Configuration incomplete!'); } const instances = this.config.config.instances; const runnables: any[] = []; for (const ins of instances) { if (!ins.name || !ins.irc) { throw new Error('Invalid instance configuration!'); } const irc = ins.irc; if (!irc.nick || !irc.host) { logger.warn('[%s] Instance named %s was skipped for invalid configuration.', this.name, ins.name); continue; } runnables.push(ins); } return runnables; } @EventListener('pluginUnload') unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { logger.debug('[%s]', this.name, 'shutting down..'); this.service?.stopAll().then(() => this.emit('pluginUnloaded', this)); } } } module.exports = IRCServicePlugin;