462 lines
12 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|