irclib/src/irc.ts

520 lines
14 KiB
TypeScript

import {
IRCCommunicator,
IRCConnector,
IRCConnectorConstructor,
} from './types/impl.interface';
import {
IIRCLine,
IIRCOptions,
INickStore,
IQueue,
} from './types/irc.interfaces';
import { Collector, WhoisCollector } from './utility/collector';
import { parse } from './utility/parser';
import { SimpleEventEmitter } from './utility/simple-event-emitter';
import { parseWhois, WhoisResponse } from './utility/whois-parser';
const encodeBase64 = (input: string) => {
if (window !== undefined && btoa !== undefined) {
return btoa(input);
} else if (Buffer !== undefined) {
return Buffer.from(input).toString('base64');
}
return input;
};
export class IRCConnectionWrapper
extends SimpleEventEmitter
implements IRCCommunicator
{
public channels: string[] = [];
public queue: IQueue[] = [];
public nickservStore: { [key: string]: INickStore } = {};
public authenticated = false;
public serverData: { [key: string]: any } = {
name: '',
supportedModes: {},
serverSupports: {},
};
public 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();
}
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 handleServerLine(line: IIRCLine): void {
if (this.queue.length) {
let skipHandling = false;
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.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;
if (skipHandling) {
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);
}
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 = encodeBase64(
this.options.nick +
'\x00' +
this.options.username +
'\x00' +
this.options.password,
);
this.write('AUTHENTICATE %s', authline);
break;
}
case '353': {
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': {
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':
const newNick = this.options.nick + '_';
this.write('NICK %s', newNick);
this.options.nick = newNick;
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', {
type: line.command.toLowerCase(),
message: line.trailing,
to: line.arguments?.[0],
nickname: line.user.nickname,
raw: line,
});
break;
case '001':
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': {
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];
} 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 '321':
this.emit('channel-list-item', {
channel: 'Channel',
users: 'Users',
topic: 'Topic',
});
break;
case '322':
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) {
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 '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', {
nickname: line.user.nickname,
channel: line.arguments?.[0],
});
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.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();
}
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 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))),
'WHOIS %s',
nick,
);
});
}
public useCollector(collector: IQueue, line: string, ...args: any[]) {
this.queue.push(collector);
this.write(line, ...args);
}
}