import util from 'util'; import tls, { TLSSocket } from 'tls'; import net, { Socket } from 'net'; import { IIRCLine, parse } from './parser'; import { EventEmitter } from 'events'; 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; } declare type ConnectSocket = TLSSocket | Socket; export class IRC extends EventEmitter { public alive = false; private authenticated = false; private serverData: {[key: string]: any} = { name: '', supportedModes: {}, serverSupports: {}, }; private queue: any[] = []; private channels: string[] = []; private nickservStore: {[key: string]: any} = {}; 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) => { 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) => { const splitline = line.trailing!.split(' '); if (splitline![1] !== '0') { this.nickservStore[data.nickname] = { result: true, checked: Date.now(), }; data.func(true); } else { this.nickservStore[data.nickname] = { result: false, checked: Date.now(), }; data.func(false); } } }); 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; for (const i in this.queue) { const entry = this.queue[i]; if (entry.await && line.command === entry.await) { if (entry.from && line.user.nickname.toLowerCase() === entry.from.toLowerCase()) { if (entry.do) { skipHandling = true; this.queue.splice(parseInt(i, 10), 1); entry.do(line); } } } } 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]; 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': const argv = line.arguments!.slice(1); for (const a in argv) { let t: any = argv[a]; if (t.indexOf('=') !== -1) { t = t.split('='); if (t[0] === 'PREFIX') { const d = t[1].match(/\((\w+)\)(.*)/); const r = d![1].split(''); const aa = d![2].split(''); for (const b in r) { this.serverData.supportedModes[r[b]] = aa[b]; } } else if (t[0] === 'NETWORK') { this.serverData.network = t[1]; } else if (t[0] === 'CHANNELLEN') { this.serverData.maxChannelLength = parseInt(t[1], 10); } if (!isNaN(parseInt(t[1], 10))) { t[1] = parseInt(t[1], 10); } this.serverData.serverSupports[t[0]] = t[1]; } else { this.serverData.serverSupports[t] = 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]; } 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) { 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; }); } }