service-irc/irc/plugin.ts

319 lines
8.0 KiB
TypeScript

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<boolean> {
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<boolean> {
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;