import { IRCCommunicator, IRCConnector, IRCConnectorConstructor, } from './types/impl.interface'; import { IIRCLine, IIRCOptions, IQueue } from './types/irc.interfaces'; import { Collector, WhoisCollector } from './utility/collector'; import { parse } from './utility/parser'; import { SimpleEventEmitter } from './utility/simple-event-emitter'; import { parseWhois, WhoisResponse } from './utility/whois-parser'; const encodeBase64 = (input: string) => { if (window !== undefined && btoa !== undefined) { return btoa(input); } else if (Buffer !== undefined) { return Buffer.from(input).toString('base64'); } return input; }; export class IRCConnectionWrapper extends SimpleEventEmitter implements IRCCommunicator { public channels: string[] = []; public queue: IQueue[] = []; public authenticated = false; public serverData: { [key: string]: any } = { name: '', supportedModes: {}, serverSupports: {}, }; private connection?: IRCConnector; private _supportsDone = false; private _lastLineWasSupports = false; constructor( public options: IIRCOptions, public connector: IRCConnectorConstructor, ) { super(); if (!options.username) { options.username = options.nick; } this.handlers(); } write(format: string, ...args: any[]): void { this.connection?.write(format, ...args); } private handlers(): void { this.on('authenticated', () => { if (this.options.channels?.length) { this.joinMissingChannels(this.options.channels); } }); } private authenticate(): void { if (this.options.sasl) { this.write('CAP REQ :sasl'); } if (this.options.password && !this.options.sasl) { this.write('PASS %s', this.options.password); } this.write( 'USER %s 8 * :%s', this.options.username, this.options.realname || 'realname', ); this.write('NICK %s', this.options.nick); } private joinMissingChannels(arr: string[]): void { if (arr) { for (const i in arr) { let chan = arr[i]; if (chan.indexOf('#') !== 0) { chan = '#' + chan; } if (this.channels.indexOf(chan) === -1) { this.write('JOIN %s', chan); } } } } private handleServerLine(line: IIRCLine): void { if (this.queue.length) { let skipHandling = false; const afterModifyQueue: IQueue[] = []; this.queue.forEach((entry) => { if (!entry.untracked) { if (entry.await && line.command === entry.await) { if ( ((entry.from && line.user.nickname.toLowerCase() === entry.from.toLowerCase()) || !entry.from) && entry.do ) { skipHandling = true; entry.do(line, entry.buffer); return; } } if ( entry.additional && entry.additional.includes(line.command) && entry.digest ) { skipHandling = true; entry.digest(line); } } afterModifyQueue.push(entry); }); this.queue = afterModifyQueue; if (skipHandling) { return; } } if ( this._lastLineWasSupports && !this._supportsDone && line.command !== '005' ) { this._lastLineWasSupports = false; this._supportsDone = true; this.emit('supported-modes', this.serverData.supportedModes); this.emit('server-supports', this.serverData.serverSupports); } switch (line.command.toLowerCase()) { case 'cap': if ( line.trailing === 'sasl' && line.arguments?.[1] === 'ACK' && !this.authenticated ) { this.write('AUTHENTICATE PLAIN'); } break; case '+': case ':+': { if (this.authenticated) { return; } const authline = encodeBase64( this.options.nick + '\x00' + this.options.username + '\x00' + this.options.password, ); this.write('AUTHENTICATE %s', authline); break; } case '353': { const isUpQueued = this.queue.find( (item) => item.await === '366' && item.from === line.arguments![2], ); if (isUpQueued) { isUpQueued.buffer.push(line); } else { this.queue.push({ untracked: true, from: line.arguments![2], await: '366', additional: ['353'], buffer: [line], do: (bline, data) => { this.emit('names', { channel: bline.arguments![1], list: [ ...data.map((cline: IIRCLine) => (cline.trailing || '').split(' '), ), ].flat(), }); }, }); } break; } case '366': { const isUpQueued = this.queue.find( (item) => item.await === '366' && item.from === line.arguments![1], ); if (isUpQueued) { isUpQueued.do(line, isUpQueued.buffer); this.queue.splice(this.queue.indexOf(isUpQueued), 1); } break; } case '433': const newNick = this.options.nick + '_'; this.write('NICK %s', newNick); this.options.nick = newNick; break; case '904': this.emit('error', { error: new Error(line.trailing), fatal: true, }); break; case '903': this.write('CAP END'); break; case 'notice': case 'privmsg': if (!line.user.nickname || line.user.nickname === '') { return; } this.emit('message', { type: line.command.toLowerCase(), message: line.trailing, to: line.arguments?.[0], nickname: line.user.nickname, raw: line, }); break; case '001': if (!this.authenticated) { this.serverData.name = line.user.hostname; this.authenticated = true; // Set nick to what the server actually thinks is our nick this.options.nick = line.arguments?.[0] || this.options.nick; this.authenticated = true; this.emit('authenticated', true); // Send a whois request for self in order to reliably fetch hostname of self this.write('WHOIS %s', this.options.nick); } break; case '005': { this._lastLineWasSupports = true; if (!line.arguments) { break; } const argv = line.arguments?.slice(1); for (const entry of argv) { if (entry.indexOf('=') !== -1) { const t = entry.split('=') as string[]; if (t[0] === 'PREFIX') { const d = t[1].match(/\((\w+)\)(.*)/); if (d) { const r = d[1].split(''); const aa = d[2].split(''); r.forEach((value, index) => { this.serverData.supportedModes[value] = aa[index]; }); } } else if (t[0] === 'NETWORK') { this.serverData.network = t[1]; } else if (t[0] === 'CHANNELLEN') { this.serverData.maxChannelLength = parseInt(t[1], 10); } let numeral: string | number = t[1]; if (!isNaN(parseInt(numeral, 10))) { numeral = parseInt(numeral, 10); } this.serverData.serverSupports[t[0]] = numeral; } else { this.serverData.serverSupports[entry] = true; } } break; } // Set hostname from 396 (non-standard) case '396': this.options.hostname = line.arguments?.[1]; break; // Set hostname from self-whois case '311': if (line.arguments?.[1] !== this.options.nick) { return; } this.options.hostname = line.arguments?.[3]; break; case '321': this.emit('channel-list-item', { channel: 'Channel', users: 'Users', topic: 'Topic', }); break; case '322': this.emit('channel-list-item', { channel: line.arguments![1], users: line.arguments![2], topic: line.trailing, }); break; case 'quit': if (line.user.nickname !== this.options.nick) { this.emit('leave', { nickname: line.user.nickname, }); } break; case 'nick': if (line.user.nickname === this.options.nick) { this.options.nick = line.arguments?.[0] || 'unknown'; } this.emit('nick', { oldNick: line.user.nickname, newNick: line.arguments?.[0], }); break; case 'join': if (line.user.nickname === this.options.nick && line.trailing) { this.channels.push(line.trailing); } this.emit('join', { nickname: line.user.nickname, channel: line.trailing, }); break; case 'mode': let isChannelMode = false; let method = '+'; let lts = line.trailing ? line.trailing.split(' ') : []; if (line.arguments![0].indexOf('#') !== -1) { isChannelMode = true; } let modes: string | string[] = line.arguments![1]; if (!modes && line.trailing !== '') { modes = line.trailing!; } let sender = line.user.nickname; if (sender === '') { sender = line.user.hostname; } method = modes.substring(0, 1); modes = modes.substring(1).split(''); let pass = []; if (isChannelMode) { for (let i in modes) { let mode = modes[i]; let modei = parseInt(i); if (this.serverData.supportedModes[mode]) { this.emit('channel-mode', { type: method, mode: mode, modeTarget: line.arguments![2] ? line.arguments![2 + modei] ? line.arguments![2 + modei] : lts[modei - 1] : lts[modei], ...line, }); } else { pass.push(mode); } } } else { pass = modes; } if (pass.length > 0) { this.emit('user-mode', { type: method, mode: pass.join(''), modeTarget: line.arguments![0], ...line, }); } break; case 'part': case 'kick': if (line.user.nickname === this.options.nick && line.arguments) { const indexAt = this.channels.indexOf(line.arguments[0]); if (indexAt !== -1) { this.channels.splice(indexAt, 1); } } this.emit('leave', { nickname: line.user.nickname, channel: line.arguments?.[0], }); break; case 'error': this.emit('error', { fatal: true, error: new Error(line.raw) }); if (!this.authenticated) { this.connection?.destroy(); this.connection = undefined; } break; } } public async connect(): Promise { if (this.connection) { await this.connection.destroy(); } this.connection = new this.connector( this.options.ssl ?? false, this.options.host, this.options.port, ); this.connection.on('close', (data) => { this.emit('disconnect', { type: 'sock_closed', raw: data, message: 'Connection closed.', }); this.connection = undefined; this.authenticated = false; }); this.connection.on('error', (data) => { this.emit('error', { fatal: true, error: new Error(data) }); this.connection = undefined; this.authenticated = false; }); this.connection.on('data', (line: string) => { const parsedLine = parse(line); this.emit('line', parsedLine); this.handleServerLine(parsedLine); }); await this.connection.connect(); this.authenticate(); } public async disconnect(reason?: string): Promise { if (!this.connected) { if (this.connection) { await this.connection.destroy(); } return; } this.write('QUIT :%s', reason || 'Client exiting'); // Wait for exit return new Promise((resolve) => { const interval = setInterval(() => { if (!this.connected) { clearInterval(interval); resolve(); } }, 100); }); } public get connected() { return this.connection?.connected ?? false; } public async getPing(): Promise { return new Promise((resolve) => { const sendTime = Date.now(); this.useCollector( new Collector('PONG', (line) => { resolve(Date.now() - sendTime); }), 'PING :%s', this.serverData.name, ); }); } public async whois(nick: string): Promise { return new Promise((resolve) => { this.useCollector( new WhoisCollector((data) => resolve(parseWhois(data))), 'WHOIS %s', nick, ); }); } public useCollector(collector: IQueue, line: string, ...args: any[]) { this.queue.push(collector); this.write(line, ...args); } }