import { IRCConnectionEvents } from './types/events'; import { IWritableEventEmitter, IIRCConnector, IIRCConnectorConstructor, IIRCWrapper, } from './types/impl.interface'; import { IIRCLine, IIRCOptions, IIRCServerData, IQueue, } from './types/irc.interfaces'; import { Collector, MultiLineCollector, WhoCollector, WhoisCollector, } from './utility/collector'; import { parse } from './utility/parser'; import { encodeBase64 } from './utility/platform-base64'; import { TypedEventEmitter } from './utility/typed-event-emitter'; import { parseWho, WhoResponse } from './utility/who-parser'; import { parseWhois, WhoisResponse } from './utility/whois-parser'; export class IRCConnection extends TypedEventEmitter implements IIRCWrapper { public channels: string[] = []; public queue: IQueue[] = []; public authenticated = false; public serverData: IIRCServerData = { name: '', supportedModes: {}, serverSupports: {}, }; private connection?: IIRCConnector; private _supportsDone = false; private _lastLineWasSupports = false; constructor( public options: IIRCOptions, public connector: IIRCConnectorConstructor, ) { 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 pumpQueue(line: IIRCLine): boolean { let skipHandling = false; if (this.queue.length) { 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.match || (entry.match && entry.match(line))) && 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; } return skipHandling; } private handleServerLine(line: IIRCLine): void { if (this.pumpQueue(line)) { this.emit('line', line, true); 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); if ( this.options.bot && this.serverData.serverSupports.USERMODES && (this.serverData.serverSupports.USERMODES as string).includes('B') ) { this.write('MODE %s +B', this.options.nick); } } this.emit('line', line, false); switch (line.command.toLowerCase()) { case 'cap': if ( line.trailing.includes('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': { // RPL_NAMEREPLY 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': { // RPL_ENDOFNAMES 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 '432': { // ERR_ERRONEUSNICKNAME // No backing away from this, we don't know what is wrong exactly. if (!this.authenticated) { this.emit('error', { error: new Error('Invalid nickname'), fatal: true, }); this.connection?.destroy(); return; } const resetNick = line.arguments[0]; this.emit('nick', { oldNick: this.options.nick, newNick: resetNick, }); this.options.nick = resetNick; break; } case '433': { // ERR_NICKNAMEINUSE const newNick = this.options.nick + '_'; this.write('NICK %s', newNick); this.emit('nick', { oldNick: this.options.nick, newNick, }); this.options.nick = newNick; break; } case '902': // ERR_NICKLOCKED case '904': // ERR_SASLFAIL case '905': // ERR_SASLTOOLONG this.emit('error', { error: new Error(line.trailing), fatal: true, }); case '903': // RPL_SASLSUCCESS 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': // RPL_WELCOME 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.useCollector( new WhoisCollector((lines) => { const wholine = lines.find(({ command }) => command === '311'); if (wholine) { this.options.hostname = wholine.arguments?.[3]; } }, this.options.nick), 'WHOIS %s', this.options.nick, ); } break; case '005': { // RPL_ISUPPORT 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]; } 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; case '321': // RPL_LISTSTART this.emit('channel-list-item', { channel: 'Channel', users: 'Users', topic: 'Topic', }); break; case '322': // RPL_LIST 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', { type: 'quit', nickname: line.user.nickname, reason: line.trailing, }); } 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', { type: line.command.toLowerCase(), nickname: line.user.nickname, channel: line.arguments?.[0], reason: line.trailing, }); 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.options.connOpts, ); 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.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 setNick(newNick: string): void { this.write('NICK %s', newNick); this.emit('nick', { oldNick: this.options.nick, newNick, }); this.options.nick = newNick; } 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)), nick), 'WHOIS %s', nick, ); }); } public async who(target: string): Promise { return new Promise((resolve) => { this.useCollector( new WhoCollector((lines) => resolve(parseWho(lines)), target), 'WHO %s', target, ); }); } public async names(channel: string): Promise { return new Promise((resolve) => { this.useCollector( new MultiLineCollector('366', ['353'], (lines) => { resolve( [ ...lines .filter(({ command }) => command === '353') .map((cline: IIRCLine) => (cline.trailing || '').split(' ')), ].flat(), ); }), 'NAMES %s', channel, ); }); } public async list(): Promise { return new Promise((resolve) => { this.useCollector( new MultiLineCollector('323', ['322'], (lines) => { resolve( lines .filter(({ command }) => command === '322') .map((line) => [ line.arguments[1], line.arguments[2], line.trailing, ]), ); }), 'LIST', ); }); } public useCollector(collector: IQueue, line: string, ...args: any[]) { this.queue.push(collector); this.write(line, ...args); } }