diff --git a/src/bot.ts b/src/bot.ts index 7523179..5bd14a9 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -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) { diff --git a/src/connector/net.connector.ts b/src/connector/net.connector.ts index 5736e81..cb56e70 100644 --- a/src/connector/net.connector.ts +++ b/src/connector/net.connector.ts @@ -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 implements IRCConnector { connected = false; @@ -15,12 +16,13 @@ export class IRCSocketConnector public secure: boolean, public host: string, public port?: number, + public connOpts?: Record, ) { super(); } connect(): Promise { - 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; } diff --git a/src/connector/websocket.connector.ts b/src/connector/websocket.connector.ts index 3bc885e..08fc816 100644 --- a/src/connector/websocket.connector.ts +++ b/src/connector/websocket.connector.ts @@ -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 implements IRCConnector { connected = false; @@ -13,14 +14,17 @@ export class IRCWebSocketConnector public secure: boolean, public host: string, public port?: number, + public connOpts?: Record, ) { super(); } connect(): Promise { - 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); }); } } diff --git a/src/examples/connection-test.ts b/src/examples/connection-test.ts index 1318796..9115190 100644 --- a/src/examples/connection-test.ts +++ b/src/examples/connection-test.ts @@ -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; } }); diff --git a/src/index.ts b/src/index.ts index 8b54c72..8b276f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/irc.ts b/src/irc.ts index 084d502..cc81bfe 100644 --- a/src/irc.ts +++ b/src/irc.ts @@ -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 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 { 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 { + 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 { + 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 `[, <# visible>, ][]` format + */ + public async list(): Promise { + 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); diff --git a/src/spec/command-replies.ts b/src/spec/command-replies.ts new file mode 100644 index 0000000..55ea1f8 --- /dev/null +++ b/src/spec/command-replies.ts @@ -0,0 +1,110 @@ +export const RPL_COMMAND = [ + ['300', 'RPL_NONE', ''], + ['302', 'RPL_USERHOST', ':[{}]'], + ['303', 'RPL_ISON', ':[ {}]'], + ['301', 'RPL_AWAY', ' :'], + ['305', 'RPL_UNAWAY', ':You are no longer marked as being away'], + ['306', 'RPL_NOWAWAY', ':You have been marked as being away'], + ['307', 'RPL_WHOISREGNICK', ' :is a registered nick'], + ['311', 'RPL_WHOISUSER', ' * :'], + ['312', 'RPL_WHOISSERVER', ' :'], + ['313', 'RPL_WHOISOPERATOR', ' :is an IRC operator'], + [ + '317', + 'RPL_WHOISIDLE', + ' [] :seconds idle[, signon at]', + ], + ['318', 'RPL_ENDOFWHOIS', ' :End of /WHOIS list'], + ['319', 'RPL_WHOISCHANNELS', ' :{[@|+]}'], + ['314', 'RPL_WHOWASUSER', ' * :'], + ['369', 'RPL_ENDOFWHOWAS', ' :End of WHOWAS'], + ['321', 'RPL_LISTSTART', 'Channel :Users Name'], + ['322', 'RPL_LIST', ' <# visible> :'], + ['323', 'RPL_LISTEND', ':End of /LIST'], + ['324', 'RPL_CHANNELMODEIS', ' '], + ['324', 'RPL_WHOISACCOUNT', ' :is logged in as'], + ['331', 'RPL_NOTOPIC', ' :No topic is set'], + ['332', 'RPL_TOPIC', ' :'], + ['333', 'RPL_TOPICWHOTIME', ' :'], + ['335', 'RPL_WHOISBOT', ':is a bot on network'], + ['341', 'RPL_INVITING', ' '], + ['342', 'RPL_SUMMONING', ' :Summoning user to IRC'], + ['351', 'RPL_VERSION', '. :'], + [ + '352', + 'RPL_WHOREPLY', + ' [*][@|+] : ', + ], + ['315', 'RPL_ENDOFWHO', ' :End of /WHO list'], + ['353', 'RPL_NAMREPLY', ' :[[@|+] [[@|+] [...]]]'], + ['366', 'RPL_ENDOFNAMES', ' :End of /NAMES list'], + ['364', 'RPL_LINKS', ' : '], + ['365', 'RPL_ENDOFLINKS', ' :End of /LINKS list'], + ['367', 'RPL_BANLIST', ' '], + ['368', 'RPL_ENDOFBANLIST', ' :End of channel ban list'], + ['371', 'RPL_INFO', ':'], + ['374', 'RPL_ENDOFINFO', ':End of /INFO list'], + ['375', 'RPL_MOTDSTART', ':- Message of the day - '], + ['372', 'RPL_MOTD', ':- '], + ['376', 'RPL_ENDOFMOTD', ':End of /MOTD command'], + ['379', 'RPL_WHOISMODES', ' :is using modes'], + ['381', 'RPL_YOUREOPER', ':You are now an IRC operator'], + ['382', 'RPL_REHASHING', ' :Rehashing'], + ['391', 'RPL_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 ', + ], + ['201', 'RPL_TRACECONNECTING', 'Try. '], + ['202', 'RPL_TRACEHANDSHAKE', 'H.S. '], + ['203', 'RPL_TRACEUNKNOWN', '???? []'], + ['204', 'RPL_TRACEOPERATOR', 'Oper '], + ['205', 'RPL_TRACEUSER', 'User '], + [ + '206', + 'RPL_TRACESERVER', + 'Serv S C @', + ], + ['208', 'RPL_TRACENEWTYPE', ' 0 '], + ['261', 'RPL_TRACELOG', 'File '], + [ + '211', + 'RPL_STATSLINKINFO', + '