tons of useful additions

This commit is contained in:
Evert Prants 2022-09-24 10:49:28 +03:00
parent 7f28f41ca9
commit 146c7ba900
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
21 changed files with 761 additions and 126 deletions

View File

@ -1,7 +1,7 @@
import { format } from 'util';
import { IRCSocketConnector } from './connector/net.connector';
import { IRCConnectionWrapper } from './irc';
import { IIRCOptions } from './types/irc.interfaces';
import { formatstr } from './utility/formatstr';
export class IRCBot extends IRCConnectionWrapper {
constructor(options: IIRCOptions) {
@ -9,11 +9,11 @@ export class IRCBot extends IRCConnectionWrapper {
}
send(to: string, message: string, ...args: any[]) {
this.write('PRIVMSG %s :%s', to, format(message, ...args));
this.write('PRIVMSG %s :%s', to, formatstr(message, ...args));
}
notice(to: string, message: string, ...args: any[]) {
this.write('NOTICE %s :%s', to, format(message, ...args));
this.write('NOTICE %s :%s', to, formatstr(message, ...args));
}
nick(newNick: string) {

View File

@ -1,11 +1,12 @@
import net, { Socket } from 'net';
import tls, { TLSSocket } from 'tls';
import { formatWithOptions } from 'util';
import { ConnectorEvents } from '../types/events';
import { IRCConnector } from '../types/impl.interface';
import { SimpleEventEmitter } from '../utility/simple-event-emitter';
import { formatstr } from '../utility/formatstr';
import { TypedEventEmitter } from '../utility/typed-event-emitter';
export class IRCSocketConnector
extends SimpleEventEmitter
extends TypedEventEmitter<ConnectorEvents>
implements IRCConnector
{
connected = false;
@ -15,12 +16,13 @@ export class IRCSocketConnector
public secure: boolean,
public host: string,
public port?: number,
public connOpts?: Record<string, unknown>,
) {
super();
}
connect(): Promise<void> {
const opts = { host: this.host, port: this.port || 6667 };
const opts = { host: this.host, port: this.port || 6667, ...this.connOpts };
return new Promise((resolve, reject) => {
const onConnect = () => {
@ -49,9 +51,7 @@ export class IRCSocketConnector
}
write(format: string, ...args: any[]): void {
this.socket?.write(
formatWithOptions({ colors: false }, format, ...args) + '\r\n',
);
this.socket?.write(formatstr(format, ...args) + '\r\n');
}
private handle() {
@ -64,7 +64,7 @@ export class IRCSocketConnector
buffer = data.pop() || '';
data.forEach((line: string) => {
if (line.indexOf('PING') === 0) {
if (line.indexOf('PING') === 0 && !this.connOpts?.skipPings) {
this.socket?.write('PONG' + line.substring(4) + '\r\n');
return;
}

View File

@ -1,9 +1,10 @@
import { formatWithOptions } from 'util';
import { ConnectorEvents } from '../types/events';
import { IRCConnector } from '../types/impl.interface';
import { SimpleEventEmitter } from '../utility/simple-event-emitter';
import { formatstr } from '../utility/formatstr';
import { TypedEventEmitter } from '../utility/typed-event-emitter';
export class IRCWebSocketConnector
extends SimpleEventEmitter
extends TypedEventEmitter<ConnectorEvents>
implements IRCConnector
{
connected = false;
@ -13,14 +14,17 @@ export class IRCWebSocketConnector
public secure: boolean,
public host: string,
public port?: number,
public connOpts?: Record<string, unknown>,
) {
super();
}
connect(): Promise<void> {
const url = `ws${this.secure ? 's' : ''}://${this.host}:${
this.port || 6667
}`;
let url = `ws${this.secure ? 's' : ''}://${this.host}:${this.port || 6667}`;
if (this.connOpts?.path) {
url += ('/' + this.connOpts.path) as string;
}
return new Promise((resolve, reject) => {
const onConnect = () => {
@ -46,9 +50,7 @@ export class IRCWebSocketConnector
}
write(format: string, ...args: any[]): void {
this.socket?.send(
formatWithOptions({ colors: false }, format, ...args) + '\r\n',
);
this.socket?.send(formatstr(format, ...args) + '\r\n');
}
private handle() {
@ -60,7 +62,7 @@ export class IRCWebSocketConnector
buffer = data.pop() || '';
data.forEach((line: string) => {
if (line.indexOf('PING') === 0) {
if (line.indexOf('PING') === 0 && !this.connOpts?.skipPings) {
this.socket?.send('PONG' + line.substring(4) + '\r\n');
return;
}
@ -73,7 +75,7 @@ export class IRCWebSocketConnector
this.socket?.addEventListener('close', (err) => {
this.connected = false;
this.socket = undefined;
this.emit('close', err);
this.emit('close', err.reason);
});
}
}

View File

@ -21,6 +21,10 @@ bot.on('server-supports', (supported) => {
console.log(supported);
});
bot.on('supported-modes', (supported) => {
console.log(supported);
});
// bot.on('line', console.log);
bot.on('message', ({ message, to, nickname }) => {
@ -38,14 +42,32 @@ bot.on('message', ({ message, to, nickname }) => {
nickname,
),
);
return;
}
if (message.startsWith('!whois')) {
bot.whois(nickname).then(console.log);
return;
}
if (message.startsWith('!who')) {
bot.who(to).then(console.log);
return;
}
if (message.startsWith('!names')) {
bot.names(to).then(console.log);
return;
}
if (message.startsWith('!list')) {
bot.list().then(console.log);
return;
}
if (message.startsWith('!ping')) {
bot.getPing().then((res) => bot.send(to, `Pong: ${res / 1000}s`));
return;
}
});

View File

@ -1,12 +1,19 @@
export * from './types/events';
export * from './types/irc.interfaces';
export * from './types/impl.interface';
export * from './utility/collector';
export * from './utility/estimate-prefix';
export * from './utility/formatstr';
export * from './utility/mode-from-prefix';
export * from './utility/nickserv-validator';
export * from './utility/parser';
export * from './utility/simple-event-emitter';
export * from './utility/typed-event-emitter';
export * from './utility/truncate';
export * from './utility/user-mapper';
export * from './utility/whois-parser';
export * from './spec/command-replies';
export * from './spec/error-replies';
export * from './irc';

View File

@ -1,12 +1,19 @@
import { IRCCommunicatorEvents } from './types/events';
import {
IRCCommunicator,
IRCConnector,
IRCConnectorConstructor,
} from './types/impl.interface';
import { IIRCLine, IIRCOptions, IQueue } from './types/irc.interfaces';
import { Collector, WhoisCollector } from './utility/collector';
import {
Collector,
MultiLineCollector,
WhoCollector,
WhoisCollector,
} from './utility/collector';
import { parse } from './utility/parser';
import { SimpleEventEmitter } from './utility/simple-event-emitter';
import { TypedEventEmitter } from './utility/typed-event-emitter';
import { parseWho, WhoResponse } from './utility/who-parser';
import { parseWhois, WhoisResponse } from './utility/whois-parser';
const encodeBase64 = (input: string) => {
@ -18,8 +25,9 @@ const encodeBase64 = (input: string) => {
return input;
};
// TODO: typed emitter
export class IRCConnectionWrapper
extends SimpleEventEmitter
extends TypedEventEmitter<IRCCommunicatorEvents>
implements IRCCommunicator
{
public channels: string[] = [];
@ -91,9 +99,9 @@ export class IRCConnectionWrapper
}
}
private handleServerLine(line: IIRCLine): void {
private pumpQueue(line: IIRCLine): boolean {
let skipHandling = false;
if (this.queue.length) {
let skipHandling = false;
const afterModifyQueue: IQueue[] = [];
this.queue.forEach((entry) => {
if (!entry.untracked) {
@ -103,6 +111,7 @@ export class IRCConnectionWrapper
line.user.nickname.toLowerCase() ===
entry.from.toLowerCase()) ||
!entry.from) &&
(!entry.match || (entry.match && entry.match(line))) &&
entry.do
) {
skipHandling = true;
@ -125,10 +134,14 @@ export class IRCConnectionWrapper
});
this.queue = afterModifyQueue;
}
if (skipHandling) {
return;
}
return skipHandling;
}
private handleServerLine(line: IIRCLine): void {
if (this.pumpQueue(line)) {
return;
}
if (
@ -169,8 +182,9 @@ export class IRCConnectionWrapper
break;
}
case '353': {
// RPL_NAMEREPLY
const isUpQueued = this.queue.find(
(item) => item.await === '366' && item.from === line.arguments![2],
(item) => item.await === '366' && item.from === line.arguments[2],
);
if (isUpQueued) {
@ -178,13 +192,13 @@ export class IRCConnectionWrapper
} else {
this.queue.push({
untracked: true,
from: line.arguments![2],
from: line.arguments[2],
await: '366',
additional: ['353'],
buffer: [line],
do: (bline, data) => {
this.emit('names', {
channel: bline.arguments![1],
channel: bline.arguments[1],
list: [
...data.map((cline: IIRCLine) =>
(cline.trailing || '').split(' '),
@ -197,8 +211,9 @@ export class IRCConnectionWrapper
break;
}
case '366': {
// RPL_ENDOFNAMES
const isUpQueued = this.queue.find(
(item) => item.await === '366' && item.from === line.arguments![1],
(item) => item.await === '366' && item.from === line.arguments[1],
);
if (isUpQueued) {
isUpQueued.do(line, isUpQueued.buffer);
@ -206,18 +221,18 @@ export class IRCConnectionWrapper
}
break;
}
case '433':
case '433': // ERR_NICKNAMEINUSE
const newNick = this.options.nick + '_';
this.write('NICK %s', newNick);
this.options.nick = newNick;
break;
case '904':
case '904': // SASL fail
this.emit('error', {
error: new Error(line.trailing),
fatal: true,
});
break;
case '903':
case '903': // SASL success
this.write('CAP END');
break;
case 'notice':
@ -233,7 +248,7 @@ export class IRCConnectionWrapper
raw: line,
});
break;
case '001':
case '001': // RPL_WELCOME
if (!this.authenticated) {
this.serverData.name = line.user.hostname;
this.authenticated = true;
@ -248,6 +263,7 @@ export class IRCConnectionWrapper
}
break;
case '005': {
// RPL_ISUPPORT
this._lastLineWasSupports = true;
if (!line.arguments) {
break;
@ -288,30 +304,32 @@ export class IRCConnectionWrapper
this.options.hostname = line.arguments?.[1];
break;
// Set hostname from self-whois
case '311':
case '311': // RPL_WHOISUSER
if (line.arguments?.[1] !== this.options.nick) {
return;
}
this.options.hostname = line.arguments?.[3];
break;
case '321':
case '321': // RPL_LISTSTART
this.emit('channel-list-item', {
channel: 'Channel',
users: 'Users',
topic: 'Topic',
});
break;
case '322':
case '322': // RPL_LIST
this.emit('channel-list-item', {
channel: line.arguments![1],
users: line.arguments![2],
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;
@ -338,14 +356,14 @@ export class IRCConnectionWrapper
let isChannelMode = false;
let method = '+';
let lts = line.trailing ? line.trailing.split(' ') : [];
if (line.arguments![0].indexOf('#') !== -1) {
if (line.arguments[0].indexOf('#') !== -1) {
isChannelMode = true;
}
let modes: string | string[] = line.arguments![1];
let modes: string | string[] = line.arguments[1];
if (!modes && line.trailing !== '') {
modes = line.trailing!;
modes = line.trailing;
}
let sender = line.user.nickname;
@ -365,9 +383,9 @@ export class IRCConnectionWrapper
this.emit('channel-mode', {
type: method,
mode: mode,
modeTarget: line.arguments![2]
? line.arguments![2 + modei]
? line.arguments![2 + modei]
modeTarget: line.arguments[2]
? line.arguments[2 + modei]
? line.arguments[2 + modei]
: lts[modei - 1]
: lts[modei],
...line,
@ -384,7 +402,7 @@ export class IRCConnectionWrapper
this.emit('user-mode', {
type: method,
mode: pass.join(''),
modeTarget: line.arguments![0],
modeTarget: line.arguments[0],
...line,
});
}
@ -399,8 +417,10 @@ export class IRCConnectionWrapper
}
this.emit('leave', {
type: line.command.toLowerCase(),
nickname: line.user.nickname,
channel: line.arguments?.[0],
reason: line.trailing,
});
break;
case 'error':
@ -422,6 +442,7 @@ export class IRCConnectionWrapper
this.options.ssl ?? false,
this.options.host,
this.options.port,
this.options.connOpts,
);
this.connection.on('close', (data) => {
@ -490,6 +511,11 @@ export class IRCConnectionWrapper
});
}
/**
* 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(
@ -500,6 +526,67 @@ export class IRCConnectionWrapper
});
}
/**
* 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))),
'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',
);
});
}
public useCollector(collector: IQueue, line: string, ...args: any[]) {
this.queue.push(collector);
this.write(line, ...args);

110
src/spec/command-replies.ts Normal file
View File

@ -0,0 +1,110 @@
export const RPL_COMMAND = [
['300', 'RPL_NONE', ''],
['302', 'RPL_USERHOST', ':[<reply>{<space><reply>}]'],
['303', 'RPL_ISON', ':[<nick> {<space><nick>}]'],
['301', 'RPL_AWAY', '<nick> :<away message>'],
['305', 'RPL_UNAWAY', ':You are no longer marked as being away'],
['306', 'RPL_NOWAWAY', ':You have been marked as being away'],
['307', 'RPL_WHOISREGNICK', '<nick> :is a registered nick'],
['311', 'RPL_WHOISUSER', '<nick> <user> <host> * :<real name>'],
['312', 'RPL_WHOISSERVER', '<nick> <server> :<server info>'],
['313', 'RPL_WHOISOPERATOR', '<nick> :is an IRC operator'],
[
'317',
'RPL_WHOISIDLE',
'<nick> <integer> [<integer>] :seconds idle[, signon at]',
],
['318', 'RPL_ENDOFWHOIS', '<nick> :End of /WHOIS list'],
['319', 'RPL_WHOISCHANNELS', '<nick> :{[@|+]<channel><space>}'],
['314', 'RPL_WHOWASUSER', '<nick> <user> <host> * :<real name>'],
['369', 'RPL_ENDOFWHOWAS', '<nick> :End of WHOWAS'],
['321', 'RPL_LISTSTART', 'Channel :Users Name'],
['322', 'RPL_LIST', '<channel> <# visible> :<topic>'],
['323', 'RPL_LISTEND', ':End of /LIST'],
['324', 'RPL_CHANNELMODEIS', '<channel> <mode> <mode params>'],
['324', 'RPL_WHOISACCOUNT', '<nick> <authname> :is logged in as'],
['331', 'RPL_NOTOPIC', '<channel> :No topic is set'],
['332', 'RPL_TOPIC', '<channel> :<topic>'],
['333', 'RPL_TOPICWHOTIME', '<nick> <user> <host> :<integer>'],
['335', 'RPL_WHOISBOT', ':is a bot on network'],
['341', 'RPL_INVITING', '<channel> <nick>'],
['342', 'RPL_SUMMONING', '<user> :Summoning user to IRC'],
['351', 'RPL_VERSION', '<version>.<debuglevel> <server> :<comments>'],
[
'352',
'RPL_WHOREPLY',
'<channel> <user> <host> <server> <nick> <H|G>[*][@|+] :<hopcount> <real name>',
],
['315', 'RPL_ENDOFWHO', '<name> :End of /WHO list'],
['353', 'RPL_NAMREPLY', '<channel> :[[@|+]<nick> [[@|+]<nick> [...]]]'],
['366', 'RPL_ENDOFNAMES', '<channel> :End of /NAMES list'],
['364', 'RPL_LINKS', '<mask> <server> :<hopcount> <server info>'],
['365', 'RPL_ENDOFLINKS', '<mask> :End of /LINKS list'],
['367', 'RPL_BANLIST', '<channel> <banid>'],
['368', 'RPL_ENDOFBANLIST', '<channel> :End of channel ban list'],
['371', 'RPL_INFO', ':<string>'],
['374', 'RPL_ENDOFINFO', ':End of /INFO list'],
['375', 'RPL_MOTDSTART', ':- <server> Message of the day - '],
['372', 'RPL_MOTD', ':- <text>'],
['376', 'RPL_ENDOFMOTD', ':End of /MOTD command'],
['379', 'RPL_WHOISMODES', '<nick> <modes> :is using modes'],
['381', 'RPL_YOUREOPER', ':You are now an IRC operator'],
['382', 'RPL_REHASHING', '<config file> :Rehashing'],
['391', 'RPL_TIME', "<server> :<string showing server's local time>"],
['392', 'RPL_USERSSTART', ':UserID Terminal Host'],
['393', 'RPL_USERS', ':%-8s %-9s %-8s'],
['394', 'RPL_ENDOFUSERS', ':End of users'],
['395', 'RPL_NOUSERS', ':Nobody logged in'],
[
'200',
'RPL_TRACELINK',
'Link <version & debug level> <destination> <next server>',
],
['201', 'RPL_TRACECONNECTING', 'Try. <class> <server>'],
['202', 'RPL_TRACEHANDSHAKE', 'H.S. <class> <server>'],
['203', 'RPL_TRACEUNKNOWN', '???? <class> [<client IP address in dot form>]'],
['204', 'RPL_TRACEOPERATOR', 'Oper <class> <nick>'],
['205', 'RPL_TRACEUSER', 'User <class> <nick>'],
[
'206',
'RPL_TRACESERVER',
'Serv <class> <int>S <int>C <server> <nick!user|*!*>@<host|server>',
],
['208', 'RPL_TRACENEWTYPE', '<newtype> 0 <client name>'],
['261', 'RPL_TRACELOG', 'File <logfile> <debug level>'],
[
'211',
'RPL_STATSLINKINFO',
'<linkname> <sendq> <sent messages> <sent bytes> <received messages> <received bytes> <time open>',
],
['212', 'RPL_STATSCOMMANDS', '<command> <count>'],
['213', 'RPL_STATSCLINE', 'C <host> * <name> <port> <class>'],
['214', 'RPL_STATSNLINE', 'N <host> * <name> <port> <class>'],
['215', 'RPL_STATSILINE', 'I <host> * <host> <port> <class>'],
['216', 'RPL_STATSKLINE', 'K <host> * <username> <port> <class>'],
[
'218',
'RPL_STATSYLINE',
'Y <class> <ping frequency> <connect frequency> <max sendq>',
],
['219', 'RPL_ENDOFSTATS', '<stats letter> :End of /STATS report'],
['241', 'RPL_STATSLLINE', 'L <hostmask> * <servername> <maxdepth>'],
['242', 'RPL_STATSUPTIME', ':Server Up %d days %d:%02d:%02d'],
['243', 'RPL_STATSOLINE', 'O <hostmask> * <name>'],
['244', 'RPL_STATSHLINE', 'H <hostmask> * <servername>'],
['221', 'RPL_UMODEIS', '<user mode string>'],
[
'251',
'RPL_LUSERCLIENT',
':There are <integer> users and <integer> invisible on <integer> servers',
],
['252', 'RPL_LUSEROP', '<integer> :operator(s) online'],
['253', 'RPL_LUSERUNKNOWN', '<integer> :unknown connection(s)'],
['254', 'RPL_LUSERCHANNELS', '<integer> :channels formed'],
['255', 'RPL_LUSERME', ':I have <integer> clients and <integer> servers'],
['256', 'RPL_ADMINME', '<server> :Administrative info'],
['257', 'RPL_ADMINLOC1', ':<admin info>'],
['258', 'RPL_ADMINLOC2', ':<admin info>'],
['259', 'RPL_ADMINEMAIL', ':<admin info>'],
['671', 'RPL_WHOISSECURE', '<nick> [<type>] :is using a secure connection'],
];

59
src/spec/error-replies.ts Normal file
View File

@ -0,0 +1,59 @@
export const RPL_ERROR = [
['401', 'ERR_NOSUCHNICK', '<nickname> :No such nick/channel'],
['402', 'ERR_NOSUCHSERVER', '<server name> :No such server'],
['403', 'ERR_NOSUCHCHANNEL', '<channel name> :No such channel'],
['404', 'ERR_CANNOTSENDTOCHAN', '<channel name> :Cannot send to channel'],
[
'405',
'ERR_TOOMANYCHANNELS',
'<channel name> :You have joined too many channels',
],
['406', 'ERR_WASNOSUCHNICK', '<nickname> :There was no such nickname'],
[
'407',
'ERR_TOOMANYTARGETS',
'<target> :Duplicate recipients. No message delivered',
],
['409', 'ERR_NOORIGIN', ':No origin specified'],
['411', 'ERR_NORECIPIENT', ':No recipient given (<command>)'],
['412', 'ERR_NOTEXTTOSEND', ':No text to send'],
['413', 'ERR_NOTOPLEVEL', '<mask> :No toplevel domain specified'],
['414', 'ERR_WILDTOPLEVEL', '<mask> :Wildcard in toplevel domain'],
['421', 'ERR_UNKNOWNCOMMAND', '<command> :Unknown command'],
['422', 'ERR_NOMOTD', ':MOTD File is missing'],
['423', 'ERR_NOADMININFO', '<server> :No administrative info available'],
['424', 'ERR_FILEERROR', ':File error doing <file op> on <file>'],
['431', 'ERR_NONICKNAMEGIVEN', ':No nickname given'],
['432', 'ERR_ERRONEUSNICKNAME', '<nick> :Erroneus nickname'],
['433', 'ERR_NICKNAMEINUSE', '<nick> :Nickname is already in use'],
['436', 'ERR_NICKCOLLISION', '<nick> :Nickname collision KILL'],
[
'441',
'ERR_USERNOTINCHANNEL',
"<nick> <channel> :They aren't on that channel",
],
['442', 'ERR_NOTONCHANNEL', "<channel> :You're not on that channel"],
['443', 'ERR_USERONCHANNEL', '<user> <channel> :is already on channel'],
['444', 'ERR_NOLOGIN', '<user> :User not logged in'],
['445', 'ERR_SUMMONDISABLED', ':SUMMON has been disabled'],
['446', 'ERR_USERSDISABLED', ':USERS has been disabled'],
['451', 'ERR_NOTREGISTERED', ':You have not registered'],
['461', 'ERR_NEEDMOREPARAMS', '<command> :Not enough parameters'],
['462', 'ERR_ALREADYREGISTRED', ':You may not reregister'],
['463', 'ERR_NOPERMFORHOST', ":Your host isn't among the privileged"],
['464', 'ERR_PASSWDMISMATCH', ':Password incorrect'],
['465', 'ERR_YOUREBANNEDCREEP', ':You are banned from this server'],
['467', 'ERR_KEYSET', '<channel> :Channel key already set'],
['471', 'ERR_CHANNELISFULL', '<channel> :Cannot join channel (+l)'],
['472', 'ERR_UNKNOWNMODE', '<char> :is unknown mode char to me'],
['473', 'ERR_INVITEONLYCHAN', '<channel> :Cannot join channel (+i)'],
['474', 'ERR_BANNEDFROMCHAN', '<channel> :Cannot join channel (+b)'],
['475', 'ERR_BADCHANNELKEY', '<channel> :Cannot join channel (+k)'],
['481', 'ERR_NOPRIVILEGES', ":Permission Denied- You're not an IRC operator"],
['482', 'ERR_CHANOPRIVSNEEDED', "<channel> :You're not channel operator"],
['483', 'ERR_CANTKILLSERVER', ':You cant kill a server!'],
['489', 'ERR_SECUREONLYCHAN', '<channel> :Secure connection required'],
['491', 'ERR_NOOPERHOST', ':No O-lines for your host'],
['501', 'ERR_UMODEUNKNOWNFLAG', ':Unknown MODE flag'],
['502', 'ERR_USERSDONTMATCH', ':Cant change mode for other users'],
];

View File

@ -0,0 +1,92 @@
import { IIRCLine } from './irc.interfaces';
export type ConnectorEvents = {
error: (error: any) => void;
data: (data: string) => void;
close: (reason: string) => void;
};
export type IRCCommunicatorEvents = {
/**
* Parsed line from the IRC server.
*/
line: (line: IIRCLine) => void;
/**
* Supported channel user modes from the server (e.g. `ohv: @%+`)
*/
'supported-modes': (modes: Record<string, unknown>) => void;
/**
* Everything this server supports. See IRC documentation for command `005` or `RPL_ISUPPORT` for more info.
*/
'server-supports': (supports: Record<string, unknown>) => void;
/**
* Nicks in a channel.
*/
names: (data: { channel: string; list: string[] }) => void;
/**
* Error event from the server.
*/
error: (data: { error: Error; fatal: boolean }) => void;
/**
* Message event from the server.
*/
message: (data: {
type: string;
message: string;
to: string;
nickname: string;
raw: IIRCLine;
}) => void;
/**
* Emitted when the login to the server was successful.
*
* **Note:** "login" in this case doesn't account for NickServ or SASL success.
*/
authenticated: (authed: boolean) => void;
'channel-list-item': (data: {
channel: string;
users: string;
topic: string;
}) => void;
/**
* User leaving event. Type can be `quit`, `kick` or `part`.
*/
leave: (data: {
type: string;
nickname: string;
channel?: string;
reason?: string;
}) => void;
/**
* User changed their nickname.
*/
nick: (data: { oldNick: string; newNick: string }) => void;
/**
* User joined a channel you are in.
*/
join: (data: { nickname: string; channel: string }) => void;
/**
* A mode was set on a user in a channel you're in.
*/
'channel-mode': (
data: {
type: string;
mode: string;
modeTarget: string;
} & IIRCLine,
) => void;
/**
* A mode was set on a user, usually you.
*/
'user-mode': (
data: {
type: string;
mode: string;
modeTarget: string;
} & IIRCLine,
) => void;
/**
* Disconnected from the server.
*/
disconnect: (data: { type: string; raw: any; message: string }) => void;
};

View File

@ -11,5 +11,10 @@ export interface IRCConnector extends IRCCommunicator {
}
export interface IRCConnectorConstructor {
new (secure: boolean, host: string, port?: number): IRCConnector;
new (
secure: boolean,
host: string,
port?: number,
opts?: Record<string, unknown>,
): IRCConnector;
}

View File

@ -5,46 +5,145 @@ export interface IIRCUser {
}
export interface IIRCLine {
/**
* Sender information
*/
user: IIRCUser;
/**
* Raw IRC command. You may need to map this to `RPL_ERROR` or `RPL_COMMAND` depending on your needs.
*/
command: string;
arguments?: string[];
trailing?: string;
/**
* Arguments of the IRC command. They appear after the command and before the trailing (` :`)
*/
arguments: string[];
/**
* Trailing of the IRC command. This is the text after ` :` (excluded)
*/
trailing: string;
/**
* Raw line from the server.
*/
raw: string;
}
export interface IUserLine {
command: string;
arguments?: string[];
arguments: string[];
message: string;
}
export interface IQueue<T = any> {
untracked?: boolean;
/**
* Wait for this command from the server.
*/
await: string;
/**
* Append lines with these commands to buffer.
*/
additional?: string[];
/**
* From nickname, used for NOTICE and PRIVMSG collectors.
*/
from?: string;
/**
* Buffer list of additional data.
*/
buffer?: T;
/**
* Resolve the collector.
* @param line The line resolving to `await`
* @param data Additional data in `buffer`, usually only present when using `additional`
*/
do(line: IIRCLine, data?: T): void;
/**
* Match the lines you're looking for.
* @param line Server line
*/
match?(line: IIRCLine): boolean;
/**
* Line matching commands in `additional` will call this function.
* Use it to populate the `buffer`.
* @param line Server line
*/
digest?(line: IIRCLine): void;
}
export interface INickServOptions {
enabled: boolean;
/**
* NickServ login status command.
*/
command: string;
/**
* NickServ bot name, defaults to NickServ.
*/
nickservBot?: string;
/**
* Response command to wait from NickServ, defaults to NOTICE.
*/
responseCommand?: string;
}
export interface IIRCOptions {
/**
* IRC nickname
*/
nick: string;
/**
* IRC server hostname
*/
host: string;
/**
* IRC username
*/
username?: string;
/**
* Your user's hostname, this will be set automatically on connect.
* Setting it manually has no effect.
*/
hostname?: string;
/**
* IRC realname
*/
realname?: string;
/**
* IRC server port
*/
port?: number;
password?: string | null;
/**
* IRC server password.
* Sometimes also used as nickserv password.
*/
password?: string;
/**
* Enable SASL authentication.
*/
sasl?: boolean;
/**
* Enable SSL
*/
ssl?: boolean;
/**
* Set +B mode on self (servers may not all support this!)
*/
bot?: boolean;
/**
* List of channels to join on connect
*/
channels?: string[];
/**
* Additional NickServ options
*/
nickserv?: INickServOptions;
/**
* Additional options for connections, usually passed right along to socket
* without additional alterations.
*
* Special cases for included connections:
* - `path` - `IRCWebSocketConnector` will append this to the WebSocket URL.
* - `skipPings` - Included connectors will not respond to PINGs if set.
*/
connOpts?: Record<string, unknown>;
}

View File

@ -1,10 +1,14 @@
import { IIRCLine, IQueue } from '../types/irc.interfaces';
/**
* Collect a single line from the server.
*/
export class Collector implements IQueue<IIRCLine> {
constructor(
public await: string,
private resolve: (lines: IIRCLine) => void,
public from?: string,
public match?: (line: IIRCLine) => boolean,
) {}
do(line: IIRCLine): void {
@ -12,6 +16,9 @@ export class Collector implements IQueue<IIRCLine> {
}
}
/**
* Collect lines from the server.
*/
export class MultiLineCollector implements IQueue<IIRCLine[]> {
public buffer: IIRCLine[] = [];
@ -19,6 +26,7 @@ export class MultiLineCollector implements IQueue<IIRCLine[]> {
public await: string,
public additional: string[],
private resolve: (lines: IIRCLine[]) => void,
public match?: (line: IIRCLine) => boolean,
) {}
do(line: IIRCLine, data: IIRCLine[]): void {
@ -58,7 +66,10 @@ export class MultiLineCollector implements IQueue<IIRCLine[]> {
* `318` - End of WHOIS
*/
export class WhoisCollector extends MultiLineCollector {
constructor(resolve: (lines: IIRCLine[]) => void) {
constructor(
resolve: (lines: IIRCLine[]) => void,
match?: (line: IIRCLine) => boolean,
) {
super(
'318', // End of WHOIS
[
@ -75,6 +86,7 @@ export class WhoisCollector extends MultiLineCollector {
'317', // Sign on time and idle time
],
resolve,
match,
);
}
}
@ -87,13 +99,17 @@ export class WhoisCollector extends MultiLineCollector {
* `315` - end of WHO
*/
export class WhoCollector extends MultiLineCollector {
constructor(resolve: (lines: IIRCLine[]) => void) {
constructor(
resolve: (lines: IIRCLine[]) => void,
match?: (line: IIRCLine) => boolean,
) {
super(
'315', // End of WHO
[
'352', // WHO line: <channel> <user> <host> <server> <nick> <H|G>[*][@|+] :<hopcount> <real_name>
],
resolve,
match,
);
}
}

View File

@ -0,0 +1,25 @@
import { IIRCOptions } from '../types/irc.interfaces';
/**
* Estimate the length of the message the actual server will send to clients,
* you can use this information to truncate/split your messages.
*
* `:nickname!username@hostname command args :trailing\r\n`
* @param options IRC options (nick, hostname, username)
* @param command Command to be sent
* @param args Arguments for the command
* @returns Predictive length of the message without the trailing
*/
export const estimateMessagePrefixLength = (
options: IIRCOptions,
command: string,
args: string[],
) => {
const header =
options.nick.length +
(options.hostname || '').length +
(options.username || '').length +
4 +
2;
return command.length + args.join(' ').length + 3 + header;
};

47
src/utility/formatstr.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* Basic string formatting, akin to `sprintf` or `util.format`, but limited.
* Only supports `%o` for arrays, `%j` for objects, `%s` for strings and `%d` for numbers.
*
* You should really be using template literals instead (developer's note to self, lol).
* @param fmt Input
* @param args Arguments
* @returns Formatted string
*/
export function formatstr(fmt: string, ...args: any[]) {
const re = /(%?)(%([ojds]))/g;
if (args.length) {
fmt = fmt.replace(re, function (match, escaped, ptn, flag) {
let arg = args.shift();
switch (flag) {
case 'o':
if (Array.isArray(arg)) {
arg = JSON.stringify(arg);
break;
}
case 's':
arg = '' + arg;
break;
case 'd':
arg = Number(arg);
break;
case 'j':
arg = JSON.stringify(arg);
break;
}
if (!escaped) {
return arg;
}
args.unshift(arg);
return match;
});
}
// arguments remain after formatting
if (args.length) {
fmt += ' ' + args.join(' ');
}
fmt = fmt.replace('%%', '%');
return '' + fmt;
}

View File

@ -0,0 +1,15 @@
/**
* Get a channel user mode from their prefix.
* @param prefixOrNick Prefix on its own (`@`, `+`, etc) or a nick `@Diamond`
* @param supportedModes Object of channel modes `{ 'o': '@' }`
* @returns Mode for prefix symbol
*/
export const modeFromPrefix = (
prefixOrNick: string,
supportedModes: Record<string, string>,
) => {
const pfx = prefixOrNick.substring(0, 1);
return Object.keys(supportedModes)[
Object.values(supportedModes).indexOf(pfx)
];
};

View File

@ -1,21 +1,11 @@
import { IIRCLine } from '../types/irc.interfaces';
function parseERROR(line: string[]): IIRCLine {
const final: IIRCLine = {
user: { nickname: '', username: '', hostname: '' },
command: 'ERROR',
trailing: '',
raw: line.join(' '),
};
let pass1 = line.slice(1).join(' ');
if (pass1.indexOf(':') === 0) {
pass1 = pass1.substring(1);
function skipAndLine(line: string[]): string {
let result = line.slice(1).join(' ');
if (result.indexOf(':') === 0) {
result = result.substring(1);
}
final.trailing = pass1;
return final;
return result;
}
export function parse(rawline: string): IIRCLine {
@ -35,8 +25,11 @@ export function parse(rawline: string): IIRCLine {
rawline.indexOf(':') === 0
? rawline.substring(1).split(' ')
: rawline.split(' ');
if (pass1[0] === 'ERROR') {
return parseERROR(pass1);
if (pass1[0] === 'PING' || pass1[0] === 'ERROR') {
final.command = pass1[0];
final.trailing = skipAndLine(pass1);
return final;
}
if (pass1[0].indexOf('!') !== -1) {

View File

@ -1,38 +0,0 @@
export type EventHandler = (...args: any[]) => void;
export class SimpleEventEmitter {
private _handlers: { [x: string]: EventHandler[] } = {};
on(event: string, fn: EventHandler) {
if (typeof fn !== 'function') {
return;
}
if (!this._handlers[event]) {
this._handlers[event] = [];
}
this._handlers[event].push(fn);
}
emit(event: string, ...args: any[]): void {
if (!this._handlers[event]) {
return;
}
this._handlers[event]
.filter((fn) => fn && typeof fn === 'function')
.forEach((fn) => fn(...args));
}
removeEventListener(event: string, fn: EventHandler): void {
if (!this._handlers[event] || typeof fn !== 'function') {
return;
}
const indexOf = this._handlers[event].indexOf(fn);
if (indexOf > -1) {
this._handlers[event].splice(indexOf, 1);
}
}
}

View File

@ -0,0 +1,65 @@
export type EventMap = {
[key: string]: (...args: any) => void;
};
export class TypedEventEmitter<Events extends EventMap> {
private _handlers: {
[x: string]: { fn: (...args: any) => void; once: boolean }[];
} = {};
addListener<E extends keyof Events>(event: E, fn: Events[E], once = false) {
if (typeof fn !== 'function') {
return;
}
if (!this._handlers[event as string]) {
this._handlers[event as string] = [];
}
this._handlers[event as string].push({ fn, once });
}
on<E extends keyof Events>(event: E, fn: Events[E]) {
this.addListener<E>(event, fn, false);
}
once<E extends keyof Events>(event: E, fn: Events[E]) {
this.addListener<E>(event, fn, true);
}
emit<E extends keyof Events>(event: E, ...args: Parameters<Events[E]>): void {
if (!this._handlers[event as string]) {
return;
}
this._handlers[event as string]
.filter((handler) => handler && typeof handler.fn === 'function')
.forEach((handler) => {
handler.fn(...(args as []));
if (handler.once) {
this.removeEventListener(event, handler.fn as Events[E]);
}
});
}
removeEventListener<E extends keyof Events>(event: E, fn: Events[E]): void {
if (!this._handlers[event as string] || typeof fn !== 'function') {
return;
}
const indexOf = this._handlers[event as string].findIndex(
(entry) => entry.fn === fn,
);
if (indexOf > -1) {
this._handlers[event as string].splice(indexOf, 1);
}
}
removeAllListeners<E extends keyof Events>(event: E): void {
if (!this._handlers[event as string]) {
return;
}
delete this._handlers[event as string];
}
}

View File

@ -7,22 +7,22 @@ export function mapUserInput(data: IUserLine, msgMaxLength = 512): string[][] {
case 'topic':
output.push([
'TOPIC %s',
data.arguments![0],
data.arguments[0],
data.message !== '' ? ' :' + data.message : '',
]);
break;
case 'kick':
output.push(['KICK %s :%s', data.arguments!.join(' '), data.message]);
output.push(['KICK %s :%s', data.arguments.join(' '), data.message]);
break;
case 'part':
output.push(['PART %s :%s', data.arguments![0], data.message]);
output.push(['PART %s :%s', data.arguments[0], data.message]);
break;
case 'nick':
case 'whois':
case 'who':
case 'names':
case 'join':
output.push(['JOIN %s', data.arguments![0]]);
output.push(['JOIN %s', data.arguments[0]]);
break;
case 'quit':
output.push(['QUIT :%s', data.message]);
@ -36,7 +36,7 @@ export function mapUserInput(data: IUserLine, msgMaxLength = 512): string[][] {
...messages.map((msg) => [
'%s %s :%s',
data.command.toUpperCase(),
data.arguments![0],
data.arguments[0],
msg,
]),
);
@ -48,13 +48,13 @@ export function mapUserInput(data: IUserLine, msgMaxLength = 512): string[][] {
case 'ctcp':
let ctcpmsg = '';
if (data.arguments![1].toLowerCase() === 'ping') {
if (data.arguments[1].toLowerCase() === 'ping') {
ctcpmsg = 'PING ' + Math.floor(Date.now() / 1000);
} else {
ctcpmsg = data.message;
}
output.push(['PRIVMSG %s :\x01%s\x01', data.arguments![0], ctcpmsg]);
output.push(['PRIVMSG %s :\x01%s\x01', data.arguments[0], ctcpmsg]);
break;
default:
output.push([data.command.toUpperCase(), data.message]);

29
src/utility/who-parser.ts Normal file
View File

@ -0,0 +1,29 @@
import { IIRCLine } from '../types/irc.interfaces';
export interface WhoResponse {
channel?: string;
username?: string;
hostname?: string;
server?: string;
nickname?: string;
modes?: string[];
oper?: boolean;
hops?: number;
realname?: string;
}
export const parseWho = (lines: IIRCLine[]): WhoResponse[] => {
return lines
.filter(({ command }) => command === '352')
.map((line) => ({
channel: line.arguments[1],
username: line.arguments[2],
hostname: line.arguments[3],
server: line.arguments[4],
nickname: line.arguments[5],
modes: line.arguments[6].split(''),
oper: line.arguments[6].includes('*'),
hops: parseInt(line.trailing.split(' ')[0], 10),
realname: line.trailing.split(' ').slice(1).join(' '),
}));
};

View File

@ -24,8 +24,8 @@ export function parseWhois(lines: IIRCLine[]) {
lines.forEach((line) => {
switch (line.command) {
case '311':
data.nickname = line.arguments![1];
data.hostmask = `${line.arguments![2]}@${line.arguments![3]}`;
data.nickname = line.arguments[1];
data.hostmask = `${line.arguments[2]}@${line.arguments[3]}`;
data.realname = line.trailing || '';
break;
case '319':
@ -38,14 +38,14 @@ export function parseWhois(lines: IIRCLine[]) {
data.usingModes = line.trailing;
break;
case '312':
data.server = line.arguments![2];
data.server = line.arguments[2];
data.serverName = line.trailing || '';
break;
case '313':
data.title = line.trailing;
break;
case '330':
data.loggedInAs = line.arguments![2];
data.loggedInAs = line.arguments[2];
break;
case '335':
data.bot = true;
@ -57,8 +57,8 @@ export function parseWhois(lines: IIRCLine[]) {
data.secure = true;
break;
case '317':
data.signOnTime = parseInt(line.arguments![3], 10);
data.idleSeconds = parseInt(line.arguments![2], 10);
data.signOnTime = parseInt(line.arguments[3], 10);
data.idleSeconds = parseInt(line.arguments[2], 10);
break;
}
});