irclib/src/irc.ts

621 lines
16 KiB
TypeScript

import { IRCConnectionEvents } from './types/events';
import {
IWritableEventEmitter,
IIRCConnector,
IIRCConnectorConstructor,
IIRCWrapper,
} 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 IRCConnection
extends TypedEventEmitter<IRCConnectionEvents>
implements IIRCWrapper
{
public channels: string[] = [];
public queue: IQueue[] = [];
public authenticated = false;
public serverData: IIRCServerData = {
name: '',
supportedModes: {},
serverSupports: {},
};
private connection?: IIRCConnector;
private _supportsDone = false;
private _lastLineWasSupports = false;
constructor(
public options: IIRCOptions,
public connector: IIRCConnectorConstructor,
) {
super();
if (!options.username) {
options.username = options.nick;
}
this.handlers();
}
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)) {
this.emit('line', line, true);
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);
}
}
this.emit('line', line, false);
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 '432': {
// ERR_ERRONEUSNICKNAME
// No backing away from this, we don't know what is wrong exactly.
if (!this.authenticated) {
this.emit('error', {
error: new Error('Invalid nickname'),
fatal: true,
});
this.connection?.destroy();
return;
}
const resetNick = line.arguments[0];
this.emit('nick', {
oldNick: this.options.nick,
newNick: resetNick,
});
this.options.nick = resetNick;
break;
}
case '433': {
// ERR_NICKNAMEINUSE
const newNick = this.options.nick + '_';
this.write('NICK %s', newNick);
this.emit('nick', {
oldNick: this.options.nick,
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.useCollector(
new WhoisCollector((lines) => {
const wholine = lines.find(({ command }) => command === '311');
if (wholine) {
this.options.hostname = wholine.arguments?.[3];
}
}, this.options.nick),
'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;
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;
}
}
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.handleServerLine(parsedLine);
});
await this.connection.connect();
this.authenticate();
}
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);
});
}
public get connected() {
return this.connection?.connected ?? false;
}
public setNick(newNick: string): void {
this.write('NICK %s', newNick);
this.emit('nick', {
oldNick: this.options.nick,
newNick,
});
this.options.nick = newNick;
}
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,
);
});
}
public async whois(nick: string): Promise<WhoisResponse> {
return new Promise((resolve) => {
this.useCollector(
new WhoisCollector((data) => resolve(parseWhois(data)), nick),
'WHOIS %s',
nick,
);
});
}
public async who(target: string): Promise<WhoResponse[]> {
return new Promise((resolve) => {
this.useCollector(
new WhoCollector((lines) => resolve(parseWho(lines)), target),
'WHO %s',
target,
);
});
}
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,
);
});
}
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',
);
});
}
public useCollector(collector: IQueue, line: string, ...args: any[]) {
this.queue.push(collector);
this.write(line, ...args);
}
}