UNPKG

modernirc

Version:

IRC library for creating modern IRC bots

442 lines (425 loc) 11.2 kB
import { EventEmitter } from 'node:events' import net from 'node:net' import tls from 'node:tls' import { cutStringLength } from './util.js' const CRLF = '\r\n' const MessageCodes = { '001': 'welcome', '002': 'yourhost', '003': 'created', '004': 'myinfo', '005': 'serverconfig', '300': 'none', '302': 'userhost', '303': 'ison', '301': 'away', '305': 'unaway', '306': 'noaway', '311': 'whoisuser', '312': 'whoisserver', '313': 'whoisoperator', '317': 'whoisidle', '318': 'endofwhois', '319': 'whoischannels', '314': 'whomasuser', '369': 'endofwhowas', '321': 'liststart', '322': 'list', '323': 'listend', '324': 'channelmodeis', '325': 'uniqopis', '331': 'notopic', '332': 'topic', '341': 'inviting', '342': 'summoning', '346': 'invitelist', '347': 'endofinvitelist', '348': 'exceptlist', '349': 'endofexceptlist', '351': 'version', '352': 'whoreply', '315': 'endofwho', '353': 'namreply', '366': 'endofnames', '364': 'links', '365': 'endoflinks', '367': 'banlist', '368': 'endofbanlist', '371': 'info', '374': 'endofinfo', '375': 'motdstart', '372': 'motd', '376': 'endofmotd', '381': 'youreoper', '382': 'rehashing', '391': 'time', '392': 'usersstart', '393': 'users', '394': 'endofusers', '395': 'nousers', '200': 'tracelink', '201': 'traceconnecting', '202': 'tracehandshake', '203': 'traceunknown', '204': 'traceoperator', '205': 'traceuser', '206': 'traceserver', '207': 'traceservice', '208': 'tracenewtype', '209': 'traceclass', '210': 'tracereconnect', '261': 'tracelog', '262': 'traceend', '211': 'statslinkinfo', '212': 'statscommands', '213': 'statscline', '214': 'statsnline', '215': 'statsiline', '216': 'statskline', '218': 'statsyline', '219': 'endofstats', '241': 'statsline', '242': 'statsuptime', '243': 'statsoline', '244': 'statshline', '221': 'umodeis', '251': 'luserclient', '252': 'luserop', '253': 'luserunknown', '254': 'luserchannels', '255': 'luserme', '256': 'adminme', '257': 'adminloc1', '258': 'adminloc2', '259': 'adminemail', '263': 'tryagain', } const ErrorCodes = { '401': 'nosuchnick', '402': 'nosuchserver', '403': 'nosuchchannel', '404': 'cannotsendtochan', '405': 'toomanychannels', '406': 'wasnosuchnick', '407': 'toomanytargets', '408': 'nosuchservice', '409': 'noorigin', '411': 'norecipient', '412': 'notexttosend', '413': 'notoplevel', '414': 'wildtoplevel', '421': 'unknowncommand', '422': 'nomotd', '423': 'noadmininfo', '424': 'fileerror', '431': 'nonicknamegiven', '432': 'erroneousnickname', '433': 'nicknameinuse', '436': 'nickcollision', '437': 'unavailresource', '441': 'usernotinchannel', '442': 'notonchannel', '443': 'useronchannel', '444': 'nologin', '445': 'summondisabled', '446': 'usersdisabled', '451': 'notregistered', '461': 'needmoreparams', '462': 'alreadyregistered', '463': 'nopermforhost', '464': 'passwdmismatch', '465': 'yourebannedcreep', '467': 'keyset', '471': 'channelisfull', '472': 'unknownmode', '473': 'inviteonlychan', '474': 'bannedfromchan', '475': 'badchannelkey', '476': 'badchanmask', '477': 'nochanmodes', '478': 'banlistfull', '481': 'noprivileges', '482': 'chanoprivsneeded', '483': 'cantkillserver', '484': 'restricted', '485': 'uniqopprivsneeded', '491': 'nooperhost', '501': 'umodeunknownflag', '502': 'usersdontmatch', } const ReservedCodes = { '209': 'traceclass', '231': 'serviceinfo', '233': 'service', '235': 'servlistend', '316': 'whoischanop', '362': 'closing', '373': 'infostart', '466': 'youwillbebanned', '217': 'statsqline', '232': 'endofservices', '234': 'servlist', '361': 'killdone', '363': 'closeend', '384': 'myportis', '476': 'badchanmask', '492': 'noservicehost', } function createMessage(command, params = [], message = null) { let result = command.toUpperCase() if (params.length > 0) { result += ` ${params.join(' ')}` } if (message !== null && typeof message === 'string' && message.length > 0) { result += ` :${message}` } result += CRLF return result } /** * Wraps the underlying socket with some IRC utility functions * @param socket {import('net').Socket} TCP socket * @param options {object} Options * @returns {object} Socket wrapper */ function wrapSocket(socket, options) { const emitter = new EventEmitter() Object.defineProperties(emitter, { _socket: { enumerable: true, writable: false, configurable: true, value: socket, }, loadedModules: { enumerable: true, writable: false, configurable: true, value: [], }, serverConfig: { enumerable: true, writable: false, configurable: true, value: {}, }, joinedChannels: { enumerable: true, writable: false, configurable: true, value: {}, }, identity: { enumerable: true, writable: false, configurable: true, value: { ...options.identity }, }, context: { enumerable: true, writable: true, configurable: true, value: {}, }, init: { enumerable: true, writable: false, configurable: true, value() { socket.on('data', (chunk) => { const messages = chunk.toString().split(CRLF) messages.forEach((message) => { if (message.length > 0) { const parsed = parseMessage(message, options) emitter.emit(parsed.command, parsed) emitter.emit('message', parsed) if (parsed.code in ErrorCodes) { options.logger.error(`code ${parsed.code}: ${parsed.message}`) emitter.emit('failed', parsed) } } }) }) socket.on('error', (err) => emitter.emit('error', err)) socket.on('end', () => emitter.emit('end')) // send IRC initialization if (options.uplink.password) { this.send('pass', [options.uplink.password]) } this.send('nick', [options.identity.nickname]) this.send('user', [options.identity.username, options.identity.username, options.identity.username], options.identity.realname) }, }, send: { configurable: true, writable: false, enumerable: true, value(command, params = [], message = null) { this._socket.write(createMessage(command, params, message)) }, }, changeNickname: { configurable: true, writable: false, enumerable: true, value(nickname) { const ircNickRegex = /^([a-zA-Z[\]\-\\`_^{}|])([a-zA-Z0-9[\]\-\\`_^{}|]{2,})$/ if (!ircNickRegex.test(nickname)) { throw new Error('invalid nickname pattern') } this.send('nick', [nickname]) }, }, join: { configurable: true, writable: false, enumerable: true, value(channel, key = null) { const all = [channel] if (key) { all.push(key) } this.send('join', all) }, }, part: { configurable: true, writable: false, enumerable: true, value(channel, reason = '') { this.send('part', [channel], reason) }, }, message: { configurable: true, writable: false, enumerable: true, value(target, message) { const cuts = cutStringLength(message, 510) for (let i = 0; i < cuts.length; i++) { this.send('privmsg', [target], cuts[i]) } }, }, notice: { configurable: true, writable: false, enumerable: true, value(target, message) { this.send('notice', [target], message) }, }, kick: { configurable: true, writable: false, enumerable: true, value(channel, target, reason = '') { this.send('kick', [channel, target], reason) }, }, me: { configurable: true, writable: false, enumerable: true, value(channel, action) { this.message(channel, `\x01ACTION ${action}`) }, }, storage: { configurable: true, writable: true, enumerable: true, value: null, }, }) return emitter } /** * Parses the prefix from the message * @param prefix {string} Raw incoming prefix * @returns {object} */ function parsePrefix(prefix) { const indexOfExclam = prefix.indexOf('!') const indexOfAt = prefix.indexOf('@') let nickname if (indexOfExclam === -1) { if (indexOfAt === -1) { nickname = prefix } else { nickname = prefix.substring(0, indexOfAt) } } else { nickname = prefix.substring(0, indexOfExclam) } return { nickname, username: indexOfExclam === -1 ? null : prefix.substring(indexOfExclam + 1, indexOfAt), hostname: indexOfAt === -1 ? null : prefix.substring(indexOfAt + 1), _raw: prefix, } } /** * Takes an input buffer from the socket and parses it as an IRC message * @param message {string} Raw incoming message * @param options {object} * @returns {Object} Message */ function parseMessage(message, options) { if (!message.startsWith(':')) { // server messages const [cmd, ...rest] = message.split(' ') return { _raw: message, prefix: null, code: cmd.toUpperCase(), command: cmd.toLowerCase(), params: rest, message: null, self: false, } } let indexOfSpace = message.indexOf(' ') let cursor = 1 const prefix = parsePrefix(message.substring(cursor, indexOfSpace)) // we ignore the ':' at the beginning of the message cursor = indexOfSpace + 1 indexOfSpace = message.indexOf(' ', cursor) const commandCode = message.substring(cursor, indexOfSpace) const command = MessageCodes[commandCode] ?? ErrorCodes[commandCode] ?? ReservedCodes[commandCode] ?? commandCode.toLowerCase() cursor = indexOfSpace + 1 indexOfSpace = message.indexOf(' :', cursor) const params = message.substring(cursor, indexOfSpace !== -1 ? indexOfSpace : undefined).trim().split(' ') const msg = indexOfSpace === -1 ? null : message.substring(indexOfSpace + 2) return { _raw: message, prefix, code: commandCode, command, params, message: msg, self: prefix.nickname === options.identity.nickname, } } /** * Creates a new socket client and connects it to the configured uplink * @param options {object} options * @returns {Promise<object>} */ export function createClient(options) { const { uplink } = options return new Promise((resolve, reject) => { let intf = uplink.tls ? tls : net const socket = intf.connect({ host: uplink.host, port: uplink.port, rejectUnauthorized: uplink.rejectUnauthorized, }, () => { if (uplink.tls && !socket.authorized) { return reject(new Error('Unauthorized socket connection')) } options.logger.info('connected') return resolve(wrapSocket(socket, options)) }) socket.on('error', (err) => options.logger.error(`cannot connect to uplink [${uplink.host}:${uplink.port}]: ${err.code}`)) }) }