520 lines
14 KiB
TypeScript
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);
|
||
|
}
|
||
|
}
|