diff --git a/.gitignore b/.gitignore index fdc200b0..bdc51885 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ npm-debug.log node_modules/ _build/ .idea/ +lib/ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..dee4cd41 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +# Because yarn is [hot garbage](https://github.com/yarnpkg/yarn/issues/5235#issuecomment-571206092), we need an empty .npmignore file \ No newline at end of file diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 8501dd2c..00000000 --- a/index.d.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Socket } from "net"; -import { EventEmitter } from "events"; - -type CbFunction = (err: Error|null) => void; - -// Type definitions for node-irc -// Project: matrix-org -// Definitions by: Will Hunt https://github.com/matrix-org/node-irc -export interface IrcClientOpts { - password?: string|null; - userName?: string; - realName?: string; - port?: number; - family: number|null; - bustRfc3484: boolean; - localAddress?: string|null; - localPort?: number|null; - debug?: boolean; - showErrors?: boolean; - autoRejoin?: boolean; - autoConnect?: boolean; - channels?: string[]; - retryCount?: number|null; - retryDelay?: number; - secure?: boolean|object; - selfSigned?: boolean; - certExpired?: boolean; - floodProtection?: boolean; - floodProtectionDelay?: number; - sasl?: boolean; - stripColors?: boolean; - channelPrefixes?: string; - messageSplit?: number; - encoding?: string|false; - onNickConflict?: (maxLen: number) => number, - webirc?: { - pass: string, - ip: string, - user: string - }; -} - -export interface ChanData { - key: string; - serverName: string; - users: {[nick: string]: string /* mode */}, - mode: string; -} - -export interface WhoisResponse { - user: string; - idle: number; - channels: string[]; - host: string; - realname: string; -} - -export class Client extends EventEmitter { - readonly modeForPrefix: {[prefix: string]: string}; - readonly nick: string; - readonly chans: {[channel: string]: ChanData}; - readonly supported?: { - nicklength?: number; - } - readonly conn?: Socket; - constructor(server: string, nick: string, opts: IrcClientOpts); - connect(retryCount: number, callback: CbFunction): void; - disconnect(reason?: string|CbFunction, callback?: CbFunction): void - send(...data: string[]): Promise; - action(channel: string, text: string): Promise; - notice(channel: string, text: string): Promise; - say(channel: string, text: string): Promise; - join(channel: string, cb?: () => void): Promise; - part(channel: string, reason: string, cb?: () => void): Promise; - ctcp(to: string, type: string, text: string): Promise; - whois(nick: string, cb: (whois: WhoisResponse) => void): void; - mode(channelOrNick: string, cb?: () => void): Promise; - setUserMode(mode: string, nick?: string): Promise; - names(channel: string, cb: (channelName: string, names: {[nick: string]: string}) => void): void; - isUserPrefixMorePowerfulThan(prefix: string, testPrefix: string): boolean; - _toLowerCase(str: string): string; - getSplitMessages(target: string, text: string): string[]; - chanData(channel: string, create?: boolean): ChanData|undefined; -} diff --git a/lib/irc.js b/lib/irc.js deleted file mode 100644 index d3005d5d..00000000 --- a/lib/irc.js +++ /dev/null @@ -1,1359 +0,0 @@ -/* - irc.js - Node JS IRC client library - - (C) Copyright Martyn Smith 2010 - - This library is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this library. If not, see . -*/ - -exports.Client = Client; -const dns = require('dns'); -const net = require('net'); -const tls = require('tls'); -const util = require('util'); -const isValidUTF8 = require('utf-8-validate'); -const { EventEmitter } = require('events'); -const { Iconv } = require('iconv-lite'); -const detectCharset = require('detect-character-encoding'); - -const colors = require('./colors'); -const parseMessage = require('./parse_message'); -exports.colors = colors; - -const lineDelimiter = new RegExp('\r\n|\r|\n'); - -const MIN_DELAY_MS = 33; - -function Client(server, nick, opt) { - const self = this; - /** - * This promise is used to block new sends until the previous one completes. - */ - this.sendingPromise = Promise.resolve(); - this.lastSendTime = 0; - self.opt = { - server: server, - nick: nick, - password: null, - userName: 'nodebot', - realName: 'nodeJS IRC client', - port: 6667, - family: 4, - bustRfc3484: false, - localAddress: null, - localPort: null, - debug: false, - showErrors: false, - autoRejoin: false, - autoConnect: true, - channels: [], - retryCount: null, - retryDelay: 2000, - secure: false, - selfSigned: false, - certExpired: false, - floodProtection: false, - floodProtectionDelay: 1000, - sasl: false, - saslType: 'PLAIN', - stripColors: false, - channelPrefixes: '&#', - messageSplit: 512, - encoding: false, - encodingFallback: null, - onNickConflict: function(maxLen) { // maxLen may be undefined if not known - if (typeof (self.opt.nickMod) == 'undefined') - self.opt.nickMod = 0; - self.opt.nickMod++; - var n = self.opt.nick + self.opt.nickMod; - if (maxLen && n.length > maxLen) { - // truncate the end of the nick and then suffix a numeric - var digitStr = "" + self.opt.nickMod; - var maxNickSegmentLen = maxLen - digitStr.length; - n = self.opt.nick.substr(0, maxNickSegmentLen) + digitStr; - } - return n; - }, - webirc: { - pass: '', - ip: '', - user: '' - } - }; - - if (typeof opt == 'object') { - const keys = Object.keys(opt); - for (let i = 0; i < keys.length; i++) { - var k = keys[i]; - if (opt[k] !== undefined) - self.opt[k] = opt[k]; - } - } - - // Features supported by the server - // (initial values are RFC 1459 defaults. Zeros signify - // no default or unlimited value) - self.supported = { - channel: { - idlength: [], - length: 200, - limit: [], - modes: { a: '', b: '', c: '', d: ''}, - types: self.opt.channelPrefixes - }, - kicklength: 0, - maxlist: [], - maxtargets: [], - modes: 3, - nicklength: 9, - topiclength: 0, - usermodes: '', - usermodepriority: '', // E.g "ov" - casemapping: 'ascii' - }; - - self.hostMask = ''; - - // TODO - fail if nick or server missing - // TODO - fail if username has a space in it - if (self.opt.autoConnect === true) { - self.connect(); - } - - var prevClashNick = ''; - - self.addListener('raw', function(message) { - var channels = [], - channel, - nick, - from, - text, - to; - - switch (message.command) { - case 'rpl_welcome': - // Set nick to whatever the server decided it really is - // (normally this is because you chose something too long and - // the server has shortened it - self.nick = message.args[0]; - // Note our hostmask to use it in splitting long messages. - // We don't send our hostmask when issuing PRIVMSGs or NOTICEs, - // of course, but rather the servers on the other side will - // include it in messages and will truncate what we send if - // the string is too long. Therefore, we need to be considerate - // neighbors and truncate our messages accordingly. - var welcomeStringWords = message.args[1].split(/\s+/); - self.hostMask = welcomeStringWords[welcomeStringWords.length - 1]; - self._updateMaxLineLength(); - self.emit('registered', message); - self.whois(self.nick, function(args) { - self.nick = args.nick; - self.hostMask = args.user + "@" + args.host; - self._updateMaxLineLength(); - }); - break; - case 'rpl_myinfo': - self.supported.usermodes = message.args[3]; - break; - case 'rpl_isupport': - message.args.forEach(function(arg) { - var match; - match = arg.match(/([A-Z]+)=(.*)/); - if (match) { - var param = match[1]; - var value = match[2]; - switch (param) { - case 'CASEMAPPING': - self.supported.casemapping = value; - break; - case 'CHANLIMIT': - value.split(',').forEach(function(val) { - val = val.split(':'); - self.supported.channel.limit[val[0]] = parseInt(val[1]); - }); - break; - case 'CHANMODES': - value = value.split(','); - var type = ['a', 'b', 'c', 'd']; - for (var i = 0; i < type.length; i++) { - self.supported.channel.modes[type[i]] += value[i]; - } - break; - case 'CHANTYPES': - self.supported.channel.types = value; - break; - case 'CHANNELLEN': - self.supported.channel.length = parseInt(value); - break; - case 'IDCHAN': - value.split(',').forEach(function(val) { - val = val.split(':'); - self.supported.channel.idlength[val[0]] = val[1]; - }); - break; - case 'KICKLEN': - self.supported.kicklength = value; - break; - case 'MAXLIST': - value.split(',').forEach(function(val) { - val = val.split(':'); - self.supported.maxlist[val[0]] = parseInt(val[1]); - }); - break; - case 'NICKLEN': - self.supported.nicklength = parseInt(value); - break; - case 'PREFIX': - match = value.match(/\((.*?)\)(.*)/); - if (match) { - self.supported.usermodepriority = match[1]; - match[1] = match[1].split(''); - match[2] = match[2].split(''); - while (match[1].length) { - self.modeForPrefix[match[2][0]] = match[1][0]; - self.supported.channel.modes.b += match[1][0]; - self.prefixForMode[match[1].shift()] = match[2].shift(); - } - } - break; - case 'STATUSMSG': - break; - case 'TARGMAX': - value.split(',').forEach(function(val) { - val = val.split(':'); - val[1] = (!val[1]) ? 0 : parseInt(val[1]); - self.supported.maxtargets[val[0]] = val[1]; - }); - break; - case 'TOPICLEN': - self.supported.topiclength = parseInt(value); - break; - } - } - }); - break; - case 'rpl_yourhost': - case 'rpl_created': - case 'rpl_luserclient': - case 'rpl_luserop': - case 'rpl_luserchannels': - case 'rpl_luserme': - case 'rpl_localusers': - case 'rpl_globalusers': - case 'rpl_statsconn': - case 'rpl_luserunknown': - // Random welcome crap, ignoring - break; - case 'err_nicknameinuse': - var nextNick = self.opt.onNickConflict(); - if (self.opt.nickMod > 1) { - // We've already tried to resolve this nick before and have failed to do so. - // This could just be because there are genuinely 2 clients with the - // same nick and the same nick with a numeric suffix or it could be much - // much more gnarly. If there is a conflict and the original nick is equal - // to the NICKLEN, then we'll never be able to connect because the numeric - // suffix will always be truncated! - // - // To work around this, we'll persist what nick we send up, and compare it - // to the nick which is returned in this error response. If there is - // truncation going on, the two nicks won't match, and then we can do - // something about it. - - if (prevClashNick !== '') { - // we tried to fix things and it still failed, check to make sure - // that the server isn't truncating our nick. - var errNick = message.args[1]; - if (errNick !== prevClashNick) { - nextNick = self.opt.onNickConflict(errNick.length); - } - } - - prevClashNick = nextNick; - } - - self._send('NICK', nextNick); - self.nick = nextNick; - self._updateMaxLineLength(); - break; - case 'PING': - self.send('PONG', message.args[0]); - self.emit('ping', message.args[0]); - break; - case 'PONG': - self.emit('pong', message.args[0]); - break; - case 'NOTICE': - self._casemap(message, 0); - from = message.nick; - to = message.args[0]; - if (!to) { - to = null; - } - text = message.args[1] || ''; - if (text[0] === '\u0001' && text.lastIndexOf('\u0001') > 0) { - self._handleCTCP(from, to, text, 'notice', message); - break; - } - self.emit('notice', from, to, text, message); - - if (self.opt.debug && to == self.nick) - util.log('GOT NOTICE from ' + (from ? '"' + from + '"' : 'the server') + ': "' + text + '"'); - break; - case 'MODE': - self._casemap(message, 0); - if (self.opt.debug) - util.log('MODE: ' + message.args[0] + ' sets mode: ' + message.args[1]); - - channel = self.chanData(message.args[0]); - if (!channel) break; - var modeList = message.args[1].split(''); - var adding = true; - var modeArgs = message.args.slice(2); - modeList.forEach(function(mode) { - if (mode == '+') { - adding = true; - return; - } - if (mode == '-') { - adding = false; - return; - } - if (mode in self.prefixForMode) { - // channel user modes - var user = modeArgs.shift(); - if (adding) { - if (channel.users[user] != null && channel.users[user].indexOf(self.prefixForMode[mode]) === -1) { - channel.users[user] += self.prefixForMode[mode]; - } - - self.emit('+mode', message.args[0], message.nick, mode, user, message); - } - else { - if (channel.users[user]) { - channel.users[user] = channel.users[user].replace(self.prefixForMode[mode], ''); - } - self.emit('-mode', message.args[0], message.nick, mode, user, message); - } - } - else { - var modeArg; - // channel modes - if (mode.match(/^[bkl]$/)) { - modeArg = modeArgs.shift(); - if (!modeArg || modeArg.length === 0) - modeArg = undefined; - } - // TODO - deal nicely with channel modes that take args - if (adding) { - if (channel.mode.indexOf(mode) === -1) - channel.mode += mode; - - self.emit('+mode', message.args[0], message.nick, mode, modeArg, message); - } - else { - channel.mode = channel.mode.replace(mode, ''); - self.emit('-mode', message.args[0], message.nick, mode, modeArg, message); - } - } - }); - break; - case 'NICK': - if (message.nick == self.nick) { - // the user just changed their own nick - self.nick = message.args[0]; - self._updateMaxLineLength(); - } - - if (self.opt.debug) - util.log('NICK: ' + message.nick + ' changes nick to ' + message.args[0]); - - channels = []; - - // finding what channels a user is in - Object.keys(self.chans).forEach(function(channame) { - var channel = self.chans[channame]; - if (message.nick in channel.users) { - channel.users[message.args[0]] = channel.users[message.nick]; - delete channel.users[message.nick]; - channels.push(channame); - } - }); - - // old nick, new nick, channels - self.emit('nick', message.nick, message.args[0], channels, message); - break; - case 'rpl_motdstart': - self.motd = message.args[1] + '\n'; - break; - case 'rpl_motd': - self.motd += message.args[1] + '\n'; - break; - case 'rpl_endofmotd': - case 'err_nomotd': - self.motd += message.args[1] + '\n'; - self.emit('motd', self.motd); - break; - case 'rpl_namreply': - self._casemap(message, 2); - channel = self.chanData(message.args[2]); - if (!message.args[3]) { - // No users - break; - } - var users = message.args[3].trim().split(/ +/); - if (channel) { - users.forEach(function(user) { - // user = "@foo", "+foo", "&@foo", etc... - // The symbols are the prefix set. - var allowedSymbols = Object.keys(self.modeForPrefix).join(""); - // Split out the prefix from the nick e.g "@&foo" => ["@&foo", "@&", "foo"] - var prefixRegex = new RegExp("^([" + escapeRegExp(allowedSymbols) + "]*)(.*)$"); - var match = user.match(prefixRegex); - if (match) { - var userPrefixes = match[1]; - var knownPrefixes = ''; - for (var i = 0; i < userPrefixes.length; i++) { - if (userPrefixes[i] in self.modeForPrefix) { - knownPrefixes += userPrefixes[i]; - } - } - if (knownPrefixes.length > 0) { - channel.users[match[2]] = knownPrefixes; - } - else { - // recombine just in case this server allows weird chars in the nick. - // We know it isn't a mode char. - channel.users[match[1] + match[2]] = ''; - } - } - }); - } - break; - case 'rpl_endofnames': - self._casemap(message, 1); - channel = self.chanData(message.args[1]); - if (channel) { - self.emit('names', message.args[1], channel.users); - self.emit('names' + message.args[1], channel.users); - self._send('MODE', message.args[1]); - } - break; - case 'rpl_topic': - self._casemap(message, 1); - channel = self.chanData(message.args[1]); - if (channel) { - channel.topic = message.args[2]; - } - break; - case 'rpl_away': - self._addWhoisData(message.args[1], 'away', message.args[2], true); - break; - case 'rpl_whoisuser': - self._addWhoisData(message.args[1], 'user', message.args[2]); - self._addWhoisData(message.args[1], 'host', message.args[3]); - self._addWhoisData(message.args[1], 'realname', message.args[5]); - break; - case 'rpl_whoisidle': - self._addWhoisData(message.args[1], 'idle', message.args[2]); - break; - case 'rpl_whoischannels': - // TODO - clean this up? - if (message.args.length >= 3) - self._addWhoisData(message.args[1], 'channels', message.args[2].trim().split(/\s+/)); - break; - case 'rpl_whoisserver': - self._addWhoisData(message.args[1], 'server', message.args[2]); - self._addWhoisData(message.args[1], 'serverinfo', message.args[3]); - break; - case 'rpl_whoisoperator': - self._addWhoisData(message.args[1], 'operator', message.args[2]); - break; - case '330': // rpl_whoisaccount? - self._addWhoisData(message.args[1], 'account', message.args[2]); - self._addWhoisData(message.args[1], 'accountinfo', message.args[3]); - break; - case 'rpl_endofwhois': - self.emit('whois', self._clearWhoisData(message.args[1])); - break; - case 'rpl_liststart': - self.channellist = []; - self.emit('channellist_start'); - break; - case 'rpl_list': - channel = { - name: message.args[1], - users: message.args[2], - topic: message.args[3] - }; - self.emit('channellist_item', channel); - self.channellist.push(channel); - break; - case 'rpl_listend': - self.emit('channellist', self.channellist); - break; - case 'rpl_topicwhotime': - self._casemap(message, 1); - channel = self.chanData(message.args[1]); - if (channel) { - channel.topicBy = message.args[2]; - // channel, topic, nick - self.emit('topic', message.args[1], channel.topic, channel.topicBy, message); - } - break; - case 'TOPIC': - // channel, topic, nick - self._casemap(message, 0); - self.emit('topic', message.args[0], message.args[1], message.nick, message); - - channel = self.chanData(message.args[0]); - if (channel) { - channel.topic = message.args[1]; - channel.topicBy = message.nick; - } - break; - case 'rpl_channelmodeis': - self._casemap(message, 1); - channel = self.chanData(message.args[1]); - if (channel) { - channel.mode = message.args[2]; - } - - self.emit('mode_is', message.args[1], message.args[2]); - break; - case 'rpl_creationtime': - self._casemap(message, 1); - channel = self.chanData(message.args[1]); - if (channel) { - channel.created = message.args[2]; - } - break; - case 'JOIN': - self._casemap(message, 0); - // channel, who - if (self.nick == message.nick) { - self.chanData(message.args[0], true); - } - else { - channel = self.chanData(message.args[0]); - if (channel && channel.users) { - channel.users[message.nick] = ''; - } - } - self.emit('join', message.args[0], message.nick, message); - self.emit('join' + message.args[0], message.nick, message); - if (message.args[0] != message.args[0].toLowerCase()) { - self.emit('join' + message.args[0].toLowerCase(), message.nick, message); - } - break; - case 'PART': - self._casemap(message, 0); - // channel, who, reason - self.emit('part', message.args[0], message.nick, message.args[1], message); - self.emit('part' + message.args[0], message.nick, message.args[1], message); - if (message.args[0] != message.args[0].toLowerCase()) { - self.emit('part' + message.args[0].toLowerCase(), message.nick, message.args[1], message); - } - if (self.nick == message.nick) { - self.removeChanData(message.args[0]); - } - else { - channel = self.chanData(message.args[0]); - if (channel && channel.users) { - delete channel.users[message.nick]; - } - } - break; - case 'KICK': - self._casemap(message, 0); - // channel, who, by, reason - self.emit('kick', message.args[0], message.args[1], message.nick, message.args[2], message); - self.emit('kick' + message.args[0], message.args[1], message.nick, message.args[2], message); - if (message.args[0] != message.args[0].toLowerCase()) { - self.emit('kick' + message.args[0].toLowerCase(), - message.args[1], message.nick, message.args[2], message); - } - - if (self.nick == message.args[1]) { - self.removeChanData(message.args[0]); - } - else { - channel = self.chanData(message.args[0]); - if (channel && channel.users) { - delete channel.users[message.args[1]]; - } - } - break; - case 'KILL': - nick = message.args[0]; - channels = []; - Object.keys(self.chans).forEach(function(channame) { - var channel = self.chans[channame]; - if (nick in channel.users) { - channels.push(channame); - delete channel.users[nick]; - } - }); - self.emit('kill', nick, message.args[1], channels, message); - break; - case 'PRIVMSG': - self._casemap(message, 0); - from = message.nick; - to = message.args[0]; - text = message.args[1] || ''; - if (text[0] === '\u0001' && text.lastIndexOf('\u0001') > 0) { - self._handleCTCP(from, to, text, 'privmsg', message); - break; - } - self.emit('message', from, to, text, message); - if (self.supported.channel.types.indexOf(to.charAt(0)) !== -1) { - self.emit('message#', from, to, text, message); - self.emit('message' + to, from, text, message); - if (to != to.toLowerCase()) { - self.emit('message' + to.toLowerCase(), from, text, message); - } - } - if (to.toUpperCase() === self.nick.toUpperCase()) self.emit('pm', from, text, message); - - if (self.opt.debug && to == self.nick) - util.log('GOT MESSAGE from ' + from + ': ' + text); - break; - case 'INVITE': - self._casemap(message, 1); - from = message.nick; - to = message.args[0]; - channel = message.args[1]; - self.emit('invite', channel, from, message); - break; - case 'QUIT': - if (self.opt.debug) - util.log('QUIT: ' + message.prefix + ' ' + message.args.join(' ')); - if (self.nick == message.nick) { - // TODO handle? - break; - } - // handle other people quitting - - channels = []; - - // finding what channels a user is in? - Object.keys(self.chans).forEach(function(channame) { - var channel = self.chans[channame]; - if (message.nick in channel.users) { - delete channel.users[message.nick]; - channels.push(channame); - } - }); - - // who, reason, channels - self.emit('quit', message.nick, message.args[0], channels, message); - break; - - // for sasl - case 'CAP': - if (message.args[0] === '*' && - message.args[1] === 'ACK' && - message.args[2].split(' ').includes('sasl')) - self._send('AUTHENTICATE', self.opt.saslType); - break; - case 'AUTHENTICATE': - if (message.args[0] === '+') { - switch (self.opt.saslType) { - case 'PLAIN': - self._send('AUTHENTICATE', - Buffer.from( - self.opt.nick + '\0' + - self.opt.userName + '\0' + - self.opt.password - ).toString('base64')); - break; - case 'EXTERNAL': - self._send('AUTHENTICATE', '+'); - break; - } - } - break; - case '903': - self._send('CAP', 'END'); - break; - case 'err_unavailresource': - // err_unavailresource has been seen in the wild on Freenode when trying to - // connect with the nick 'boot'. I'm guessing they have reserved that nick so - // no one can claim it. The error handling though is identical to offensive word - // nicks hence the fall through here. - case 'err_erroneusnickname': - if (self.opt.showErrors) - util.log('\033[01;31mERROR: ' + util.inspect(message) + '\033[0m'); - - // The Scunthorpe Problem - // ---------------------- - // Some IRC servers have offensive word filters on nicks. Trying to change your - // nick to something with an offensive word in it will return this error. - // - // If we are already logged in, this is fine, we can just emit an error and - // let the client deal with it. - // If we are NOT logged in however, we need to propose a new nick else we - // will never be able to connect successfully and the connection will - // eventually time out, most likely resulting in infinite-reconnects. - // - // Check to see if we are NOT logged in, and if so, use a "random" string - // as the next nick. - if (self.hostMask !== '') { // hostMask set on rpl_welcome - self.emit('error', message); - break; - } - // rpl_welcome has not been sent - // We can't use a truly random string because we still need to abide by - // the BNF for nicks (first char must be A-Z, length limits, etc). We also - // want to be able to debug any issues if people say that they didn't get - // the nick they wanted. - var rndNick = "enick_" + Math.floor(Math.random() * 1000) // random 3 digits - self._send('NICK', rndNick); - self.nick = rndNick; - self._updateMaxLineLength(); - break; - - default: - if (message.commandType == 'error') { - self.emit('error', message); - if (self.opt.showErrors) - util.log('\u001b[01;31mERROR: ' + util.inspect(message) + '\u001b[0m'); - } - else { - if (self.opt.debug) - util.log('\u001b[01;31mUnhandled message: ' + util.inspect(message) + '\u001b[0m'); - break; - } - } - }); - - self.addListener('kick', function(channel, who, by, reason) { - if (self.opt.autoRejoin) - self.send.apply(self, ['JOIN'].concat(channel.split(' '))); - }); - self.addListener('motd', function(motd) { - self.opt.channels.forEach(function(channel) { - self.send.apply(self, ['JOIN'].concat(channel.split(' '))); - }); - }); - - EventEmitter.call(this); -} -util.inherits(Client, EventEmitter); - -Client.prototype.conn = null; -Client.prototype.prefixForMode = {}; // o => @ -Client.prototype.modeForPrefix = {}; // @ => o -Client.prototype.chans = {}; -Client.prototype._whoisData = {}; - -Client.prototype.chanData = function(name, create) { - var key = name.toLowerCase(); - if (create) { - this.chans[key] = this.chans[key] || { - key: key, - serverName: name, - users: {}, - mode: '' - }; - } - - return this.chans[key]; -}; - -Client.prototype.removeChanData = function(name) { - const key = name.toLowerCase(); - // Sometimes we can hit a race where we will get a PART about ourselves before we - // have joined a channel fully and stored it in state. - // Ensure that we have chanData before deleting - if (this.chans[key]) { - delete this.chans[key]; - } -} - -Client.prototype._connectionHandler = function() { - if (this.opt.webirc.ip && this.opt.webirc.pass && this.opt.webirc.host) { - this._send('WEBIRC', this.opt.webirc.pass, this.opt.userName, this.opt.webirc.host, this.opt.webirc.ip); - } - if (this.opt.sasl) { - // see http://ircv3.atheme.org/extensions/sasl-3.1 - this._send('CAP REQ', 'sasl'); - } else if (this.opt.password) { - this._send('PASS', this.opt.password); - } - if (this.opt.debug) - util.log('Sending irc NICK/USER'); - this._send('NICK', this.opt.nick); - this.nick = this.opt.nick; - this._updateMaxLineLength(); - this._send('USER', this.opt.userName, 8, '*', this.opt.realName); - this.emit('connect'); -}; - -Client.prototype.connect = function(retryCount, callback) { - if (typeof (retryCount) === 'function') { - callback = retryCount; - retryCount = undefined; - } - retryCount = retryCount || 0; - if (typeof (callback) === 'function') { - this.once('registered', callback); - } - var self = this; - self.chans = {}; - - // socket opts - var connectionOpts = { - host: self.opt.server, - port: self.opt.port, - family: self.opt.family - }; - - // local address to bind to - if (self.opt.localAddress) - connectionOpts.localAddress = self.opt.localAddress; - if (self.opt.localPort) - connectionOpts.localPort = self.opt.localPort; - - if (self.opt.bustRfc3484) { - // RFC 3484 attempts to sort address results by "locallity", taking - // into consideration the length of the common prefix between the - // candidate local source address and the destination. In practice - // this always sorts one or two servers ahead of all the rest, which - // isn't what we want for proper load balancing. With this option set - // we'll randomise the list of all results so that we can spread load - // between all the servers. - connectionOpts.lookup = function(hostname, options, callback) { - var optionsWithAll = Object.assign({all: true}, options); - - dns.lookup(hostname, optionsWithAll, (err, addresses) => { - if (err) { - if (options.all) { - return callback(err, addresses); - } - else { - return callback(err, null, null); - } - } - - if (options.all) { - var shuffled = []; - while (addresses.length) { - var i = randomInt(addresses.length); - shuffled.push(addresses.splice(i, 1)[0]); - } - callback(err, shuffled); - } - else { - var chosen = addresses[randomInt(addresses.length)]; - callback(err, chosen.address, chosen.family); - } - }); - }; - } - - // destroy old socket before allocating a new one - if (self.conn !== null) - self.conn.destroy(); - - // try to connect to the server - if (self.opt.secure) { - connectionOpts.rejectUnauthorized = !self.opt.selfSigned; - - if (typeof self.opt.secure == 'object') { - // copy "secure" opts to options passed to connect() - for (var f in self.opt.secure) { - connectionOpts[f] = self.opt.secure[f]; - } - } - - if (self.opt.bustRfc3484) { - // tls.connect on its own doesn't allow you to attach a custom lookup function, meaning we cannot - // honour the bustRfc3484 flag on TLS connections. To fix this, we'll create the underlying Socket - // with the lookup function as if we weren't connecting over TLS and pass that in to tls.connect - // instead, which works because tls.connect supports a 'socket' option. - connectionOpts.socket = net.createConnection(connectionOpts); - } - - self.conn = tls.connect(connectionOpts, function() { - // callback called only after successful socket connection - self.conn.connected = true; - if (self.conn.authorized || - (self.opt.selfSigned && - (self.conn.authorizationError === 'DEPTH_ZERO_SELF_SIGNED_CERT' || - self.conn.authorizationError === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || - self.conn.authorizationError === 'SELF_SIGNED_CERT_IN_CHAIN')) || - (self.opt.certExpired && - self.conn.authorizationError === 'CERT_HAS_EXPIRED')) { - // authorization successful - - if (!self.opt.encoding) { - self.conn.setEncoding('utf-8'); - } - - if (self.opt.certExpired && - self.conn.authorizationError === 'CERT_HAS_EXPIRED') { - util.log('Connecting to server with expired certificate'); - } - - self._connectionHandler(); - } else { - // authorization failed - util.log(self.conn.authorizationError); - } - }); - } else { - self.conn = net.createConnection(connectionOpts, self._connectionHandler.bind(self)); - } - self.conn.writeAsync = util.promisify(self.conn.write).bind(self.conn); - self.conn.requestedDisconnect = false; - self.conn.setTimeout(1000 * 180); - - let buffer = Buffer.alloc(0); - - self.conn.addListener('data', function(chunk) { - if (typeof (chunk) === 'string') { - buffer += chunk; - } else { - buffer = Buffer.concat([buffer, chunk]); - } - - var lines = self.convertEncoding(buffer).toString().split(lineDelimiter); - - if (lines.pop()) { - // if buffer is not ended with \r\n, there's more chunks. - return; - } else { - // else, initialize the buffer. - buffer = Buffer.alloc(0); - } - - lines.forEach(function iterator(line) { - if (line.length) { - var message = parseMessage(line, self.opt.stripColors); - - try { - self.emit('raw', message); - } catch (err) { - if (!self.conn.requestedDisconnect) { - throw err; - } - } - } - }); - }); - self.conn.addListener('end', function() { - if (self.opt.debug) - util.log('Connection got "end" event'); - }); - self.conn.addListener('close', function() { - if (self.opt.debug) - util.log('Connection got "close" event'); - self._reconnect(retryCount); - }); - self.conn.addListener('timeout', function() { - if (self.opt.debug) - util.log('Connection got "timeout" event'); - self._reconnect(retryCount); - }); - self.conn.addListener('error', function(exception) { - self.emit('netError', exception); - if (self.opt.debug) { - util.log('Network error: ' + exception); - } - }); -}; -Client.prototype._reconnect = function reconnect(retryCount) { - var self = this; - if (self.conn.requestedDisconnect) - return; - if (self.opt.debug) - util.log('Disconnected: reconnecting'); - if (self.opt.retryCount !== null && retryCount >= self.opt.retryCount) { - if (self.opt.debug) { - util.log('Maximum retry count (' + self.opt.retryCount + ') reached. Aborting'); - } - self.emit('abort', self.opt.retryCount); - return; - } - - if (self.opt.debug) { - util.log('Waiting ' + self.opt.retryDelay + 'ms before retrying'); - } - setTimeout(function() { - self.connect(retryCount + 1); - }, self.opt.retryDelay); -}; - -Client.prototype.disconnect = function(message, callback) { - if (typeof (message) === 'function') { - callback = message; - message = undefined; - } - message = message || 'node-irc says goodbye'; - if (this.conn.readyState == 'open') { - this._send('QUIT', message); - } - this.conn.requestedDisconnect = true; - if (typeof (callback) === 'function') { - this.conn.once('end', callback); - } - this.conn.end(); -}; - -Client.prototype.send = async function(...command) { - let delayPromise = Promise.resolve(); - if (this.opt.floodProtection) { - // Get the amount of time we should wait between messages - const delay = this.opt.floodProtectionDelay - Math.min( - this.opt.floodProtectionDelay, - Date.now() - this.lastSendTime, - ); - if (delay > MIN_DELAY_MS) { - delayPromise = new Promise((r) => setTimeout(r, delay)); - } - } - const currentSendingPromise = this.sendingPromise; - const sendPromise = (async () => { - await delayPromise; - await currentSendingPromise; - return this._send(...command); - })(); - this.sendingPromise = sendPromise.finally(); - return sendPromise; -}; - -Client.prototype._send = function(...cmdArgs) { - const args = Array.prototype.slice.call(cmdArgs); - - // Note that the command arg is included in the args array as the first element - if (args[args.length - 1].match(/\s/) || args[args.length - 1].match(/^:/) || args[args.length - 1] === '') { - args[args.length - 1] = ':' + args[args.length - 1]; - } - - if (this.opt.debug) - util.log('SEND: ' + args.join(' ')); - - if (this.conn.requestedDisconnect) { - return; - } - this.lastSendTime = Date.now(); - this.conn.write(args.join(' ') + '\r\n'); -}; - -Client.prototype.join = function(channel, callback) { - var channelName = channel.split(' ')[0]; - this.once('join' + channelName, function() { - // if join is successful, add this channel to opts.channels - // so that it will be re-joined upon reconnect (as channels - // specified in options are) - if (this.opt.channels.indexOf(channel) == -1) { - this.opt.channels.push(channel); - } - - if (typeof (callback) == 'function') { - return callback.apply(this, arguments); - } - }); - return this.send.apply(this, ['JOIN'].concat(channel.split(' '))); -}; - -Client.prototype.part = function(channel, message, callback) { - if (typeof (message) === 'function') { - callback = message; - message = undefined; - } - if (typeof (callback) == 'function') { - this.once('part' + channel, callback); - } - - // remove this channel from this.opt.channels so we won't rejoin - // upon reconnect - if (this.opt.channels.indexOf(channel) != -1) { - this.opt.channels.splice(this.opt.channels.indexOf(channel), 1); - } - - if (message) { - return this.send('PART', channel, message); - } else { - return this.send('PART', channel); - } -}; - -Client.prototype.action = async function(channel, text) { - if (typeof text === 'undefined') { - return; - } - await Promise.all(text.toString().split(/\r?\n/).filter((line) => - line.length > 0 - ).map((line) => this.say(channel, '\u0001ACTION ' + line + '\u0001'))); -}; - -// E.g. isUserPrefixMorePowerfulThan("@", "&") -Client.prototype.isUserPrefixMorePowerfulThan = function(prefix, testPrefix) { - var mode = this.modeForPrefix[prefix]; - var testMode = this.modeForPrefix[testPrefix]; - if (this.supported.usermodepriority.length === 0 || !mode || !testMode) { - return false; - } - if (this.supported.usermodepriority.indexOf(mode) === -1 || this.supported.usermodepriority.indexOf(testMode) === -1) { - return false; - } - // usermodepriority is a sorted string (lower index = more powerful) - return this.supported.usermodepriority.indexOf(mode) < this.supported.usermodepriority.indexOf(testMode); -}; - -Client.prototype._splitLongLines = function(words, maxLength, destination) { - if (words.length == 0) { - return destination; - } - if (words.length <= maxLength) { - destination.push(words); - return destination; - } - var c = words[maxLength]; - var cutPos; - var wsLength = 1; - if (c.match(/\s/)) { - cutPos = maxLength; - } else { - var offset = 1; - while ((maxLength - offset) > 0) { - var c = words[maxLength - offset]; - if (c.match(/\s/)) { - cutPos = maxLength - offset; - break; - } - offset++; - } - if (maxLength - offset <= 0) { - cutPos = maxLength; - wsLength = 0; - } - } - var part = words.substring(0, cutPos); - destination.push(part); - return this._splitLongLines(words.substring(cutPos + wsLength, words.length), maxLength, destination); -}; - -Client.prototype.say = function(target, text) { - return this._speak('PRIVMSG', target, text); -}; - -Client.prototype.notice = function(target, text) { - return this._speak('NOTICE', target, text); -}; - -Client.prototype._splitMessage = function(target, text) { - var self = this; - var maxLength = Math.min(this.maxLineLength - target.length, this.opt.messageSplit); - if (text) { - return text.toString().split(/\r?\n/).filter(function(line) { - return line.length > 0; - }).map(function(line) { - return self._splitLongLines(line, maxLength, []); - }).reduce(function(a, b) { - return a.concat(b); - }, []); - } - return []; -}; - -Client.prototype._speak = function(kind, target, text) { - const linesToSend = this._splitMessage(target, text); - return Promise.all(linesToSend.map((toSend) => { - const p = this.send(kind, target, toSend); - p.finally(() => { - if (kind == 'PRIVMSG') { - this.emit('selfMessage', target, toSend); - } - }); - return p; - })); -}; - -// Returns individual IRC messages that would be sent to target -// if sending text (via say() or notice()). -Client.prototype.getSplitMessages = function(target, text) { - return this._splitMessage(target, text); -}; - -Client.prototype.whois = function(nick, callback) { - if (typeof callback === 'function') { - var callbackWrapper = function(info) { - if (info.nick.toLowerCase() == nick.toLowerCase()) { - this.removeListener('whois', callbackWrapper); - return callback.apply(this, arguments); - } - }; - this.addListener('whois', callbackWrapper); - } - return this.send('WHOIS', nick); -}; - -// Send a NAMES command to channel. If callback is a function, add it as -// a listener for the names event, which is called when rpl_endofnames is -// received in response to original NAMES command. The callback should -// accept channelName as the first argument. An object with each key a -// user nick and each value '@' if they are a channel operator is passed -// as the second argument to the callback. -Client.prototype.names = function(channel, callback) { - if (typeof callback === 'function') { - var callbackWrapper = function (callbackChannel) { - if (callbackChannel === channel) { - return callback.apply(this, arguments); - } - } - this.addListener('names', callbackWrapper); - } - return this.send('NAMES', channel); -}; - -// Send a MODE command -Client.prototype.mode = function(channel, callback) { - if (typeof callback === 'function') { - var callbackWrapper = function (callbackChannel) { - if (callbackChannel === channel) { - return callback.apply(this, arguments); - } - } - this.addListener('mode_is', callbackWrapper); - } - return this.send('MODE', channel); -}; - -// Set user modes. If nick is falsey, your own user modes will be changed. -// E.g. to set "+RiG" on yourself: setUserMode("+RiG") -Client.prototype.setUserMode = function(mode, nick) { - nick = nick || this.nick; - return this.send('MODE', nick, mode); -}; - -Client.prototype.list = function() { - var args = Array.prototype.slice.call(arguments, 0); - args.unshift('LIST'); - return this.send.apply(this, args); -}; - -Client.prototype._addWhoisData = function(nick, key, value, onlyIfExists) { - if (onlyIfExists && !this._whoisData[nick]) return; - this._whoisData[nick] = this._whoisData[nick] || {nick: nick}; - this._whoisData[nick][key] = value; -}; - -Client.prototype._clearWhoisData = function(nick) { - // Ensure that at least the nick exists before trying to return - this._addWhoisData(nick, 'nick', nick); - var data = this._whoisData[nick]; - delete this._whoisData[nick]; - return data; -}; - -Client.prototype._handleCTCP = function(from, to, text, type, message) { - text = text.slice(1); - text = text.slice(0, text.indexOf('\u0001')); - var parts = text.split(' '); - this.emit('ctcp', from, to, text, type, message); - this.emit('ctcp-' + type, from, to, text, message); - if (type === 'privmsg' && text === 'VERSION') - this.emit('ctcp-version', from, to, message); - if (parts[0] === 'ACTION' && parts.length > 1) - this.emit('action', from, to, parts.slice(1).join(' '), message); - if (parts[0] === 'PING' && type === 'privmsg' && parts.length > 1) - this.ctcp(from, 'notice', text); -}; - -Client.prototype.ctcp = function(to, type, text) { - return this[type === 'privmsg' ? 'say' : 'notice'](to, '\1' + text + '\1'); -}; - -Client.prototype.convertEncoding = function(str) { - var self = this, out = str; - - if (self.opt.encoding) { - try { - const charset = detectCharset(str); - if (!charset) { - throw Error("No charset detected"); - } - const converter = new Iconv(charset.encoding, self.opt.encoding); - out = converter.convert(str); - } catch (err) { - if (self.opt.debug) { - util.log('\u001b[01;31mERROR: ' + err + '\u001b[0m'); - util.inspect({ str: str, charset: charset }); - } - } - } else if (self.opt.encodingFallback) { - try { - if (!isValidUTF8(str)) { - const converter = new Iconv(self.opt.encodingFallback, "UTF-8") - out = converter.convert(str) - } - } catch (err) { - if (self.opt.debug) { - util.log('\u001b[01;31mERROR: ' + err + '\u001b[0m'); - util.inspect({ str: str, encodingFallback: self.opt.encodingFallback }); - } - } - } - - return out; -}; -// blatantly stolen from irssi's splitlong.pl. Thanks, Bjoern Krombholz! -Client.prototype._updateMaxLineLength = function() { - // 497 = 510 - (":" + "!" + " PRIVMSG " + " :").length; - // target is determined in _speak() and subtracted there - this.maxLineLength = 497 - this.nick.length - this.hostMask.length; -}; - -// Checks the arg at the given index for a channel. If one exists, casemap it -// according to ISUPPORT rules. -Client.prototype._casemap = function(msg, index) { - if (!msg.args || !msg.args[index] || msg.args[index][0] !== "#") { - return; - } - msg.args[index] = this._toLowerCase(msg.args[index]); -} - -Client.prototype._toLowerCase = function(str) { - // http://www.irc.org/tech_docs/005.html - var knownCaseMappings = ['ascii', 'rfc1459', 'strict-rfc1459']; - if (knownCaseMappings.indexOf(this.supported.casemapping) === -1) { - return str; - } - var lower = str.toLowerCase(); - if (this.supported.casemapping === 'rfc1459') { - lower = lower. - replace(/\[/g, '{'). - replace(/\]/g, '}'). - replace(/\\/g, '|'). - replace(/\^/g, '~'); - } - else if (this.supported.casemapping === 'strict-rfc1459') { - lower = lower. - replace(/\[/g, '{'). - replace(/\]/g, '}'). - replace(/\\/g, '|'); - } - return lower; -} - -// https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions -function escapeRegExp(string){ - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string -} - -function randomInt(length) { - return Math.floor(Math.random() * length); -} diff --git a/package-lock.json b/package-lock.json index 0f5d9128..883c0194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,45 +4,18 @@ "lockfileVersion": 1, "requires": true, "dependencies": { - "JSV": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz", - "integrity": "sha1-0Hf2glVx+CEy+d/67Vh7QCn+/1c=", + "@types/node": { + "version": "14.14.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.45.tgz", + "integrity": "sha512-DssMqTV9UnnoxDWu959sDLZzfvqCF0qDNRjaWeYSui9xkFe61kKo4l1TWNTQONpuXEm+gLMRvdlzvNHBamzmEw==", "dev": true }, - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "dev": true - }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, "array-filter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-1.0.0.tgz", "integrity": "sha1-uveeYubvTCpMC4MSMtr/7CUfnYM=", "dev": true }, - "async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=", - "dev": true - }, "available-typed-arrays": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz", @@ -52,22 +25,6 @@ "array-filter": "^1.0.0" } }, - "babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", - "dev": true, - "requires": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - } - }, - "babylon": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", - "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", - "dev": true - }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -94,124 +51,23 @@ "get-intrinsic": "^1.0.2" } }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dev": true, - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, "chardet": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.3.0.tgz", "integrity": "sha512-cyTQGGptIjIT+CMGT5J/0l9c6Fb+565GCFjjeUTKxUO7w3oR+FcNCMEKTn5xtVKaLFmladN7QF68IiQsv5Fbdw==" }, - "cli-table": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.1.tgz", - "integrity": "sha1-9TsFJmqLGguTSz0IIebi3FkUriM=", - "dev": true, - "requires": { - "colors": "1.0.3" - } - }, - "colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", - "dev": true - }, - "commander": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", - "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", - "dev": true, - "requires": { - "graceful-readlink": ">= 1.0.0" - } - }, - "comment-parser": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-0.3.2.tgz", - "integrity": "sha1-PAPwd2uGo239mgosl8YwfzMggv4=", - "dev": true, - "requires": { - "readable-stream": "^2.0.4" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", - "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "core-js": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", - "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", - "dev": true - }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, - "cst": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/cst/-/cst-0.4.10.tgz", - "integrity": "sha512-U5ETe1IOjq2h56ZcBE3oe9rT7XryCH6IKgPMv0L7sSk6w29yR3p5egCK0T3BDNHHV95OoUBgXsqiVG+3a900Ag==", - "dev": true, - "requires": { - "babel-runtime": "^6.9.2", - "babylon": "^6.8.1", - "source-map-support": "^0.4.0" - } - }, - "cycle": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", - "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=", - "dev": true - }, "deep-equal": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-0.1.2.tgz", @@ -241,55 +97,6 @@ "integrity": "sha1-817qfXBekzuvE7LwOz+D2SFAOz4=", "dev": true }, - "dom-serializer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.1.tgz", - "integrity": "sha512-sK3ujri04WyjwQXVoK4PU3y8ula1stq10GJZpqHIUgoGZdsGzAGu65BnU3d08aTVSvO7mGPZUc0wTEDL+qGE0Q==", - "dev": true, - "requires": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - }, - "dependencies": { - "domelementtype": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz", - "integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==", - "dev": true - }, - "entities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz", - "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==", - "dev": true - } - } - }, - "domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", - "dev": true - }, - "domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", - "dev": true, - "requires": { - "domelementtype": "1" - } - }, - "domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", - "dev": true, - "requires": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, "dotignore": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz", @@ -305,12 +112,6 @@ "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", "dev": true }, - "entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", - "dev": true - }, "es-abstract": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz", @@ -378,36 +179,6 @@ "is-symbol": "^1.0.2" } }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "esprima": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", - "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", - "dev": true - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true - }, - "eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=", - "dev": true - }, "faucet": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/faucet/-/faucet-0.0.1.tgz", @@ -477,25 +248,6 @@ "has-symbols": "^1.0.1" } }, - "glob": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", - "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", - "dev": true, - "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "graceful-readlink": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", - "dev": true - }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -505,52 +257,18 @@ "function-bind": "^1.1.1" } }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, "has-bigints": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", "dev": true }, - "has-color": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz", - "integrity": "sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=", - "dev": true - }, "has-symbols": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", "dev": true }, - "htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", - "dev": true, - "requires": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - } - }, - "i": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/i/-/i-0.3.6.tgz", - "integrity": "sha1-2WyScyB28HJxG2sQ/X1PZa2O4j0=", - "dev": true - }, "iconv-lite": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", @@ -569,12 +287,6 @@ "wrappy": "1" } }, - "inherit": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/inherit/-/inherit-2.2.7.tgz", - "integrity": "sha512-dxJmC1j0Q32NFAjvbd6g3lXYLZ49HgzotgbSMwMkoiTXGhC9412Oc24g7A7M9cPPkw/vDsF2cSII+2zJwocUtQ==", - "dev": true - }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -693,12 +405,6 @@ "has-symbols": "^1.0.1" } }, - "is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", - "dev": true - }, "is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -717,104 +423,12 @@ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", "dev": true }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true - }, - "js-yaml": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.4.6.tgz", - "integrity": "sha1-a+GyP2JJ9T0pM3D9TRqqY84bTrA=", - "dev": true, - "requires": { - "argparse": "^1.0.2", - "esprima": "^2.6.0", - "inherit": "^2.2.2" - } - }, - "jscs": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/jscs/-/jscs-3.0.7.tgz", - "integrity": "sha1-cUG03/W4bjLQ6Z12S4NnZ8MNIBo=", - "dev": true, - "requires": { - "chalk": "~1.1.0", - "cli-table": "~0.3.1", - "commander": "~2.9.0", - "cst": "^0.4.3", - "estraverse": "^4.1.0", - "exit": "~0.1.2", - "glob": "^5.0.1", - "htmlparser2": "3.8.3", - "js-yaml": "~3.4.0", - "jscs-jsdoc": "^2.0.0", - "jscs-preset-wikimedia": "~1.0.0", - "jsonlint": "~1.6.2", - "lodash": "~3.10.0", - "minimatch": "~3.0.0", - "natural-compare": "~1.2.2", - "pathval": "~0.1.1", - "prompt": "~0.2.14", - "reserved-words": "^0.1.1", - "resolve": "^1.1.6", - "strip-bom": "^2.0.0", - "strip-json-comments": "~1.0.2", - "to-double-quotes": "^2.0.0", - "to-single-quotes": "^2.0.0", - "vow": "~0.4.8", - "vow-fs": "~0.3.4", - "xmlbuilder": "^3.1.0" - } - }, - "jscs-jsdoc": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jscs-jsdoc/-/jscs-jsdoc-2.0.0.tgz", - "integrity": "sha1-9T684CmqMSW9iCkLpQ1k1FEKSHE=", - "dev": true, - "requires": { - "comment-parser": "^0.3.1", - "jsdoctypeparser": "~1.2.0" - } - }, - "jscs-preset-wikimedia": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/jscs-preset-wikimedia/-/jscs-preset-wikimedia-1.0.1.tgz", - "integrity": "sha512-RWqu6IYSUlnYuCRCF0obCOHjJV0vhpLcvKbauwxmLQoZ0PiXDTWBYlfpsEfdhg7pmREAEwrARfDRz5qWD6qknA==", - "dev": true - }, - "jsdoctypeparser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-1.2.0.tgz", - "integrity": "sha1-597cFToRhJ/8UUEUSuhqfvDCU5I=", - "dev": true, - "requires": { - "lodash": "^3.7.0" - } - }, "jsonify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "dev": true }, - "jsonlint": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.3.tgz", - "integrity": "sha512-jMVTMzP+7gU/IyC6hvKyWpUU8tmTkK5b3BPNuMI9U8Sit+YAWLlZwB6Y6YrdCxfg2kNz05p3XY3Bmm4m26Nv3A==", - "dev": true, - "requires": { - "JSV": "^4.0.x", - "nomnom": "^1.5.x" - } - }, - "lodash": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", - "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", - "dev": true - }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -830,80 +444,10 @@ "integrity": "sha1-16oye87PUY+RBqxrjwA/o7zqhWY=", "dev": true }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "dev": true, - "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - } - } - }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true - }, - "natural-compare": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.2.2.tgz", - "integrity": "sha1-H5bWDjFBysG20FZTzg2urHY69qo=", - "dev": true - }, - "ncp": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.4.2.tgz", - "integrity": "sha1-q8xsvT7C7Spyn/bnwfqPAXhKhXQ=", - "dev": true - }, "node-gyp-build": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.7.0.tgz", - "integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w==" - }, - "nomnom": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.8.1.tgz", - "integrity": "sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=", - "dev": true, - "requires": { - "chalk": "~0.4.0", - "underscore": "~1.6.0" - }, - "dependencies": { - "ansi-styles": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.0.0.tgz", - "integrity": "sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=", - "dev": true - }, - "chalk": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.4.0.tgz", - "integrity": "sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=", - "dev": true, - "requires": { - "ansi-styles": "~1.0.0", - "has-color": "~0.1.0", - "strip-ansi": "~0.1.0" - } - }, - "strip-ansi": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz", - "integrity": "sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=", - "dev": true - } - } + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", + "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==" }, "object-inspect": { "version": "1.10.3", @@ -968,46 +512,6 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, - "pathval": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-0.1.1.tgz", - "integrity": "sha1-CPkRzcqczllCiA2ngXvAtyO2bYI=", - "dev": true - }, - "pkginfo": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.4.1.tgz", - "integrity": "sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8=", - "dev": true - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "prompt": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/prompt/-/prompt-0.2.14.tgz", - "integrity": "sha1-V3VPZPVD/XsIRXB8gY7OYY8F/9w=", - "dev": true, - "requires": { - "pkginfo": "0.x.x", - "read": "1.0.x", - "revalidator": "0.1.x", - "utile": "0.2.x", - "winston": "0.8.x" - } - }, - "read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", - "dev": true, - "requires": { - "mute-stream": "~0.0.4" - } - }, "readable-stream": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", @@ -1020,12 +524,6 @@ "string_decoder": "~0.10.x" } }, - "regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "dev": true - }, "regexp.prototype.flags": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz", @@ -1036,21 +534,6 @@ "define-properties": "^1.1.3" } }, - "reserved-words": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz", - "integrity": "sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE=", - "dev": true - }, - "resolve": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", - "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", - "dev": true, - "requires": { - "path-parse": "^1.0.6" - } - }, "resumer": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz", @@ -1060,43 +543,6 @@ "through": "~2.3.4" } }, - "revalidator": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz", - "integrity": "sha1-/s5hv6DBtSoga9axgZgYS91SOjs=", - "dev": true - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - }, - "dependencies": { - "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1113,39 +559,12 @@ "object-inspect": "^1.9.0" } }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - }, - "source-map-support": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", - "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", - "dev": true, - "requires": { - "source-map": "^0.5.6" - } - }, "sprintf": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/sprintf/-/sprintf-0.1.5.tgz", "integrity": "sha1-j4PjmpMXwaUCy324BQ5Rxnn27c8=", "dev": true }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true - }, - "stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", - "dev": true - }, "string.prototype.trim": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.4.tgz", @@ -1183,36 +602,6 @@ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", - "dev": true, - "requires": { - "is-utf8": "^0.2.0" - } - }, - "strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=", - "dev": true - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "dev": true - }, "tap-parser": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-0.4.3.tgz", @@ -1337,16 +726,10 @@ "xtend": "~2.1.1" } }, - "to-double-quotes": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-double-quotes/-/to-double-quotes-2.0.0.tgz", - "integrity": "sha1-qvIx1vqUiUn4GTAburRITYWI5Kc=", - "dev": true - }, - "to-single-quotes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/to-single-quotes/-/to-single-quotes-2.0.1.tgz", - "integrity": "sha1-fMKRUfD18sQZRvEZ9ZMv5VQXASU=", + "typescript": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", + "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", "dev": true }, "unbox-primitive": { @@ -1361,87 +744,12 @@ "which-boxed-primitive": "^1.0.2" } }, - "underscore": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", - "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=", - "dev": true - }, "utf-8-validate": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.2.tgz", - "integrity": "sha512-SwV++i2gTD5qh2XqaPzBnNX88N6HdyhQrNNRykvcS0QKvItV9u3vPEJr+X5Hhfb1JC0r0e1alL0iB09rY8+nmw==", - "requires": { - "node-gyp-build": "~3.7.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "utile": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/utile/-/utile-0.2.1.tgz", - "integrity": "sha1-kwyI6ZCY1iIINMNWy9mncFItkNc=", - "dev": true, - "requires": { - "async": "~0.2.9", - "deep-equal": "*", - "i": "0.3.x", - "mkdirp": "0.x.x", - "ncp": "0.4.x", - "rimraf": "2.x.x" - } - }, - "uuid": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", - "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=", - "dev": true - }, - "vow": { - "version": "0.4.20", - "resolved": "https://registry.npmjs.org/vow/-/vow-0.4.20.tgz", - "integrity": "sha512-YYoSYXUYABqY08D/WrjcWJxJSErcILRRTQpcPyUc0SFfgIPKSUFzVt7u1HC3TXGJZM/qhsSjCLNQstxqf7asgQ==", - "dev": true - }, - "vow-fs": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/vow-fs/-/vow-fs-0.3.6.tgz", - "integrity": "sha1-LUxZviLivyYY3fWXq0uqkjvnIA0=", - "dev": true, + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.5.tgz", + "integrity": "sha512-+pnxRYsS/axEpkrrEpzYfNZGXp0IjC/9RIxwM5gntY4Koi8SHmUGSfxfWqxZdRxrtaoVstuOzUp/rbs3JSPELQ==", "requires": { - "glob": "^7.0.5", - "uuid": "^2.0.2", - "vow": "^0.4.7", - "vow-queue": "^0.4.1" - }, - "dependencies": { - "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "vow-queue": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/vow-queue/-/vow-queue-0.4.3.tgz", - "integrity": "sha512-/poAKDTFL3zYbeQg7cl4BGcfP4sGgXKrHnRFSKj97dteUFu8oyXMwIcdwu8NSx/RmPGIuYx1Bik/y5vU4H/VKw==", - "dev": true, - "requires": { - "vow": "^0.4.17" + "node-gyp-build": "^4.2.0" } }, "which-boxed-primitive": { @@ -1484,50 +792,12 @@ "is-typed-array": "^1.1.3" } }, - "winston": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-0.8.3.tgz", - "integrity": "sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA=", - "dev": true, - "requires": { - "async": "0.2.x", - "colors": "0.6.x", - "cycle": "1.0.x", - "eyes": "0.1.x", - "isstream": "0.1.x", - "pkginfo": "0.3.x", - "stack-trace": "0.0.x" - }, - "dependencies": { - "colors": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", - "integrity": "sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w=", - "dev": true - }, - "pkginfo": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", - "integrity": "sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE=", - "dev": true - } - } - }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, - "xmlbuilder": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-3.1.0.tgz", - "integrity": "sha1-LIaIjy1OrehQ+jjKf3Ij9yCVFuE=", - "dev": true, - "requires": { - "lodash": "^3.5.0" - } - }, "xtend": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", diff --git a/package.json b/package.json index c8ae22e8..da759514 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,15 @@ "version": "0.3.12", "author": "Matrix.org (original fork from Martyn Smith )", "scripts": { + "prepare": "npm run build", + "build": "tsc --project tsconfig.json", "test": "./node_modules/faucet/bin/cmd.js test/test-*.js", "lint": "./node_modules/jscs/bin/jscs --preset=airbnb */*.js" }, "contributors": [ "node-irc contributors" ], - "types": "index.d.ts", + "types": "lib/index.d.ts", "repository": { "type": "git", "url": "http://github.com/matrix-org/node-irc" @@ -18,20 +20,21 @@ "bugs": { "url": "http://github.com/matrix-org/node-irc/issues" }, - "main": "lib/irc", + "main": "lib/index", "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" }, "license": "GPL-3.0", "dependencies": { - "irc-colors": "^1.5.0", - "utf-8-validate": "^5.0.2", + "chardet": "^1.3.0", "iconv-lite": "^0.6.2", - "chardet": "^1.3.0" + "irc-colors": "^1.5.0", + "utf-8-validate": "^5.0.5" }, "devDependencies": { + "@types/node": "^14", "faucet": "0.0.1", - "jscs": "^3.0.7", - "tape": "^5.2.2" + "tape": "^5.2.2", + "typescript": "^4.2.4" } } diff --git a/lib/codes.js b/src/codes.ts similarity index 97% rename from lib/codes.js rename to src/codes.ts index f905b26f..96d9bdfc 100644 --- a/lib/codes.js +++ b/src/codes.ts @@ -1,4 +1,6 @@ -module.exports = { +export type CommandType = 'reply'|'error'|'normal'; + +export const replyCodes = { '001': { name: 'rpl_welcome', type: 'reply' @@ -231,6 +233,10 @@ module.exports = { name: 'rpl_creationtime', type: 'reply' }, + 330: { + name: 'rpl_whoisaccount', + type: 'reply' + }, 331: { name: 'rpl_notopic', type: 'reply' @@ -543,4 +549,4 @@ module.exports = { name: 'err_usersdontmatch', type: 'error' } -}; +} as {[id: string]: {name: string, type: CommandType}}; diff --git a/lib/colors.js b/src/colors.ts similarity index 84% rename from lib/colors.js rename to src/colors.ts index f5ef5294..dba3f1e6 100644 --- a/lib/colors.js +++ b/src/colors.ts @@ -1,4 +1,4 @@ -var codes = { +export const codes = { white: '\u000300', black: '\u000301', dark_blue: '\u000302', @@ -21,13 +21,11 @@ var codes = { reset: '\u000f' }; -exports.codes = codes; -function wrap(color, text, resetColor) { +export function wrap(color: keyof(typeof codes), text: string, resetColor: keyof(typeof codes)) { if (codes[color]) { text = codes[color] + text; text += (codes[resetColor]) ? codes[resetColor] : codes.reset; } return text; } -exports.wrap = wrap; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..242ef59c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './colors'; +export * from './irc'; \ No newline at end of file diff --git a/src/irc.ts b/src/irc.ts new file mode 100644 index 00000000..59d84c1f --- /dev/null +++ b/src/irc.ts @@ -0,0 +1,1543 @@ +/* + irc.js - Node JS IRC client library + + Copyright 2010 Martyn Smith + Copyright 2020-2021 The Matrix.org Foundation C.I.C + + This library is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this library. If not, see . +*/ +import * as dns from 'dns'; +import { Socket, createConnection, TcpSocketConnectOpts } from 'net'; +import * as tls from 'tls'; +import * as util from 'util'; +import isValidUTF8 from 'utf-8-validate'; +import { EventEmitter } from 'events'; +import * as Iconv from 'iconv-lite'; +import * as detectCharset from 'chardet'; +import { Message, parseMessage } from './parse_message'; + +const lineDelimiter = new RegExp('\r\n|\r|\n'); +const MIN_DELAY_MS = 33; + +export interface ChanData { + created: string; + key: string; + serverName: string; + users: {[nick: string]: string /* mode */}, + mode: string; + topic: string; + topicBy: string; +} + +export interface ChanListItem { + name: string; + users: string; + topic: string; +} + +export interface WhoisResponse { + nick: string; + user?: string; + channels?: string[]; + host?: string; + realname?: string; + away?: string; + idle?: string; + server?: string; + serverinfo?: string; + operator?: string; + account?: string; + accountinfo?: string; +} + +export interface IrcClientOpts { + password?: string|null; + userName?: string; + realName?: string; + port?: number; + family?: 4|6|null; + bustRfc3484?: boolean; + localAddress?: string|null; + localPort?: number|null; + debug?: boolean; + showErrors?: boolean; + autoRejoin?: boolean; + autoConnect?: boolean; + channels?: string[]; + retryCount?: number|null; + retryDelay?: number; + secure?: boolean|object; + selfSigned?: boolean; + certExpired?: boolean; + floodProtection?: boolean; + floodProtectionDelay?: number; + sasl?: boolean; + saslType?: string; + stripColors?: boolean; + channelPrefixes?: string; + messageSplit?: number; + encoding?: string|false; + encodingFallback?: string; + onNickConflict?: (maxLen?: number) => string, + webirc?: { + pass: string, + ip: string, + user: string + host?: string, + }; + nickMod?: number; +} + +/** + * Similar to `IrcClientOpts` but most properties + * must be defined. + */ +interface IrcClientOptInternal extends IrcClientOpts { + password: string|null; + userName: string; + realName: string; + port: number; + family: 4|6; + bustRfc3484: boolean; + localAddress: string|null; + localPort: number|null; + debug: boolean; + showErrors: boolean; + autoRejoin: boolean; + autoConnect: boolean; + channels: string[]; + retryCount: number|null; + retryDelay: number; + secure: boolean|object; + selfSigned: boolean; + certExpired: boolean; + floodProtection: boolean; + floodProtectionDelay: number; + sasl: boolean; + saslType: string; + stripColors: boolean; + channelPrefixes: string; + messageSplit: number; + encoding: string|false; + onNickConflict: (maxLen?: number) => string, + webirc: { + pass: string, + ip: string, + user: string, + host?: string, + }; +} + +interface IrcSupported { + channel: { + idlength: {[key: string]: string}; + length: number; + limit: {[key: string]: number}; + modes: { a: string; b: string; c: string; d: string;}, + types: string; + }; + maxlist: {[key: string]: number}; + maxtargets:{[key: string]: number}; + modes: number; + nicklength: number; + topiclength: number; + kicklength: number; + usermodes: string; + usermodepriority: string; // E.g "ov" + // http://www.irc.org/tech_docs/005.html + casemapping: 'ascii'|'rfc1459'|'strict-rfc1459'; + +} + + +export class Client extends EventEmitter { + private sendingPromise = Promise.resolve(); + private lastSendTime = 0; + private nickMod = 0; + private opt: IrcClientOptInternal; + private hostMask = ''; + private prevClashNick = ''; + private maxLineLength: number = 0; + public conn?: Socket|tls.TLSSocket; + private requestedDisconnect = false; + private supportedState: IrcSupported; + + /** + * Cached data + */ + private whoisData = new Map(); + public chans: {[key: string]: ChanData} = {}; + public prefixForMode: {[mode: string]: string} = {}; // o => @ + public modeForPrefix: {[prefix: string]: string} = {}; // @ => o + + /** + * These variables are used to build up state and should be discarded after use. + */ + private motd?: string = ""; + private channelListState?: ChanListItem[]; + + /** + * This will either be the requested nick or the actual nickname. + */ + private currentNick: string; + + get nick() { + return this.currentNick; + } + + get supported(): IrcSupported { + return { + ...this.supportedState, + }; + } + + constructor (private server: string, requestedNick: string, opt: IrcClientOpts) { + super(); + this.currentNick = requestedNick; + /** + * This promise is used to block new sends until the previous one completes. + */ + this.sendingPromise = Promise.resolve(); + this.lastSendTime = 0; + this.opt = { + password: null, + userName: 'nodebot', + realName: 'nodeJS IRC client', + port: 6667, + bustRfc3484: false, + localAddress: null, + localPort: null, + debug: false, + showErrors: false, + autoRejoin: false, + autoConnect: true, + channels: [], + retryCount: null, + retryDelay: 2000, + secure: false, + selfSigned: false, + certExpired: false, + floodProtection: false, + floodProtectionDelay: 1000, + sasl: false, + saslType: 'PLAIN', + stripColors: false, + channelPrefixes: '&#', + messageSplit: 512, + encoding: false, + onNickConflict: this.onNickConflict.bind(this), + webirc: { + pass: '', + ip: '', + user: '' + }, + ...opt, + family: opt.family ? opt.family : 4, + }; + this.nickMod = opt.nickMod ?? 0; + + // Features supported by the server + // (initial values are RFC 1459 defaults. Zeros signify + // no default or unlimited value) + this.supportedState = { + channel: { + idlength: {}, + length: 200, + limit: {}, + modes: { a: '', b: '', c: '', d: ''}, + types: this.opt.channelPrefixes + }, + kicklength: 0, + maxlist: {}, + maxtargets: {}, + modes: 3, + nicklength: 9, + topiclength: 0, + usermodes: '', + usermodepriority: '', // E.g "ov" + casemapping: 'ascii' + }; + + super.on('raw', this.onRaw.bind(this)); + + super.on('kick', (channel: string, who: string, by: string) => { + if (this.opt.autoRejoin) + this.send('join', ...channel.split(' ')); + } + ); + super.on('motd', (motd: string) => { + this.opt.channels?.forEach((channel) => { + this.send('join', ...channel.split(' ')); + }); + }); + + // TODO - fail if nick or server missing + // TODO - fail if username has a space in it + if (this.opt.autoConnect === true) { + this.connect(); + } + + } + + private onRaw(message: Message) { + let channel: ChanData; + let from, to: string; + switch (message.command) { + case 'rpl_welcome': + // Set nick to whatever the server decided it really is + // (normally this is because you chose something too long and + // the server has shortened it + this.currentNick = message.args[0]; + // Note our hostmask to use it in splitting long messages. + // We don't send our hostmask when issuing PRIVMSGs or NOTICEs, + // of course, but rather the servers on the other side will + // include it in messages and will truncate what we send if + // the string is too long. Therefore, we need to be considerate + // neighbors and truncate our messages accordingly. + var welcomeStringWords = message.args[1].split(/\s+/); + this.hostMask = welcomeStringWords[welcomeStringWords.length - 1]; + this._updateMaxLineLength(); + this.emit('registered', message); + this.whois(this.nick, (args) => { + this.currentNick = args.nick; + this.hostMask = args.user + "@" + args.host; + this._updateMaxLineLength(); + }); + break; + case 'rpl_myinfo': + this.supportedState.usermodes = message.args[3]; + break; + case 'rpl_isupport': + message.args.forEach((arg) => { + var match; + match = arg.match(/([A-Z]+)=(.*)/); + if (match) { + var param = match[1]; + var value = match[2]; + switch (param) { + case 'CASEMAPPING': + // We assume this is fine. + this.supportedState.casemapping = value as any; + break; + case 'CHANLIMIT': + value.split(',').forEach((val) => { + const [val0, val1] = val.split(':'); + this.supportedState.channel.limit[val0] = parseInt(val1); + }); + break; + case 'CHANMODES': + const values = value.split(','); + const type: ['a','b','c','d'] = ['a', 'b', 'c', 'd']; + for (var i = 0; i < type.length; i++) { + this.supportedState.channel.modes[type[i]] += values[i]; + } + break; + case 'CHANTYPES': + this.supportedState.channel.types = value; + break; + case 'CHANNELLEN': + this.supportedState.channel.length = parseInt(value); + break; + case 'IDCHAN': + value.split(',').forEach((val) => { + const [val0, val1] = val.split(':'); + this.supportedState.channel.idlength[val0] = val1; + }); + break; + case 'KICKLEN': + this.supportedState.kicklength = parseInt(value); + break; + case 'MAXLIST': + value.split(',').forEach((val) => { + const [val0, val1] = val.split(':'); + this.supportedState.maxlist[val0] = parseInt(val1); + }); + break; + case 'NICKLEN': + this.supportedState.nicklength = parseInt(value); + break; + case 'PREFIX': + match = value.match(/\((.*?)\)(.*)/); + if (match) { + this.supportedState.usermodepriority = match[1]; + const match1 = match[1].split(''); + const match2 = match[2].split(''); + while (match1.length) { + this.modeForPrefix[match2[0]] = match1[0]; + this.supportedState.channel.modes.b += match1[0]; + const idx = match1.shift(); + if (idx) { + const result = match2.shift(); + if (result) { + this.prefixForMode[idx] = result; + } + } + } + } + break; + case 'STATUSMSG': + break; + case 'TARGMAX': + value.split(',').forEach((val) => { + let [ key, value ] = val.split(':'); + value = value ?? parseInt(value); + if (typeof value === 'number') { + this.supportedState.maxtargets[key] = value; + } + }); + break; + case 'TOPICLEN': + this.supportedState.topiclength = parseInt(value); + break; + } + } + }); + break; + case 'rpl_yourhost': + case 'rpl_created': + case 'rpl_luserclient': + case 'rpl_luserop': + case 'rpl_luserchannels': + case 'rpl_luserme': + case 'rpl_localusers': + case 'rpl_globalusers': + case 'rpl_statsconn': + case 'rpl_luserunknown': + // Random welcome crap, ignoring + break; + case 'err_nicknameinuse': + var nextNick = this.opt.onNickConflict(); + if (this.nickMod > 1) { + // We've already tried to resolve this nick before and have failed to do so. + // This could just be because there are genuinely 2 clients with the + // same nick and the same nick with a numeric suffix or it could be much + // much more gnarly. If there is a conflict and the original nick is equal + // to the NICKLEN, then we'll never be able to connect because the numeric + // suffix will always be truncated! + // + // To work around this, we'll persist what nick we send up, and compare it + // to the nick which is returned in this error response. If there is + // truncation going on, the two nicks won't match, and then we can do + // something about it. + + if (this.prevClashNick !== '') { + // we tried to fix things and it still failed, check to make sure + // that the server isn't truncating our nick. + var errNick = message.args[1]; + if (errNick !== this.prevClashNick) { + nextNick = this.opt.onNickConflict(errNick.length); + } + } + + this.prevClashNick = nextNick; + } + + this._send('NICK', nextNick); + this.currentNick = nextNick; + this._updateMaxLineLength(); + break; + case 'PING': + this.send('PONG', message.args[0]); + this.emit('ping', message.args[0]); + break; + case 'PONG': + this.emit('pong', message.args[0]); + break; + case 'NOTICE': + this._casemap(message, 0); + from = message.nick; + to = message.args[0]; + let noticeText = message.args[1] || ''; + if (noticeText[0] === '\u0001' && noticeText.lastIndexOf('\u0001') > 0) { + if (from && to && noticeText) { + this._handleCTCP(from, to, noticeText, 'notice', message); + } + break; + } + this.emit('notice', from, to, noticeText, message); + + if (this.opt.debug && to == this.nick) + util.log('GOT NOTICE from ' + (from ? '"' + from + '"' : 'the server') + ': "' + noticeText + '"'); + break; + case 'MODE': + this._casemap(message, 0); + if (this.opt.debug) + util.log('MODE: ' + message.args[0] + ' sets mode: ' + message.args[1]); + + channel = this.chanData(message.args[0]); + if (!channel) break; + const modeList = message.args[1].split(''); + let adding = true; + const modeArgs = message.args.slice(2); + modeList.forEach((mode) => { + if (mode == '+') { + adding = true; + return; + } + if (mode == '-') { + adding = false; + return; + } + if (mode in this.prefixForMode) { + // channel user modes + var user = modeArgs.shift(); + if (adding) { + if (user && channel.users[user] != null && channel.users[user].indexOf(this.prefixForMode[mode]) === -1) { + channel.users[user] += this.prefixForMode[mode]; + } + + this.emit('+mode', message.args[0], message.nick, mode, user, message); + } + else { + if (user && channel.users[user]) { + channel.users[user] = channel.users[user].replace(this.prefixForMode[mode], ''); + } + this.emit('-mode', message.args[0], message.nick, mode, user, message); + } + } + else { + var modeArg; + // channel modes + if (mode.match(/^[bkl]$/)) { + modeArg = modeArgs.shift(); + if (!modeArg || modeArg.length === 0) + modeArg = undefined; + } + // TODO - deal nicely with channel modes that take args + if (adding) { + if (channel.mode.indexOf(mode) === -1) + channel.mode += mode; + + this.emit('+mode', message.args[0], message.nick, mode, modeArg, message); + } + else { + channel.mode = channel.mode.replace(mode, ''); + this.emit('-mode', message.args[0], message.nick, mode, modeArg, message); + } + } + }); + break; + case 'NICK': + if (message.nick == this.nick) { + // the user just changed their own nick + this.currentNick = message.args[0]; + this._updateMaxLineLength(); + } + + if (this.opt.debug) + util.log('NICK: ' + message.nick + ' changes nick to ' + message.args[0]); + + const channelsForNick: string[] = []; + + // finding what channels a user is in + Object.keys(this.chans).forEach((channame) => { + var channel = this.chans[channame]; + if (message.nick && message.nick in channel.users) { + channel.users[message.args[0]] = channel.users[message.nick]; + delete channel.users[message.nick]; + channelsForNick.push(channame); + } + }); + + // old nick, new nick, channels + this.emit('nick', message.nick, message.args[0], channelsForNick, message); + break; + case 'rpl_motdstart': + this.motd = message.args[1] + '\n'; + break; + case 'rpl_motd': + this.motd += message.args[1] + '\n'; + break; + case 'rpl_endofmotd': + case 'err_nomotd': + this.motd += message.args[1] + '\n'; + this.emit('motd', this.motd); + break; + case 'rpl_namreply': + this._casemap(message, 2); + channel = this.chanData(message.args[2]); + if (!message.args[3]) { + // No users + break; + } + const users = message.args[3].trim().split(/ +/); + if (channel) { + users.forEach(user => { + // user = "@foo", "+foo", "&@foo", etc... + // The symbols are the prefix set. + const allowedSymbols = Object.keys(this.modeForPrefix).join(""); + // Split out the prefix from the nick e.g "@&foo" => ["@&foo", "@&", "foo"] + const prefixRegex = new RegExp("^([" + escapeRegExp(allowedSymbols) + "]*)(.*)$"); + const match = user.match(prefixRegex); + if (match) { + const userPrefixes = match[1]; + let knownPrefixes = ''; + for (let i = 0; i < userPrefixes.length; i++) { + if (userPrefixes[i] in this.modeForPrefix) { + knownPrefixes += userPrefixes[i]; + } + } + if (knownPrefixes.length > 0) { + channel.users[match[2]] = knownPrefixes; + } + else { + // recombine just in case this server allows weird chars in the nick. + // We know it isn't a mode char. + channel.users[match[1] + match[2]] = ''; + } + } + }); + } + break; + case 'rpl_endofnames': + this._casemap(message, 1); + channel = this.chanData(message.args[1]); + if (channel) { + this.emit('names', message.args[1], channel.users); + this.emit('names' + message.args[1], channel.users); + this._send('MODE', message.args[1]); + } + break; + case 'rpl_topic': + this._casemap(message, 1); + channel = this.chanData(message.args[1]); + if (channel) { + channel.topic = message.args[2]; + } + break; + case 'rpl_away': + this._addWhoisData(message.args[1], 'away', message.args[2], true); + break; + case 'rpl_whoisuser': + this._addWhoisData(message.args[1], 'user', message.args[2]); + this._addWhoisData(message.args[1], 'host', message.args[3]); + this._addWhoisData(message.args[1], 'realname', message.args[5]); + break; + case 'rpl_whoisidle': + this._addWhoisData(message.args[1], 'idle', message.args[2]); + break; + case 'rpl_whoischannels': + // TODO - clean this up? + if (message.args.length >= 3) + this._addWhoisData(message.args[1], 'channels', message.args[2].trim().split(/\s+/)); + break; + case 'rpl_whoisserver': + this._addWhoisData(message.args[1], 'server', message.args[2]); + this._addWhoisData(message.args[1], 'serverinfo', message.args[3]); + break; + case 'rpl_whoisoperator': + this._addWhoisData(message.args[1], 'operator', message.args[2]); + break; + case 'rpl_whoisaccount': + this._addWhoisData(message.args[1], 'account', message.args[2]); + this._addWhoisData(message.args[1], 'accountinfo', message.args[3]); + break; + case 'rpl_endofwhois': + this.emit('whois', this._clearWhoisData(message.args[1])); + break; + case 'rpl_liststart': + this.channelListState = []; + this.emit('channellist_start'); + break; + case 'rpl_list': + const chanListEntry = { + name: message.args[1], + users: message.args[2], + topic: message.args[3] + }; + this.emit('channellist_item', chanListEntry); + if (this.channelListState) { + this.channelListState.push(chanListEntry); + } + break; + case 'rpl_listend': + this.emit('channellist', this.channelListState); + // Clear after use. + this.channelListState = undefined; + break; + case 'rpl_topicwhotime': + this._casemap(message, 1); + channel = this.chanData(message.args[1]); + if (channel) { + channel.topicBy = message.args[2]; + // channel, topic, nick + this.emit('topic', message.args[1], channel.topic, channel.topicBy, message); + } + break; + case 'TOPIC': + // channel, topic, nick + this._casemap(message, 0); + this.emit('topic', message.args[0], message.args[1], message.nick, message); + + channel = this.chanData(message.args[0]); + if (channel) { + channel.topic = message.args[1]; + if (message.nick) { + channel.topicBy = message.nick; + } + } + break; + case 'rpl_channelmodeis': + this._casemap(message, 1); + channel = this.chanData(message.args[1]); + if (channel) { + channel.mode = message.args[2]; + } + + this.emit('mode_is', message.args[1], message.args[2]); + break; + case 'rpl_creationtime': + this._casemap(message, 1); + channel = this.chanData(message.args[1]); + if (channel) { + channel.created = message.args[2]; + } + break; + case 'JOIN': + this._casemap(message, 0); + // channel, who + if (this.nick == message.nick) { + this.chanData(message.args[0], true); + } + else { + channel = this.chanData(message.args[0]); + if (message.nick && channel && channel.users) { + channel.users[message.nick] = ''; + } + } + this.emit('join', message.args[0], message.nick, message); + this.emit('join' + message.args[0], message.nick, message); + if (message.args[0] != message.args[0].toLowerCase()) { + this.emit('join' + message.args[0].toLowerCase(), message.nick, message); + } + break; + case 'PART': + this._casemap(message, 0); + // channel, who, reason + this.emit('part', message.args[0], message.nick, message.args[1], message); + this.emit('part' + message.args[0], message.nick, message.args[1], message); + if (message.args[0] != message.args[0].toLowerCase()) { + this.emit('part' + message.args[0].toLowerCase(), message.nick, message.args[1], message); + } + if (this.nick == message.nick) { + this.removeChanData(message.args[0]); + } + else { + channel = this.chanData(message.args[0]); + if (channel && channel.users && message.nick) { + delete channel.users[message.nick]; + } + } + break; + case 'KICK': + this._casemap(message, 0); + // channel, who, by, reason + this.emit('kick', message.args[0], message.args[1], message.nick, message.args[2], message); + this.emit('kick' + message.args[0], message.args[1], message.nick, message.args[2], message); + if (message.args[0] != message.args[0].toLowerCase()) { + this.emit('kick' + message.args[0].toLowerCase(), + message.args[1], message.nick, message.args[2], message); + } + + if (this.nick == message.args[1]) { + this.removeChanData(message.args[0]); + } + else { + channel = this.chanData(message.args[0]); + if (channel && channel.users) { + delete channel.users[message.args[1]]; + } + } + break; + case 'KILL': + const nick = message.args[0]; + const killChannels = []; + for (const [channame, channel] of Object.entries(this.chans)) { + if (message.nick && message.nick in channel.users) { + delete channel.users[message.nick]; + killChannels.push(channame); + } + } + this.emit('kill', nick, message.args[1], killChannels, message); + break; + case 'PRIVMSG': + this._casemap(message, 0); + from = message.nick; + to = message.args[0]; + let msgText = message.args[1] || ''; + if (from && msgText[0] === '\u0001' && msgText.lastIndexOf('\u0001') > 0) { + this._handleCTCP(from, to, msgText, 'privmsg', message); + break; + } + this.emit('message', from, to, msgText, message); + if (this.supportedState.channel.types.indexOf(to.charAt(0)) !== -1) { + this.emit('message#', from, to, msgText, message); + this.emit('message' + to, from, msgText, message); + if (to != to.toLowerCase()) { + this.emit('message' + to.toLowerCase(), from, msgText, message); + } + } + if (to.toUpperCase() === this.nick.toUpperCase()) this.emit('pm', from, msgText, message); + + if (this.opt.debug && to == this.nick) + util.log('GOT MESSAGE from ' + from + ': ' + msgText); + break; + case 'INVITE': + this._casemap(message, 1); + from = message.nick; + to = message.args[0]; + this.emit('invite', message.args[1], from, message); + break; + case 'QUIT': + if (this.opt.debug) + util.log('QUIT: ' + message.prefix + ' ' + message.args.join(' ')); + if (this.nick == message.nick) { + // TODO handle? + break; + } + // handle other people quitting + + const quitChannels: string[] = []; + + // finding what channels a user is in? + for (const [channame, channel] of Object.entries(this.chans)) { + if (message.nick && message.nick in channel.users) { + delete channel.users[message.nick]; + quitChannels.push(channame); + } + } + + // who, reason, channels + this.emit('quit', message.nick, message.args[0], quitChannels, message); + break; + + // for sasl + case 'CAP': + if (message.args[0] === '*' && + message.args[1] === 'ACK' && + message.args[2].split(' ').includes('sasl')) + this._send('AUTHENTICATE', this.opt.saslType); + break; + case 'AUTHENTICATE': + if (message.args[0] === '+') { + switch (this.opt.saslType) { + case 'PLAIN': + this._send('AUTHENTICATE', + Buffer.from( + this.nick + '\x00' + + this.opt.userName + '\x00' + + this.opt.password + ).toString('base64')); + break; + case 'EXTERNAL': + this._send('AUTHENTICATE', '+'); + break; + } + } + break; + case '903': + this._send('CAP', 'END'); + break; + case 'err_unavailresource': + // err_unavailresource has been seen in the wild on Freenode when trying to + // connect with the nick 'boot'. I'm guessing they have reserved that nick so + // no one can claim it. The error handling though is identical to offensive word + // nicks hence the fall through here. + case 'err_erroneusnickname': + if (this.opt.showErrors) + util.log('\x1B[01;31mERROR: ' + util.inspect(message) + '\x1B[0m'); + + // The Scunthorpe Problem + // ---------------------- + // + // Some IRC servers have offensive word filters on nicks. Trying to change your + // nick to something with an offensive word in it will return this error. + // + // If we are already logged in, this is fine, we can just emit an error and + // let the client deal with it. + // If we are NOT logged in however, we need to propose a new nick else we + // will never be able to connect successfully and the connection will + // eventually time out, most likely resulting in infinite-reconnects. + // + // Check to see if we are NOT logged in, and if so, use a "random" string + // as the next nick. + if (this.hostMask !== '') { // hostMask set on rpl_welcome + this.emit('error', message); + break; + } + // rpl_welcome has not been sent + // We can't use a truly random string because we still need to abide by + // the BNF for nicks (first char must be A-Z, length limits, etc). We also + // want to be able to debug any issues if people say that they didn't get + // the nick they wanted. + var rndNick = "enick_" + Math.floor(Math.random() * 1000) // random 3 digits + this._send('NICK', rndNick); + this.currentNick = rndNick; + this._updateMaxLineLength(); + break; + + default: + if (message.commandType == 'error') { + this.emit('error', message); + if (this.opt.showErrors) + util.log('\u001b[01;31mERROR: ' + util.inspect(message) + '\u001b[0m'); + } + else { + if (this.opt.debug) + util.log('\u001b[01;31mUnhandled message: ' + util.inspect(message) + '\u001b[0m'); + break; + } + } + } + + private onNickConflict(maxLen?: number): string { + if (typeof (this.nickMod) == 'undefined') { + this.nickMod = 0; + } + this.nickMod++; + let n = this.nick + this.nickMod; + if (maxLen && n.length > maxLen) { + // truncate the end of the nick and then suffix a numeric + var digitStr = "" + this.nickMod; + var maxNickSegmentLen = maxLen - digitStr.length; + n = this.nick.substr(0, maxNickSegmentLen) + digitStr; + } + return n; + } + + public chanData(name: string, create = false) { + var key = name.toLowerCase(); + if (create) { + this.chans[key] = this.chans[key] || { + key: key, + serverName: name, + users: {}, + mode: '' + }; + } + + return this.chans[key]; + } + + public removeChanData(name: string) { + const key = name.toLowerCase(); + // Sometimes we can hit a race where we will get a PART about ourselves before we + // have joined a channel fully and stored it in state. + // Ensure that we have chanData before deleting + if (this.chans[key]) { + delete this.chans[key]; + } + } + + private _connectionHandler() { + if (this.opt.webirc.ip && this.opt.webirc.pass && this.opt.webirc.host) { + this._send('WEBIRC', this.opt.webirc.pass, this.opt.userName, this.opt.webirc.host, this.opt.webirc.ip); + } + if (this.opt.sasl) { + // see http://ircv3.atheme.org/extensions/sasl-3.1 + this._send('CAP REQ', 'sasl'); + } else if (this.opt.password) { + this._send('PASS', this.opt.password); + } + if (this.opt.debug) + util.log('Sending irc NICK/USER'); + this._send('NICK', this.nick); + this.currentNick = this.nick; + this._updateMaxLineLength(); + this._send('USER', this.opt.userName, '8', '*', this.opt.realName); + this.emit('connect'); + } + + public connect(retryCountOrCallBack?: number|(() => void), callback?: () => void) { + let retryCount: number; + if (typeof retryCountOrCallBack === 'function') { + callback = retryCountOrCallBack; + retryCount = this.opt.retryCount ?? 0; + } else { + retryCount = retryCountOrCallBack ?? this.opt.retryCount ?? 0; + } + if (typeof callback === 'function') { + this.once('registered', callback); + } + this.chans = {}; + + // socket opts + const connectionOpts: TcpSocketConnectOpts = { + host: this.server, + port: this.opt.port, + family: this.opt.family, + }; + + // local address to bind to + if (this.opt.localAddress) + connectionOpts.localAddress = this.opt.localAddress; + if (this.opt.localPort) + connectionOpts.localPort = this.opt.localPort; + + if (this.opt.bustRfc3484) { + // RFC 3484 attempts to sort address results by "locallity", taking + // into consideration the length of the common prefix between the + // candidate local source address and the destination. In practice + // this always sorts one or two servers ahead of all the rest, which + // isn't what we want for proper load balancing. With this option set + // we'll randomise the list of all results so that we can spread load + // between all the servers. + connectionOpts.lookup = (hostname, options, callback) => { + dns.lookup(hostname, {all: true, ...options}, (err, addresses) => { + if (err) { + if (options.all) { + // @types/node doesn't provision for an all callback response, so we have to + // do some unsafe typing here. + return (callback as any)(err, addresses); + } + else { + return (callback as any)(err, null, null); + } + } + + if (options.all) { + const shuffled: dns.LookupAddress[] = []; + while (Array.isArray(addresses) && addresses.length) { + var i = randomInt(addresses.length); + shuffled.push(addresses.splice(i, 1)[0]); + } + // @types/node doesn't provision for an all callback response, so we have to + // do some unsafe typing here. + (callback as any)(err, shuffled); + } + else { + const chosen = addresses[randomInt(addresses.length)] as dns.LookupAddress; + if (typeof chosen === 'object') { + + } + // @types/node doesn't provision for an all callback response, so we have to + // do some unsafe typing here. + (callback as any)(err, chosen.address, chosen.family); + } + }); + }; + } + + // destroy old socket before allocating a new one + if (this.conn) + this.conn.destroy(); + + // try to connect to the server + if (this.opt.secure) { + let secureOpts: tls.ConnectionOptions = { + ...connectionOpts, + enableTrace: true, + rejectUnauthorized: !this.opt.selfSigned, + } + + if (typeof this.opt.secure == 'object') { + // copy "secure" opts to options passed to connect() + secureOpts = { + ...secureOpts, + ...this.opt.secure, + }; + } + + this.conn = tls.connect(secureOpts, () => { + if (this.conn === undefined) { + throw Error('Conn was not defined'); + } + if (!(this.conn instanceof tls.TLSSocket)) { + throw Error('Conn was not a TLSSocket'); + } + + // callback called only after successful socket connection + + if (!this.conn.authorized) { + util.log(this.conn.authorizationError.toString()); + switch (this.conn.authorizationError.toString()) { + case 'DEPTH_ZERO_SELF_SIGNED_CERT': + case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': + case 'SELF_SIGNED_CERT_IN_CHAIN': + if (!this.opt.selfSigned) { + return this.conn.destroy(this.conn.authorizationError); + } + break; + case 'CERT_HAS_EXPIRED': + if (!this.opt.certExpired) { + return this.conn.destroy(this.conn.authorizationError); + } + break; + default: + // Fail on other errors + return this.conn.destroy(this.conn.authorizationError) + } + } + if (!this.opt.encoding) { + this.conn.setEncoding('utf-8'); + } + this._connectionHandler(); + }); + } else { + this.conn = createConnection(connectionOpts, this._connectionHandler.bind(this)); + } + + this.requestedDisconnect = false; + this.conn.setTimeout(1000 * 180); + + let buffer = Buffer.alloc(0); + + this.conn.addListener('data', (chunk: string|Buffer) => { + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk); + } + buffer = Buffer.concat([buffer, chunk]); + + const lines = this.convertEncoding(buffer).toString().split(lineDelimiter); + + if (lines.pop()) { + // if buffer is not ended with \r\n, there's more chunks. + return; + } else { + // else, initialize the buffer. + buffer = Buffer.alloc(0); + } + + lines.forEach((line) => { + if (!line.length) { + return; + } + const message = parseMessage(line, this.opt.stripColors); + try { + this.emit('raw', message); + } catch (err) { + if (!this.requestedDisconnect) { + throw err; + } + } + }); + }); + this.conn.addListener('end', () => { + if (this.opt.debug) { + util.log('Connection got "end" event'); + } + }); + this.conn.addListener('close', () => { + if (this.opt.debug) { + util.log('Connection got "close" event'); + } + this.reconnect(retryCount); + }); + this.conn.addListener('timeout', () => { + if (this.opt.debug) { + util.log('Connection got "timeout" event'); + } + this.reconnect(retryCount); + }); + this.conn.addListener('error', (exception) => { + if (this.opt.debug) { + util.log('Network error: ' + exception); + } + this.emit('netError', exception); + }); + } + + private reconnect(retryCount: number) { + if (this.requestedDisconnect) + return; + if (this.opt.debug) + util.log('Disconnected: reconnecting'); + if (this.opt.retryCount !== null && retryCount >= this.opt.retryCount) { + if (this.opt.debug) { + util.log('Maximum retry count (' + this.opt.retryCount + ') reached. Aborting'); + } + this.emit('abort', this.opt.retryCount); + return; + } + + if (this.opt.debug) { + util.log('Waiting ' + this.opt.retryDelay + 'ms before retrying'); + } + setTimeout(() => { + this.connect(retryCount + 1); + }, this.opt.retryDelay); + } + + public disconnect(messageOrCallback?: string|(() => void), callback?: () => void) { + if (!this.conn) { + throw Error('Cannot send, not connected'); + } + let message: string|undefined; + if (typeof (messageOrCallback) === 'function') { + callback = messageOrCallback; + message = undefined; + } + message = message || 'node-irc says goodbye'; + if (this.readyState() === 'open') { + this._send('QUIT', message); + } + this.requestedDisconnect = true; + if (typeof (callback) === 'function') { + this.conn.once('end', callback); + } + this.conn.end(); + } + + public async send(...command: string[]) { + if (!this.conn) { + throw Error('Cannot send, not connected'); + } + let delayPromise = Promise.resolve(); + if (this.opt.floodProtection) { + // Get the amount of time we should wait between messages + const delay = this.opt.floodProtectionDelay - Math.min( + this.opt.floodProtectionDelay, + Date.now() - this.lastSendTime, + ); + if (delay > MIN_DELAY_MS) { + delayPromise = new Promise((r) => setTimeout(r, delay)); + } + } + const currentSendingPromise = this.sendingPromise; + const sendPromise = (async () => { + await delayPromise; + await currentSendingPromise; + return this._send(...command); + })(); + this.sendingPromise = sendPromise.finally(); + return sendPromise; + } + + private _send(...cmdArgs: string[]) { + if (!this.conn) { + throw Error('Cannot send, not connected'); + } + const args = Array.prototype.slice.call(cmdArgs); + + // Note that the command arg is included in the args array as the first element + if (args[args.length - 1].match(/\s/) || args[args.length - 1].match(/^:/) || args[args.length - 1] === '') { + args[args.length - 1] = ':' + args[args.length - 1]; + } + + if (this.opt.debug) + util.log('SEND: ' + args.join(' ')); + + if (this.requestedDisconnect) { + return; + } + this.lastSendTime = Date.now(); + this.conn.write(args.join(' ') + '\r\n'); + } + + public join(channel: string, callback?: (...args: unknown[]) => void) { + var channelName = channel.split(' ')[0]; + this.once('join' + channelName, (...args) => { + // if join is successful, add this channel to opts.channels + // so that it will be re-joined upon reconnect (as channels + // specified in options are) + if (this.opt.channels.indexOf(channel) == -1) { + this.opt.channels.push(channel); + } + + if (typeof callback == 'function') { + return callback(...args); + } + }); + return this.send('JOIN', ...channel.split(' ')); + } + + public part(channel: string, messageOrCallback: string|(() => void), callback?: () => void) { + let message: string|undefined; + if (typeof messageOrCallback === 'function') { + callback = messageOrCallback; + message = undefined; + } + if (typeof (callback) == 'function') { + this.once('part' + channel, callback); + } + + // remove this channel from this.opt.channels so we won't rejoin + // upon reconnect + if (this.opt.channels.indexOf(channel) != -1) { + this.opt.channels.splice(this.opt.channels.indexOf(channel), 1); + } + + if (message) { + return this.send('PART', channel, message); + } + return this.send('PART', channel); + } + + public async action(channel: string, text: string): Promise { + if (typeof text === 'undefined') { + return; + } + await Promise.all(text.toString().split(/\r?\n/).filter((line) => + line.length > 0 + ).map((line) => this.say(channel, '\u0001ACTION ' + line + '\u0001'))); + } + + // E.g. isUserPrefixMorePowerfulThan("@", "&") + public isUserPrefixMorePowerfulThan(prefix: string, testPrefix: string): boolean { + const mode = this.modeForPrefix[prefix]; + const testMode = this.modeForPrefix[testPrefix]; + if (this.supportedState.usermodepriority.length === 0 || !mode || !testMode) { + return false; + } + if (this.supportedState.usermodepriority.indexOf(mode) === -1 || this.supportedState.usermodepriority.indexOf(testMode) === -1) { + return false; + } + // usermodepriority is a sorted string (lower index = more powerful) + return this.supportedState.usermodepriority.indexOf(mode) < this.supportedState.usermodepriority.indexOf(testMode); + } + + private _splitLongLines(words: string, maxLength: number, destination: string[] = []): string[] { + if (words.length == 0) { + return destination; + } + if (words.length <= maxLength) { + destination.push(words); + return destination; + } + let c = words[maxLength]; + let cutPos = 0; + let wsLength = 1; + if (c.match(/\s/)) { + cutPos = maxLength; + } else { + let offset = 1; + while ((maxLength - offset) > 0) { + c = words[maxLength - offset]; + if (c.match(/\s/)) { + cutPos = maxLength - offset; + break; + } + offset++; + } + if (maxLength - offset <= 0) { + cutPos = maxLength; + wsLength = 0; + } + } + const part = words.substring(0, cutPos); + destination.push(part); + return this._splitLongLines(words.substring(cutPos + wsLength, words.length), maxLength, destination); + } + + public say(target: string, text: string) { + return this._speak('PRIVMSG', target, text); + } + + public notice(target: string, text: string): Promise { + return this._speak('NOTICE', target, text); + } + + private _splitMessage(target: string, text: string): string[] { + const maxLength = Math.min(this.maxLineLength - target.length, this.opt.messageSplit); + if (!text) { + return []; + } + return text.toString().split(/\r?\n/).filter((line) => line.length > 0) + .map((line) => this._splitLongLines(line, maxLength, [])) + .reduce((a, b) => a.concat(b), []); + } + + private async _speak(kind: string, target: string, text: string): Promise { + const linesToSend = this._splitMessage(target, text); + await Promise.all(linesToSend.map((toSend) => { + const p = this.send(kind, target, toSend); + p.finally(() => { + if (kind == 'PRIVMSG') { + this.emit('selfMessage', target, toSend); + } + }); + return p; + })); + } + + // Returns individual IRC messages that would be sent to target + // if sending text (via say() or notice()). + public getSplitMessages(target: string, text: string) { + return this._splitMessage(target, text); + } + + public whois(nick: string, callback?: (info: WhoisResponse) => void) { + if (typeof callback === 'function') { + const callbackWrapper = (info: WhoisResponse) => { + if (info.nick.toLowerCase() == nick.toLowerCase()) { + this.removeListener('whois', callbackWrapper); + return callback(info); + } + }; + this.addListener('whois', callbackWrapper); + } + return this.send('WHOIS', nick); + } + + // Send a NAMES command to channel. If callback is a function, add it as + // a listener for the names event, which is called when rpl_endofnames is + // received in response to original NAMES command. The callback should + // accept channelName as the first argument. An object with each key a + // user nick and each value '@' if they are a channel operator is passed + // as the second argument to the callback. + public names(channel: string, callback?: (callbackChannel: string, names: {[nick: string]: string}) => void) { + if (typeof callback === 'function') { + const callbackWrapper = (callbackChannel: string, names: {[nick: string]: string}) => { + if (callbackChannel === channel) { + return callback(callbackChannel, names); + } + } + this.addListener('names', callbackWrapper); + } + return this.send('NAMES', channel); + } + + // Send a MODE command + public mode(channel: string, callback?: (callbackChannel: string, ...args: unknown[]) => void) { + if (typeof callback === 'function') { + const callbackWrapper = (callbackChannel: string, ...args: unknown[]) => { + if (callbackChannel === channel) { + return callback(callbackChannel, ...args); + } + } + this.addListener('mode_is', callbackWrapper); + } + return this.send('MODE', channel); + } + + // Set user modes. If nick is falsey, your own user modes will be changed. + // E.g. to set "+RiG" on yourself: setUserMode("+RiG") + public setUserMode(mode: string, nick: string = this.currentNick): Promise { + return this.send('MODE', nick, mode); + } + + public list(): Promise { + var args = Array.prototype.slice.call(arguments, 0); + args.unshift('LIST'); + return this.send(...args); + } + + private _addWhoisData(nick: string, key: keyof(WhoisResponse), value: any, onlyIfExists = false) { + if (onlyIfExists && !this.whoisData.has(nick)) return; + const data: WhoisResponse = { + ...this.whoisData.get(nick), + nick, + [key]: value, + }; + this.whoisData.set(nick, data); + } + + private _clearWhoisData(nick: string) { + const data = this.whoisData.get(nick); + this.whoisData.delete(nick); + return data; + } + + private _handleCTCP(from: string, to: string, text: string, type: string, message: Message) { + text = text.slice(1); + text = text.slice(0, text.indexOf('\u0001')); + var parts = text.split(' '); + this.emit('ctcp', from, to, text, type, message); + this.emit('ctcp-' + type, from, to, text, message); + if (type === 'privmsg' && text === 'VERSION') + this.emit('ctcp-version', from, to, message); + if (parts[0] === 'ACTION' && parts.length > 1) + this.emit('action', from, to, parts.slice(1).join(' '), message); + if (parts[0] === 'PING' && type === 'privmsg' && parts.length > 1) + this.ctcp(from, 'notice', text); + } + + public ctcp(to: string, type: string, text: string) { + return this[type === 'privmsg' ? 'say' : 'notice'](to, '\x01' + text + '\x01'); + } + + public convertEncoding(buffer: Buffer): string { + const str = buffer.toString(); + if (this.opt.encoding) { + try { + const charset = detectCharset.detect(buffer); + if (!charset) { + throw Error("No charset detected"); + } + return Iconv.encode(Iconv.decode(buffer, charset), this.opt.encoding).toString(); + } catch (err) { + if (this.opt.debug) { + util.log('\u001b[01;31mERROR: ' + err + '\u001b[0m'); + util.inspect({ str }); + } + } + } else if (this.opt.encodingFallback) { + try { + if (!isValidUTF8(str)) { + return Iconv.decode(buffer, this.opt.encodingFallback).toString(); + } + } catch (err) { + if (this.opt.debug) { + util.log('\u001b[01;31mERROR: ' + err + '\u001b[0m'); + util.inspect({ str, encodingFallback: this.opt.encodingFallback }); + } + } + } + + return str; + } + + // blatantly stolen from irssi's splitlong.pl. Thanks, Bjoern Krombholz! + private _updateMaxLineLength(): void { + // 497 = 510 - (":" + "!" + " PRIVMSG " + " :").length; + // target is determined in _speak() and subtracted there + this.maxLineLength = 497 - this.nick.length - this.hostMask.length; + } + + // Checks the arg at the given index for a channel. If one exists, casemap it + // according to ISUPPORT rules. + private _casemap(msg: Message, index: number): string|undefined { + if (!msg.args || !msg.args[index] || msg.args[index][0] !== "#") { + return; + } + msg.args[index] = this.toLowerCase(msg.args[index]); + } + + public toLowerCase(str: string): string { + // http://www.irc.org/tech_docs/005.html + const knownCaseMappings = ['ascii', 'rfc1459', 'strict-rfc1459']; + if (knownCaseMappings.indexOf(this.supportedState.casemapping) === -1) { + return str; + } + let lower = str.toLowerCase(); + if (this.supportedState.casemapping === 'rfc1459') { + lower = lower. + replace(/\[/g, '{'). + replace(/\]/g, '}'). + replace(/\\/g, '|'). + replace(/\^/g, '~'); + } + else if (this.supportedState.casemapping === 'strict-rfc1459') { + lower = lower. + replace(/\[/g, '{'). + replace(/\]/g, '}'). + replace(/\\/g, '|'); + } + return lower; + } + + private readyState(): string|undefined { + // TypeScript doesn't include ready state here. + return (this.conn as unknown as undefined|{readyState: string})?.readyState + } +} + +// https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} + +function randomInt(length: number): number { + return Math.floor(Math.random() * length); +} diff --git a/lib/parse_message.js b/src/parse_message.ts similarity index 52% rename from lib/parse_message.js rename to src/parse_message.ts index aa2b316d..3da73098 100644 --- a/lib/parse_message.js +++ b/src/parse_message.ts @@ -1,25 +1,39 @@ -var ircColors = require('irc-colors'); -var replyFor = require('./codes'); +import * as ircColors from 'irc-colors'; +import {CommandType, replyCodes} from './codes'; + +export interface Message { + prefix?: string; + server?: string; + nick?: string; + user?: string; + host?: string; + args: string[]; + command?: string; + rawCommand?: string; + commandType: CommandType; +}; /** * parseMessage(line, stripColors) * * takes a raw "line" from the IRC server and turns it into an object with * useful keys - * @param {String} line Raw message from IRC server. - * @param {Boolean} stripColors If true, strip IRC colors. - * @return {Object} A parsed message object. + * @param line Raw message from IRC server. + * @param stripColors If true, strip IRC colors. + * @return A parsed message object. */ -module.exports = function parseMessage(line, stripColors) { - var message = {}; - var match; +export function parseMessage(line: string, stripColors: boolean): Message { + const message: Message = { + args: [], + commandType: 'normal', + }; if (stripColors) { line = ircColors.stripColorsAndStyle(line); } // Parse prefix - match = line.match(/^:([^ ]+) +/); + let match = line.match(/^:([^ ]+) +/); if (match) { message.prefix = match[1]; line = line.replace(/^:[^ ]+ +/, ''); @@ -36,22 +50,22 @@ module.exports = function parseMessage(line, stripColors) { // Parse command match = line.match(/^([^ ]+) */); - message.command = match[1]; - message.rawCommand = match[1]; - message.commandType = 'normal'; + message.command = match?.[1]; + message.rawCommand = match?.[1]; line = line.replace(/^[^ ]+ +/, ''); - - if (replyFor[message.rawCommand]) { - message.command = replyFor[message.rawCommand].name; - message.commandType = replyFor[message.rawCommand].type; + if (message.rawCommand && replyCodes[message.rawCommand]) { + message.command = replyCodes[message.rawCommand].name; + message.commandType = replyCodes[message.rawCommand].type; } - message.args = []; - var middle, trailing; + let middle, trailing; // Parse parameters if (line.search(/^:|\s+:/) != -1) { match = line.match(/(.*?)(?:^:|\s+:)(.*)/); + if (!match) { + throw Error('Invalid format, could not parse parameters'); + } middle = match[1].trimRight(); trailing = match[2]; } diff --git a/src/typings/irc-colors.ts b/src/typings/irc-colors.ts new file mode 100644 index 00000000..43995879 --- /dev/null +++ b/src/typings/irc-colors.ts @@ -0,0 +1,3 @@ +declare module 'irc-colors' { + function stripColorsAndStyle(data: string): string; +} \ No newline at end of file diff --git a/src/typings/utf-8-validate.d.ts b/src/typings/utf-8-validate.d.ts new file mode 100644 index 00000000..1d95231c --- /dev/null +++ b/src/typings/utf-8-validate.d.ts @@ -0,0 +1,2 @@ +declare function isUTF8Valid(data: string): boolean; +export = isUTF8Valid; \ No newline at end of file diff --git a/test/test-convert-encoding.js b/test/test-convert-encoding.js index a4ee30fc..ad28312b 100644 --- a/test/test-convert-encoding.js +++ b/test/test-convert-encoding.js @@ -1,7 +1,7 @@ const irc = require('../lib/irc'); const test = require('tape'); const testHelpers = require('./helpers'); -const { Iconv } = require('iconv-lite'); +const Iconv = require('iconv-lite'); const chardet = require('chardet'); const checks = testHelpers.getFixtures('convert-encoding'); @@ -10,9 +10,7 @@ test('irc.Client.convertEncoding old', (assert) => { const convertEncoding = ((str) => { if (self.opt.encoding) { const charset = chardet.detect(str); - const to = new Iconv(charset, this.opt.encoding); - - return to.convert(str); + return Iconv.encode(Iconv.decode(Buffer.from(str), charset), this.opt.encoding); } else { return str; } diff --git a/test/test-irc.js b/test/test-irc.js index c9b27e9e..138246a3 100644 --- a/test/test-irc.js +++ b/test/test-irc.js @@ -21,22 +21,23 @@ test('connect, register and quit, securely, with secure object', function(t) { }); function runTests(t, isSecure, useSecureObject) { - var port = isSecure ? 6697 : 6667; - var mock = testHelpers.MockIrcd(port, 'utf-8', isSecure); - var client; - if (isSecure && useSecureObject) { - client = new irc.Client('notlocalhost', 'testbot', { - secure: { + const port = isSecure ? 6697 : 6667; + const mock = testHelpers.MockIrcd(port, 'utf-8', isSecure); + let client; + if (isSecure) { + client = new irc.Client( useSecureObject ? 'notlocalhost' : 'localhost', 'testbot', { + secure: useSecureObject ? { host: 'localhost', port: port, rejectUnauthorized: false - }, + } : true, + port, selfSigned: true, retryCount: 0, debug: true }); } else { - var client = new irc.Client('localhost', 'testbot', { + client = new irc.Client('localhost', 'testbot', { secure: isSecure, selfSigned: true, port: port, @@ -57,7 +58,7 @@ function runTests(t, isSecure, useSecureObject) { }); mock.on('end', function() { - var msgs = mock.getIncomingMsgs(); + const msgs = mock.getIncomingMsgs(); for (var i = 0; i < msgs.length; i++) { t.equal(msgs[i], expected.sent[i][0], expected.sent[i][1]); @@ -67,9 +68,9 @@ function runTests(t, isSecure, useSecureObject) { } test ('splitting of long lines', function(t) { - var port = 6667; - var mock = testHelpers.MockIrcd(port, 'utf-8', false); - var client = new irc.Client('localhost', 'testbot', { + const port = 6667; + const mock = testHelpers.MockIrcd(port, 'utf-8', false); + const client = new irc.Client('localhost', 'testbot', { secure: false, selfSigned: true, port: port, @@ -77,7 +78,7 @@ test ('splitting of long lines', function(t) { debug: true }); - var group = testHelpers.getFixtures('_splitLongLines'); + const group = testHelpers.getFixtures('_splitLongLines'); t.plan(group.length); group.forEach(function(item) { t.deepEqual(client._splitLongLines(item.input, item.maxLength, []), item.result); diff --git a/test/test-parse-line.js b/test/test-parse-line.js index c0da5152..12d0db36 100644 --- a/test/test-parse-line.js +++ b/test/test-parse-line.js @@ -1,20 +1,20 @@ -var parseMessage = require('../lib/parse_message'); -var test = require('tape'); +const { parseMessage } = require('../lib/parse_message'); +const test = require('tape'); -var testHelpers = require('./helpers'); +const testHelpers = require('./helpers'); test('irc.parseMessage', function(t) { - var checks = testHelpers.getFixtures('parse-line'); + const checks = testHelpers.getFixtures('parse-line'); Object.keys(checks).forEach(function(line) { - var stripColors = false; + let stripColors = false; if (checks[line].hasOwnProperty('stripColors')) { stripColors = checks[line].stripColors; delete checks[line].stripColors; } - t.equal( - JSON.stringify(checks[line]), - JSON.stringify(parseMessage(line, stripColors)), + t.deepEqual( + checks[line], + parseMessage(line, stripColors), line + ' parses correctly' ); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..7e147f17 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2019", + "module": "commonjs", + "allowJs": true, + "checkJs": false, + "declaration": true, + "sourceMap": true, + "outDir": "./lib", + "composite": false, + "strict": true, + "esModuleInterop": true, + "strictNullChecks": true, + "baseUrl": "./src", + "paths": { + "*": ["./typings/*"] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "test/**/*", + "example/**/*", + "app.js" + ] + } + \ No newline at end of file