service-irc/irc/irc.ts

462 lines
12 KiB
TypeScript

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;
});
}
}