modernirc
Version:
IRC library for creating modern IRC bots
442 lines (425 loc) • 11.2 kB
JavaScript
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}`))
})
}