import util from 'util'; import tls, { TLSSocket } from 'tls'; import net, { Socket } from 'net'; import { IIRCLine, parse } from './parser'; import { EventEmitter } from 'events'; import { logger } from '@squeebot/core/lib/core'; const MAXMSGLEN = 512; export interface IIRCOptions { nick: string; host: string; username?: string; hostname?: string; port?: number; password?: string | null; sasl?: boolean; ssl?: boolean; channels: string[]; nickserv: {[key: string]: any}; } export interface IIRCMessage { message: string; to: string; nickname: string; raw: IIRCLine; } export interface IQueue { await: string; from: string; do: (line: IIRCLine) => void; } export interface INickStore { checked: number; result: boolean; } declare type ConnectSocket = TLSSocket | Socket; export class IRC extends EventEmitter { public alive = false; public authenticated = false; public serverData: { [key: string]: any } = { name: '', supportedModes: {}, serverSupports: {}, }; public queue: IQueue[] = []; public channels: string[] = []; public nickservStore: { [key: string]: INickStore } = {}; private socket: ConnectSocket | null = null; constructor(public options: IIRCOptions) { super(); if (!this.options.username) { this.options.username = this.options.nick; } } // Chop message into pieces recursively, splitting them at lenoffset public static truncate(msg: string, lenoffset: number): string[] { let pieces: string[] = []; if (msg.length <= lenoffset) { pieces.push(msg); } else { const m1 = msg.substring(0, lenoffset); const m2 = msg.substring(lenoffset); pieces.push(m1); if (m2.length > lenoffset) { pieces = pieces.concat(IRC.truncate(m2, lenoffset)); } else { pieces.push(m2); } } return pieces; } 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 * :Squeebot 3.0 Core', this.options.username); this.write('NICK %s', this.options.nick); this.on('authenticated', () => { this.joinMissingChannels(this.options.channels); }); this.on('testnick', (data: { nickname: string; func: (result: boolean) => void }) => { if (this.nickservStore[data.nickname] != null) { if (this.nickservStore[data.nickname].result === true) { data.func(true); return; } else { if (this.nickservStore[data.nickname].checked < Date.now() - 1800000) { // 30 minutes delete this.nickservStore[data.nickname]; } } } if (this.options.nickserv && this.options.nickserv.enabled && this.options.nickserv.command ) { this.queue.push({ await: 'NOTICE', from: 'NickServ', do: (line: IIRCLine) => { if (!line.trailing) { return data.func(false); } const splitline = line.trailing.trim().split(' '); const authNumber = parseInt(splitline[2], 10); let result = false; if (isNaN(authNumber)) { this.options.nickserv.enabled = false; data.func(false); logger.warn(`[IRC] ${this.options.host} does not seem to support NickServ ${this.options.nickserv.command}`); logger.warn(`[IRC] Their reply was: ${line.trailing}`); return; } if (authNumber > 0) { result = true; } this.nickservStore[data.nickname] = { result, checked: Date.now(), }; data.func(result); } }); this.write('PRIVMSG nickserv :%s %s', this.options.nickserv.command, data.nickname); } }); } public disconnect(): void { if (!this.alive) { return; } this.write('QUIT :%s', 'Squeebot 3.0 Core - IRC Service'); this.alive = false; } public write(...args: any[]): void { const data = util.format.apply(null, [args[0], ...args.slice(1)]); if (!this.alive) { return; } this.socket?.write(data + '\r\n'); } 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.await && line.command === entry.await) { if (entry.from && line.user.nickname.toLowerCase() === entry.from.toLowerCase()) { if (entry.do) { skipHandling = true; entry.do(line); return; } } } afterModifyQueue.push(entry); }); this.queue = afterModifyQueue; if (skipHandling) { return; } } 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 = Buffer.from(this.options.nick + '\x00' + this.options.username + '\x00' + this.options.password) .toString('base64'); this.write('AUTHENTICATE %s', authline); 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', { message: line.trailing, to: line.arguments?.[0], nickname: line.user.nickname, raw: line }); break; case '001': 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] || 'Squeebot'; 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': { 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 'quit': if (line.user.nickname !== this.options.nick) { if (this.nickservStore[line.user.nickname]) { delete this.nickservStore[line.user.nickname]; } this.emit('leave', { nickname: line.user.nickname }); } break; case 'nick': if (line.user.nickname === this.options.nick) { this.options.nick = line.arguments?.[0] || 'unknown'; } else if (this.nickservStore[line.user.nickname]) { delete this.nickservStore[line.user.nickname]; } 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 '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) }); break; } } // Send a message with the max bytelength of 512 in mind for trailing public cmd(command: string, argv: string[], trailing: string): void { const args = argv.join(' '); let resolution: string[] = []; // Prevent newline messages from being sent as a command const fs = trailing.split('\n'); // Predict the length the server is going to split at // :nickname!username@hostname command args :trailing\r\n const header = this.options.nick.length + (this.options.hostname || '').length + (this.options.username || '').length + 4 + 2; const offset = command.length + args.length + 3 + header; // Split the message up into chunks for (const i in fs) { const msg = fs[i]; if (msg.length > MAXMSGLEN - offset) { resolution = resolution.concat(IRC.truncate(msg, MAXMSGLEN - offset)); } else { resolution.push(msg); } } for (const i in resolution) { // Add delay to writes to prevent RecvQ overflow setTimeout(() => { this.write('%s %s :%s', command, args, resolution[i]); }, 1000 * parseInt(i, 10)); } } public message(target: string, message: string): void { this.cmd('PRIVMSG', [target], message); } public notice(target: string, message: string): void { this.cmd('NOTICE', [target], message); } public connect(): void { if (!this.options.host || !this.options.port) { this.emit('error', { error: new Error('No host or port specified!'), fatal: true }); return; } const opts = { port: this.options.port, host: this.options.host, rejectUnauthorized: false }; let connection: ConnectSocket; const connfn = () => { this.alive = true; this.authenticate(); }; // For some reason, tls.connect and net.connect are not // compatible according to TypeScript.. if (this.options.ssl) { connection = tls.connect(opts, connfn); } else { connection = net.connect(opts, connfn); } this.socket = connection; let buffer: any = ''; this.socket?.on('data', (chunk) => { buffer += chunk; const data = buffer.split('\r\n'); buffer = data.pop(); data.forEach((line: string) => { if (line.indexOf('PING') === 0) { this.socket?.write('PONG' + line.substring(4) + '\r\n'); return; } // Emit line as raw this.emit('raw', line); // Parse the line const parsed = parse(line); // Emit the parsed line this.emit('line', parsed); // Handle the line this.handleServerLine(parsed); }); }); this.socket.on('close', (data) => { this.alive = false; this.emit('disconnect', { type: 'sock_closed', raw: data, message: 'Connection closed.' }); this.authenticated = false; }); this.socket.on('error', (data) => { this.alive = false; this.emit('error', { fatal: true, error: new Error(data) }); this.authenticated = false; }); } }