irclib/src/irc.ts

644 lines
17 KiB
TypeScript

import { IRCCommunicatorEvents } from './types/events';
import {
IRCCommunicator,
IRCConnector,
IRCConnectorConstructor,
} 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 IRCConnectionWrapper
extends TypedEventEmitter<IRCCommunicatorEvents>
implements IRCCommunicator
{
/**
* Channels the bot is currently in.
*/
public channels: string[] = [];
/**
* Current collectors waiting for their reply.
*/
public queue: IQueue[] = [];
/**
* Login success status.
*/
public authenticated = false;
/**
* Information about the IRC server gathered during runtime
*/
public serverData: IIRCServerData = {
name: '',
supportedModes: {},
serverSupports: {},
};
private connection?: IRCConnector;
private _supportsDone = false;
private _lastLineWasSupports = false;
constructor(
public options: IIRCOptions,
public connector: IRCConnectorConstructor,
) {
super();
if (!options.username) {
options.username = options.nick;
}
this.handlers();
}
/**
* Send a raw command to the server.
*
* **WARNING:** Line break characters could have an unintended side-effect!
* Filter user-generated content!
* @param format Command
* @param args Command arguments
*/
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)) {
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);
}
}
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 '433': // ERR_NICKNAMEINUSE
const newNick = this.options.nick + '_';
this.write('NICK %s', 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.write('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;
// Set hostname from self-whois
case '311': // RPL_WHOISUSER
if (line.arguments?.[1] !== this.options.nick) {
return;
}
this.options.hostname = line.arguments?.[3];
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;
}
}
/**
* Create a new connection to the configured IRC server.
*/
public async connect(): Promise<void> {
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.emit('line', parsedLine);
this.handleServerLine(parsedLine);
});
await this.connection.connect();
this.authenticate();
}
/**
* Disconnect from the IRC server gracefully, sends `QUIT`.
* @param reason Reason for disconnection
*/
public async disconnect(reason?: string): Promise<void> {
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);
});
}
/**
* Get connection status. `authenticated` is a more reliable indicator
* of a successful connection.
*/
public get connected() {
return this.connection?.connected ?? false;
}
/**
* Asynchronously ping the server.
* @returns Ping in milliseconds
*/
public async getPing(): Promise<number> {
return new Promise<number>((resolve) => {
const sendTime = Date.now();
this.useCollector(
new Collector('PONG', (line) => {
resolve(Date.now() - sendTime);
}),
'PING :%s',
this.serverData.name,
);
});
}
/**
* Asynchronous WHOIS query on `nick`
* @param nick Nick to query
* @returns Parsed WHOIS response object
*/
public async whois(nick: string): Promise<WhoisResponse> {
return new Promise((resolve) => {
this.useCollector(
new WhoisCollector((data) => resolve(parseWhois(data)), nick),
'WHOIS %s',
nick,
);
});
}
/**
* Asynchronous WHO query on `target`
* @param target Channel or nick to query
* @returns Parsed WHO response object array
*/
public async who(target: string): Promise<WhoResponse[]> {
return new Promise((resolve) => {
this.useCollector(
new WhoCollector((lines) => resolve(parseWho(lines)), target),
'WHO %s',
target,
);
});
}
/**
* Get a list of names in a channel asynchronously
* @param channel Channel to query
* @returns String list of nicks (with mode prefixes preserved)
*/
public async names(channel: string): Promise<string[]> {
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,
);
});
}
/**
* Get a list of channels asynchronously
* @returns Channel list in `[<channel>, <# visible>, <topic>][]` format
*/
public async list(): Promise<string[][]> {
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',
);
});
}
/**
* Add a collector to the queue.
* This is used to grab lines from the server, wait for them
* and return them in a callback.
* @param collector IRC line collector
* @param line Command to send to the server
* @param args Arguments to the command
*/
public useCollector(collector: IQueue, line: string, ...args: any[]) {
this.queue.push(collector);
this.write(line, ...args);
}
}