diff options
Diffstat (limited to '')
-rw-r--r-- | comm/chat/protocols/irc/ircAccount.sys.mjs | 2296 |
1 files changed, 2296 insertions, 0 deletions
diff --git a/comm/chat/protocols/irc/ircAccount.sys.mjs b/comm/chat/protocols/irc/ircAccount.sys.mjs new file mode 100644 index 0000000000..6a127e16cb --- /dev/null +++ b/comm/chat/protocols/irc/ircAccount.sys.mjs @@ -0,0 +1,2296 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + ClassInfo, + executeSoon, + l10nHelper, + nsSimpleEnumerator, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { + ctcpFormatToHTML, + kListRefreshInterval, +} from "resource:///modules/ircUtils.sys.mjs"; +import { + GenericAccountPrototype, + GenericAccountBuddyPrototype, + GenericConvIMPrototype, + GenericConvChatPrototype, + GenericConvChatBuddyPrototype, + GenericConversationPrototype, + TooltipInfo, +} from "resource:///modules/jsProtoHelper.sys.mjs"; +import { NormalizedMap } from "resource:///modules/NormalizedMap.sys.mjs"; +import { Socket } from "resource:///modules/socket.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", + ircHandlers: "resource:///modules/ircHandlers.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "_conv", () => + l10nHelper("chrome://chat/locale/conversations.properties") +); +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); + +/* + * Parses a raw IRC message into an object (see section 2.3 of RFC 2812). This + * returns an object with the following fields: + * rawMessage The initial message string received without any processing. + * command A string that is the command or response code. + * params An array of strings for the parameters. The last parameter is + * stripped of its : prefix. + * origin The user's nickname or the server who sent the message. Can be + * a host (e.g. irc.mozilla.org) or an IPv4 address (e.g. 1.2.3.4) + * or an IPv6 address (e.g. 3ffe:1900:4545:3:200:f8ff:fe21:67cf). + * user The user's username, note that this can be undefined. + * host The user's hostname, note that this can be undefined. + * source A "nicely" formatted combination of user & host, which is + * <user>@<host> or <user> if host is undefined. + * tags A Map with tags stored as key-value-pair. The value is a decoded + * string or undefined if the tag has no value. + * + * There are cases (e.g. localhost) where it cannot be easily determined if a + * message is from a server or from a user, thus the usage of a generic "origin" + * instead of "nickname" or "servername". + * + * Inputs: + * aData The raw string to parse, it should already have the \r\n + * stripped from the end. + * aOrigin The default origin to use for unprefixed messages. + */ +export function ircMessage(aData, aOrigin) { + let message = { rawMessage: aData }; + let temp; + + // Splits the raw string into five parts. The third part, the command, is + // required. A raw string looks like: + // ["@" <tags> " "] [":" <prefix> " "] <command> [" " <parameter>]* [":" <last parameter>] + // <tags>: /[^ ]+/ + // <prefix>: :(<server name> | <nickname> [["!" <user>] "@" <host>]) + // <command>: /[^ ]+/ + // <parameter>: /[^ ]+/ + // <last parameter>: /.+/ + // See http://joshualuckers.nl/2010/01/10/regular-expression-to-match-raw-irc-messages/ + // Note that this expression is slightly more aggressive in matching than RFC + // 2812 would allow. It allows for empty parameters (besides the last + // parameter, which can always be empty), by allowing multiple spaces. + // (This is for compatibility with Unreal's 432 response, which returns an + // empty first parameter.) It also allows a trailing space after the + // <parameter>s when no <last parameter> is present (also occurs with Unreal). + if ( + !(temp = aData.match( + /^(?:@([^ ]+) )?(?::([^ ]+) )?([^ ]+)((?: +[^: ][^ ]*)*)? *(?::([\s\S]*))?$/ + )) + ) { + throw new Error("Couldn't parse message: \"" + aData + '"'); + } + + message.command = temp[3]; + // Space separated parameters. Since we expect a space as the first thing + // here, we want to ignore the first value (which is empty). + message.params = temp[4] ? temp[4].split(" ").slice(1) : []; + // Last parameter can contain spaces or be an empty string. + if (temp[5] !== undefined) { + message.params.push(temp[5]); + } + + // Handle the prefix part of the message per RFC 2812 Section 2.3. + + // If no prefix is given, assume the current server is the origin. + if (!temp[2]) { + temp[2] = aOrigin; + } + + // Split the prefix into separate nickname, username and hostname fields as: + // :(servername|(nickname[[!user]@host])) + [message.origin, message.user, message.host] = temp[2].split(/[!@]/); + + // Store the tags in a Map, see IRCv3.2 Message Tags. + message.tags = new Map(); + + if (temp[1]) { + let tags = temp[1].split(";"); + tags.forEach(tag => { + let [key, value] = tag.split("="); + + if (value) { + // Unescape tag values according to this mapping: + // \\ = \ + // \n = LF + // \r = CR + // \s = SPACE + // \: = ; + // everything else stays identical. + value = value.replace(/\\(.)/g, (str, type) => { + if (type == "\\") { + return "\\"; + } else if (type == "n") { + return "\n"; + } else if (type == "r") { + return "\r"; + } else if (type == "s") { + return " "; + } else if (type == ":") { + return ";"; + } + // Ignore the backslash, not specified by the spec, but as it says + // backslashes must be escaped this case should not occur in a valid + // tag value. + return type; + }); + } + // The tag key can typically have the form of example.com/aaa for vendor + // defined tags. The spec wants any unicode characters in URLs to be + // in punycode (xn--). These are not unescaped to their unicode value. + message.tags.set(key, value); + }); + } + + // It is occasionally useful to have a "source" which is a combination of + // user@host. + if (message.user) { + message.source = message.user + "@" + message.host; + } else if (message.host) { + message.source = message.host; + } else { + message.source = ""; + } + + return message; +} + +// This handles a mode change string for both channels and participants. A mode +// change string is of the form: +// aAddNewMode is true if modes are being added, false otherwise. +// aNewModes is an array of mode characters. +function _setMode(aAddNewMode, aNewModes) { + // Check each mode being added/removed. + for (let newMode of aNewModes) { + let hasMode = this._modes.has(newMode); + // If the mode is in the list of modes and we want to remove it. + if (hasMode && !aAddNewMode) { + this._modes.delete(newMode); + } else if (!hasMode && aAddNewMode) { + // If the mode is not in the list of modes and we want to add it. + this._modes.add(newMode); + } + } +} + +function TagMessage(aMessage, aTagName) { + this.message = aMessage; + this.tagName = aTagName; + this.tagValue = aMessage.tags.get(aTagName); +} + +// Properties / methods shared by both ircChannel and ircConversation. +export var GenericIRCConversation = { + _observedNicks: [], + // This is set to true after a message is sent to notify the 401 + // ERR_NOSUCHNICK handler to write an error message to the conversation. + _pendingMessage: false, + _waitingForNick: false, + + normalizeNick(aNick) { + return this._account.normalizeNick(aNick); + }, + + // This will calculate the maximum number of bytes that are left for a message + // typed by the user by calculate the amount of bytes that would be used by + // the IRC messaging. + getMaxMessageLength() { + // Build the shortest possible message that could be sent to other users. + let baseMessage = + ":" + + this._account._nickname + + this._account.prefix + + " " + + this._account.buildMessage("PRIVMSG", this.name) + + " :\r\n"; + return ( + this._account.maxMessageLength - this._account.countBytes(baseMessage) + ); + }, + /** + * @param {string} aWho - Message author's username. + * @param {string} aMessage - Message text. + * @param {object} aObject - Other properties to set on the imMessage. + */ + handleTags(aWho, aMessage, aObject) { + let messageProps = aObject; + if ("tags" in aObject && lazy.ircHandlers.hasTagHandlers) { + // Merge extra info for the handler into the props. + messageProps = Object.assign( + { + who: aWho, + message: aMessage, + get originalMessage() { + return aMessage; + }, + }, + messageProps + ); + for (let tag of aObject.tags.keys()) { + // Unhandled tags may be common, since a tag does not have to be handled + // with a tag handler, it may also be handled by a message command handler. + lazy.ircHandlers.handleTag( + this._account, + new TagMessage(messageProps, tag) + ); + } + + // Remove helper prop for tag handlers. We don't want to remove the other + // ones, since they might have been changed and will override aWho and + // aMessage in the imMessage constructor. + delete messageProps.originalMessage; + } + // Remove the IRC tags, as those were passed in just for this step. + delete messageProps.tags; + return messageProps; + }, + // Apply CTCP formatting before displaying. + prepareForDisplaying(aMsg) { + aMsg.displayMessage = ctcpFormatToHTML(aMsg.displayMessage); + GenericConversationPrototype.prepareForDisplaying.apply(this, arguments); + }, + prepareForSending(aOutgoingMessage) { + // Split the message by line breaks and send each one individually. + let messages = aOutgoingMessage.message.split(/[\r\n]+/); + + let maxLength = this.getMaxMessageLength(); + + // Attempt to smartly split a string into multiple lines (based on the + // maximum number of characters the message can contain). + for (let i = 0; i < messages.length; ++i) { + let message = messages[i]; + let length = this._account.countBytes(message); + // The message is short enough. + if (length <= maxLength) { + continue; + } + + // Find the location of a space before the maximum length. + let index = message.lastIndexOf(" ", maxLength); + + // Remove the current message and insert the two new ones. If no space was + // found, cut the first message to the maximum length and start the second + // message one character after that. If a space was found, exclude it. + messages.splice( + i, + 1, + message.substr(0, index == -1 ? maxLength : index), + message.substr(index + 1 || maxLength) + ); + } + + return messages; + }, + dispatchMessage(message, action = false, isNotice = false) { + if (!message.length) { + return; + } + + if (action) { + if (!this._account.sendCTCPMessage(this.name, false, "ACTION", message)) { + this.writeMessage( + this._account._currentServerName, + lazy._("error.sendMessageFailed"), + { + error: true, + system: true, + } + ); + return; + } + } else if ( + !this._account.sendMessage(isNotice ? "NOTICE" : "PRIVMSG", [ + this.name, + message, + ]) + ) { + this.writeMessage( + this._account._currentServerName, + lazy._("error.sendMessageFailed"), + { + error: true, + system: true, + } + ); + return; + } + + // By default the server doesn't send the message back, but this can be + // enabled with the echo-message capability. If this is not enabled, just + // assume the message was received and immediately show it. + if (!this._account._activeCAPs.has("echo-message")) { + this.writeMessage( + this._account.imAccount.alias || + this._account.imAccount.statusInfo.displayName || + this._account._nickname, + message, + { + outgoing: true, + notification: isNotice, + action, + } + ); + } + + this._pendingMessage = true; + }, + // IRC doesn't support typing notifications, but it does have a maximum + // message length. + sendTyping(aString) { + let longestLineLength = Math.max.apply( + null, + aString.split("\n").map(this._account.countBytes, this._account) + ); + return this.getMaxMessageLength() - longestLineLength; + }, + + requestCurrentWhois(aNick) { + if (!this._observedNicks.length) { + Services.obs.addObserver(this, "user-info-received"); + } + this._observedNicks.push(this.normalizeNick(aNick)); + this._account.requestCurrentWhois(aNick); + }, + + observe(aSubject, aTopic, aData) { + if (aTopic != "user-info-received") { + return; + } + + let nick = this.normalizeNick(aData); + let nickIndex = this._observedNicks.indexOf(nick); + if (nickIndex == -1) { + return; + } + + // Remove the nick from the list of nicks that are being waited to received. + this._observedNicks.splice(nickIndex, 1); + + // If this is the last nick, remove the observer. + if (!this._observedNicks.length) { + Services.obs.removeObserver(this, "user-info-received"); + } + + // If we are waiting for the conversation name, set it. + let account = this._account; + if (this._waitingForNick && nick == this.normalizedName) { + if (account.whoisInformation.has(nick)) { + this.updateNick(account.whoisInformation.get(nick).nick); + } + delete this._waitingForNick; + return; + } + + // Otherwise, print the requested whois information. + let type = { system: true, noLog: true }; + // RFC 2812 errors 401 and 406 result in there being no entry for the nick. + if (!account.whoisInformation.has(nick)) { + this.writeMessage(null, lazy._("message.unknownNick", nick), type); + return; + } + // If the nick is offline, tell the user. In that case, it's WHOWAS info. + let msgType = "message.whois"; + if ("offline" in account.whoisInformation.get(nick)) { + msgType = "message.whowas"; + } + let msg = lazy._(msgType, account.whoisInformation.get(nick).nick); + + // Iterate over each field. + for (let elt of aSubject.QueryInterface(Ci.nsISimpleEnumerator)) { + switch (elt.type) { + case Ci.prplITooltipInfo.pair: + case Ci.prplITooltipInfo.sectionHeader: + msg += "\n" + lazy._("message.whoisEntry", elt.label, elt.value); + break; + case Ci.prplITooltipInfo.sectionBreak: + break; + case Ci.prplITooltipInfo.status: + if (elt.label != Ci.imIStatusInfo.STATUS_AWAY) { + break; + } + // The away message has no tooltipInfo.pair entry. + msg += + "\n" + + lazy._("message.whoisEntry", lazy._("tooltip.away"), elt.value); + break; + } + } + this.writeMessage(null, msg, type); + }, + + unInitIRCConversation() { + this._account.removeConversation(this.name); + if (this._observedNicks.length) { + Services.obs.removeObserver(this, "user-info-received"); + } + }, +}; + +export function ircChannel(aAccount, aName, aNick) { + this._init(aAccount, aName, aNick); + this._participants = new NormalizedMap(this.normalizeNick.bind(this)); + this._modes = new Set(); + this._observedNicks = []; + this.banMasks = []; +} + +ircChannel.prototype = { + __proto__: GenericConvChatPrototype, + _modes: null, + _receivedInitialMode: false, + // For IRC you're not in a channel until the JOIN command is received, open + // all channels (initially) as left. + _left: true, + // True while we are rejoining a channel previously parted by the user. + _rejoined: false, + banMasks: [], + + // Section 3.2.2 of RFC 2812. + part(aMessage) { + let params = [this.name]; + + // If a valid message was given, use it as the part message. + // Otherwise, fall back to the default part message, if it exists. + let msg = aMessage || this._account.getString("partmsg"); + if (msg) { + params.push(msg); + } + + this._account.sendMessage("PART", params); + + // Remove reconnection information. + delete this.chatRoomFields; + }, + + close() { + // Part the room if we're connected. + if (this._account.connected && !this.left) { + this.part(); + } + GenericConvChatPrototype.close.call(this); + }, + + unInit() { + this.unInitIRCConversation(); + GenericConvChatPrototype.unInit.call(this); + }, + + // Use the normalized nick in order to properly notify the observers. + getNormalizedChatBuddyName(aNick) { + return this.normalizeNick(aNick); + }, + + getParticipant(aNick, aNotifyObservers) { + if (this._participants.has(aNick)) { + return this._participants.get(aNick); + } + + let participant = new ircParticipant(aNick, this); + this._participants.set(aNick, participant); + + // Add the participant to the whois table if it is not already there. + this._account.setWhois(participant._name); + + if (aNotifyObservers) { + this.notifyObservers( + new nsSimpleEnumerator([participant]), + "chat-buddy-add" + ); + } + return participant; + }, + + /* + * Add/remove modes from this channel. + * + * aNewMode is the new mode string, it MUST begin with + or -. + * aModeParams is a list of ordered string parameters for the mode string. + * aSetter is the nick of the person (or service) that set the mode. + */ + setMode(aNewMode, aModeParams, aSetter) { + // Save this for a comparison after the new modes have been set. + let previousTopicSettable = this.topicSettable; + + const hostMaskExp = /^.+!.+@.+$/; + function getNextParam() { + // If there's no next parameter, throw a warning. + if (!aModeParams.length) { + this.WARN("Mode parameter expected!"); + return undefined; + } + return aModeParams.pop(); + } + function peekNextParam() { + // Non-destructively gets the next param. + if (!aModeParams.length) { + return undefined; + } + return aModeParams.slice(-1)[0]; + } + + // Are modes being added or removed? + if (aNewMode[0] != "+" && aNewMode[0] != "-") { + this.WARN("Invalid mode string: " + aNewMode); + return; + } + let addNewMode = aNewMode[0] == "+"; + + // Check each mode being added and update the user. + let channelModes = []; + let userModes = new NormalizedMap(this.normalizeNick.bind(this)); + let msg; + + for (let i = aNewMode.length - 1; i > 0; --i) { + // Since some modes are conflicted between different server + // implementations, check if a participant with that name exists. If this + // is true, then update the mode of the ConvChatBuddy. + if ( + this._account.memberStatuses.includes(aNewMode[i]) && + aModeParams.length && + this._participants.has(peekNextParam()) + ) { + // Store the new modes for this nick (so each participant's mode is only + // updated once). + let nick = getNextParam(); + if (!userModes.has(nick)) { + userModes.set(nick, []); + } + userModes.get(nick).push(aNewMode[i]); + + // Don't use this mode as a channel mode. + continue; + } else if (aNewMode[i] == "k") { + // Channel key. + let newFields = this.name; + if (addNewMode) { + let key = getNextParam(); + // A new channel key was set, display a message if this key is not + // already known. + if ( + this.chatRoomFields && + this.chatRoomFields.getValue("password") == key + ) { + continue; + } + msg = lazy._("message.channelKeyAdded", aSetter, key); + newFields += " " + key; + } else { + msg = lazy._("message.channelKeyRemoved", aSetter); + } + + this.writeMessage(aSetter, msg, { system: true }); + // Store the new fields for reconnect. + this.chatRoomFields = + this._account.getChatRoomDefaultFieldValues(newFields); + } else if (aNewMode[i] == "b") { + // A banmask was added or removed. + let banMask = getNextParam(); + let msgKey = "message.banMask"; + if (addNewMode) { + this.banMasks.push(banMask); + msgKey += "Added"; + } else { + this.banMasks = this.banMasks.filter(aBanMask => banMask != aBanMask); + msgKey += "Removed"; + } + this.writeMessage(aSetter, lazy._(msgKey, banMask, aSetter), { + system: true, + }); + } else if (["e", "I", "l"].includes(aNewMode[i])) { + // TODO The following have parameters that must be accounted for. + getNextParam(); + } else if ( + aNewMode[i] == "R" && + aModeParams.length && + peekNextParam().match(hostMaskExp) + ) { + // REOP_LIST takes a mask as a parameter, since R is a conflicted mode, + // try to match the parameter. Implemented by IRCNet. + // TODO The parameter must be acounted for. + getNextParam(); + } + // TODO From RFC 2811: a, i, m, n, q, p, s, r, t, l, e, I. + + // Keep track of the channel modes in the order they were received. + channelModes.unshift(aNewMode[i]); + } + + if (aModeParams.length) { + this.WARN("Unused mode parameters: " + aModeParams.join(", ")); + } + + // Update the mode of each participant. + for (let [nick, mode] of userModes.entries()) { + this.getParticipant(nick).setMode(addNewMode, mode, aSetter); + } + + // If the topic can now be set (and it couldn't previously) or vice versa, + // notify the UI. Note that this status can change by either a channel mode + // or a user mode changing. + if (this.topicSettable != previousTopicSettable) { + this.notifyObservers(this, "chat-update-topic"); + } + + // If no channel modes were being set, don't display a message for it. + if (!channelModes.length) { + return; + } + + // Store the channel modes. + _setMode.call(this, addNewMode, channelModes); + + // Notify the UI of changes. + msg = lazy._( + "message.channelmode", + aNewMode[0] + channelModes.join(""), + aSetter + ); + this.writeMessage(aSetter, msg, { system: true }); + + this._receivedInitialMode = true; + }, + + setModesFromRestriction(aRestriction) { + // First remove all types from the list of modes. + for (let key in this._account.channelRestrictionToModeMap) { + let mode = this._account.channelRestrictionToModeMap[key]; + this._modes.delete(mode); + } + + // Add the new mode onto the list. + if (aRestriction in this._account.channelRestrictionToModeMap) { + let mode = this._account.channelRestrictionToModeMap[aRestriction]; + if (mode) { + this._modes.add(mode); + } + } + }, + + get topic() { + return this._topic; + }, // can't add a setter without redefining the getter + set topic(aTopic) { + // Note that the UI isn't updated here because the server will echo back the + // TOPIC to us and we'll set it on receive. + this._account.sendMessage("TOPIC", [this.name, aTopic]); + }, + get topicSettable() { + // Don't use getParticipant since we don't want to lazily create it! + let participant = this._participants.get(this.nick); + + // We must be in the room to set the topic. + if (!participant) { + return false; + } + + // If the channel mode is +t, hops and ops can set the topic; otherwise + // everyone can. + return !this._modes.has("t") || participant.admin || participant.moderator; + }, + writeMessage(aWho, aMsg, aObject) { + const messageProps = this.handleTags(aWho, aMsg, aObject); + GenericConvChatPrototype.writeMessage.call(this, aWho, aMsg, messageProps); + }, +}; +Object.assign(ircChannel.prototype, GenericIRCConversation); + +function ircParticipant(aName, aConv) { + this._name = aName; + this._conv = aConv; + this._account = aConv._account; + this._modes = new Set(); + + // Handle multi-prefix modes. + let i; + for ( + i = 0; + i < this._name.length && this._name[i] in this._account.userPrefixToModeMap; + ++i + ) { + let mode = this._account.userPrefixToModeMap[this._name[i]]; + if (mode) { + this._modes.add(mode); + } + } + this._name = this._name.slice(i); +} +ircParticipant.prototype = { + __proto__: GenericConvChatBuddyPrototype, + + setMode(aAddNewMode, aNewModes, aSetter) { + _setMode.call(this, aAddNewMode, aNewModes); + + // Notify the UI of changes. + let msg = lazy._( + "message.usermode", + (aAddNewMode ? "+" : "-") + aNewModes.join(""), + this.name, + aSetter + ); + this._conv.writeMessage(aSetter, msg, { system: true }); + this._conv.notifyObservers(this, "chat-buddy-update"); + }, + + get voiced() { + return this._modes.has("v"); + }, + get moderator() { + return this._modes.has("h"); + }, + get admin() { + return this._modes.has("o"); + }, + get founder() { + return this._modes.has("O") || this._modes.has("q"); + }, + get typing() { + return false; + }, +}; + +export function ircConversation(aAccount, aName) { + let nick = aAccount.normalize(aName); + if (aAccount.whoisInformation.has(nick)) { + aName = aAccount.whoisInformation.get(nick).nick; + } + + this._init(aAccount, aName); + this._observedNicks = []; + + // Fetch correctly capitalized name. + // Always request the info as it may be out of date. + this._waitingForNick = true; + this.requestCurrentWhois(aName); +} + +ircConversation.prototype = { + __proto__: GenericConvIMPrototype, + get buddy() { + return this._account.buddies.get(this.name); + }, + + unInit() { + this.unInitIRCConversation(); + GenericConvIMPrototype.unInit.call(this); + }, + + updateNick(aNewNick) { + this._name = aNewNick; + this.notifyObservers(null, "update-conv-title"); + }, + writeMessage(aWho, aMsg, aObject) { + const messageProps = this.handleTags(aWho, aMsg, aObject); + GenericConvIMPrototype.writeMessage.call(this, aWho, aMsg, messageProps); + }, +}; +Object.assign(ircConversation.prototype, GenericIRCConversation); + +function ircSocket(aAccount) { + this._account = aAccount; + this._initCharsetConverter(); +} +ircSocket.prototype = { + __proto__: Socket, + // Although RFCs 1459 and 2812 explicitly say that \r\n is the message + // separator, some networks (euIRC) only send \n. + delimiter: /\r?\n/, + connectTimeout: 60, // Failure to connect after 1 minute + readWriteTimeout: 300, // Failure when no data for 5 minutes + _converter: null, + + sendPing() { + // Send a ping using the current timestamp as a payload prefixed with + // an underscore to signify this was an "automatic" PING (used to avoid + // socket timeouts). + this._account.sendMessage("PING", "_" + Date.now()); + }, + + _initCharsetConverter() { + try { + this._converter = new TextDecoder(this._account._encoding); + } catch (e) { + delete this._converter; + this.ERROR( + "Failed to set character set to: " + + this._account._encoding + + " for " + + this._account.name + + "." + ); + } + }, + + // Implement Section 5 of RFC 2812. + onDataReceived(aRawMessage) { + let conversionWarning = ""; + if (this._converter) { + try { + let buffer = Uint8Array.from(aRawMessage, c => c.charCodeAt(0)); + aRawMessage = this._converter.decode(buffer); + } catch (e) { + conversionWarning = + "\nThis message doesn't seem to be " + + this._account._encoding + + " encoded."; + // Unfortunately, if the unicode converter failed once, + // it will keep failing so we need to reinitialize it. + this._initCharsetConverter(); + } + } + + // We've received data and are past the authentication stage. + if (this._account.connected) { + this.resetPingTimer(); + } + + // Low level dequote: replace quote character \020 followed by 0, n, r or + // \020 with a \0, \n, \r or \020, respectively. Any other character is + // replaced with itself. + const lowDequote = { 0: "\0", n: "\n", r: "\r", "\x10": "\x10" }; + let dequotedMessage = aRawMessage.replace( + // eslint-disable-next-line no-control-regex + /\x10./g, + aStr => lowDequote[aStr[1]] || aStr[1] + ); + + try { + let message = new ircMessage( + dequotedMessage, + this._account._currentServerName + ); + this.DEBUG(JSON.stringify(message) + conversionWarning); + if (!lazy.ircHandlers.handleMessage(this._account, message)) { + // If the message was not handled, throw a warning containing + // the original quoted message. + this.WARN("Unhandled IRC message:\n" + aRawMessage); + } + } catch (e) { + // Catch the error, display it and hope the connection can continue with + // this message in error. Errors are also caught inside of handleMessage, + // but we expect to handle message parsing errors here. + this.DEBUG(aRawMessage + conversionWarning); + this.ERROR(e); + } + }, + onConnection() { + this._account._connectionRegistration(); + }, + disconnect() { + if (!this._account) { + return; + } + Socket.disconnect.call(this); + delete this._account; + }, + + // Throw errors if the socket has issues. + onConnectionClosed() { + // If the account was already disconnected, e.g. in response to + // onConnectionReset, do nothing. + if (!this._account) { + return; + } + const msg = "Connection closed by server."; + if (this._account.disconnecting) { + // The server closed the connection before we handled the ERROR + // response to QUIT. + this.LOG(msg); + this._account.gotDisconnected(); + } else { + this.WARN(msg); + this._account.gotDisconnected( + Ci.prplIAccount.ERROR_NETWORK_ERROR, + lazy._("connection.error.lost") + ); + } + }, + onConnectionReset() { + this.WARN("Connection reset."); + this._account.gotDisconnected( + Ci.prplIAccount.ERROR_NETWORK_ERROR, + lazy._("connection.error.lost") + ); + }, + onConnectionTimedOut() { + this.WARN("Connection timed out."); + this._account.gotDisconnected( + Ci.prplIAccount.ERROR_NETWORK_ERROR, + lazy._("connection.error.timeOut") + ); + }, + onConnectionSecurityError(aTLSError, aNSSErrorMessage) { + this.WARN( + "Bad certificate or SSL connection for " + + this._account.name + + ":\n" + + aNSSErrorMessage + ); + let error = this._account.handleConnectionSecurityError(this); + this._account.gotDisconnected(error, aNSSErrorMessage); + }, + + get DEBUG() { + return this._account.DEBUG; + }, + get LOG() { + return this._account.LOG; + }, + get WARN() { + return this._account.WARN; + }, + get ERROR() { + return this._account.ERROR; + }, +}; + +function ircAccountBuddy(aAccount, aBuddy, aTag, aUserName) { + this._init(aAccount, aBuddy, aTag, aUserName); +} +ircAccountBuddy.prototype = { + __proto__: GenericAccountBuddyPrototype, + + // Returns an array of prplITooltipInfo objects to be displayed when the + // user hovers over the buddy. + getTooltipInfo() { + return this._account.getBuddyInfo(this.normalizedName); + }, + + // Allow sending of messages to buddies even if they are not online since IRC + // does not always provide status information in a timely fashion. (Note that + // this is OK since the server will throw an error if the user is not online.) + get canSendMessage() { + return this.account.connected; + }, + + // Called when the user wants to chat with the buddy. + createConversation() { + return this._account.createConversation(this.userName); + }, + + remove() { + this._account.removeBuddy(this); + GenericAccountBuddyPrototype.remove.call(this); + }, +}; + +function ircRoomInfo(aName, aAccount) { + this.name = aName; + this._account = aAccount; +} +ircRoomInfo.prototype = { + __proto__: ClassInfo("prplIRoomInfo", "IRC RoomInfo Object"), + get topic() { + return this._account._channelList.get(this.name).topic; + }, + get participantCount() { + return this._account._channelList.get(this.name).participantCount; + }, + get chatRoomFieldValues() { + return this._account.getChatRoomDefaultFieldValues(this.name); + }, +}; + +export function ircAccount(aProtocol, aImAccount) { + this._init(aProtocol, aImAccount); + this.buddies = new NormalizedMap(this.normalizeNick.bind(this)); + this.conversations = new NormalizedMap(this.normalize.bind(this)); + + // Split the account name into usable parts. + const [accountNickname, server] = this.protocol.splitUsername(this.name); + this._accountNickname = accountNickname; + this._server = server; + // To avoid _currentServerName being null, initialize it to the server being + // connected to. This will also get overridden during the 001 response from + // the server. + this._currentServerName = this._server; + + this._nickname = this._accountNickname; + this._requestedNickname = this._nickname; + + // For more information, see where these are defined in the prototype below. + this.trackQueue = []; + this.pendingIsOnQueue = []; + this.whoisInformation = new NormalizedMap(this.normalizeNick.bind(this)); + this._requestedCAPs = new Set(); + this._availableCAPs = new Set(); + this._activeCAPs = new Set(); + this._queuedCAPs = []; + this._commandBuffers = new Map(); + this._roomInfoCallbacks = new Set(); +} + +ircAccount.prototype = { + __proto__: GenericAccountPrototype, + _socket: null, + _MODE_WALLOPS: 1 << 2, // mode 'w' + _MODE_INVISIBLE: 1 << 3, // mode 'i' + get _mode() { + return 0; + }, + + // The name of the server we last connected to. + _currentServerName: null, + // Whether to attempt authenticating with NickServ. + shouldAuthenticate: true, + // Whether the user has successfully authenticated with NickServ. + isAuthenticated: false, + // The current in use nickname. + _nickname: null, + // The nickname stored in the account name. + _accountNickname: null, + // The nickname that was last requested by the user. + _requestedNickname: null, + // The nickname that was last requested. This can differ from + // _requestedNickname when a new nick is automatically generated (e.g. by + // adding digits). + _sentNickname: null, + // If we don't get the desired nick on connect, we try again a bit later, + // to see if it wasn't just our nick not having timed out yet. + _nickInUseTimeout: null, + get username() { + let username; + // Use a custom username in a hidden preference. + if (this.prefs.prefHasUserValue("username")) { + username = this.getString("username"); + } + // But fallback to brandShortName if no username is provided (or is empty). + if (!username) { + username = Services.appinfo.name; + } + + return username; + }, + // The prefix minus the nick (!user@host) as returned by the server, this is + // necessary for guessing message lengths. + prefix: null, + + // Parts of the specification give max lengths, keep track of them since a + // server can overwrite them. The defaults given here are from RFC 2812. + maxNicknameLength: 9, // 1.2.1 Users + maxChannelLength: 50, // 1.3 Channels + maxMessageLength: 512, // 2.3 Messages + maxHostnameLength: 63, // 2.3.1 Message format in Augmented BNF + + // The default prefixes to modes. + userPrefixToModeMap: { "@": "o", "!": "n", "%": "h", "+": "v" }, + get userPrefixes() { + return Object.keys(this.userPrefixToModeMap); + }, + // Modes that have a nickname parameter and affect a participant. See 4.1 + // Member Status of RFC 2811. + memberStatuses: ["a", "h", "o", "O", "q", "v", "!"], + channelPrefixes: ["&", "#", "+", "!"], // 1.3 Channels + channelRestrictionToModeMap: { "@": "s", "*": "p", "=": null }, // 353 RPL_NAMREPLY + + // Handle Scandanavian lower case (optionally remove status indicators). + // See Section 2.2 of RFC 2812: the characters {}|^ are considered to be the + // lower case equivalents of the characters []\~, respectively. + normalizeExpression: /[\x41-\x5E]/g, + normalize(aStr, aPrefixes) { + let str = aStr; + + if (aPrefixes) { + while (aPrefixes.includes(str[0])) { + str = str.slice(1); + } + } + + return str.replace(this.normalizeExpression, c => + String.fromCharCode(c.charCodeAt(0) + 0x20) + ); + }, + normalizeNick(aNick) { + return this.normalize(aNick, this.userPrefixes); + }, + + isMUCName(aStr) { + return this.channelPrefixes.includes(aStr[0]); + }, + + // Tell the server about status changes. IRC is only away or not away; + // consider the away, idle and unavailable status type to be away. + isAway: false, + observe(aSubject, aTopic, aData) { + if (aTopic != "status-changed") { + return; + } + + let { statusType: type, statusText: text } = this.imAccount.statusInfo; + this.DEBUG("New status received:\ntype = " + type + "\ntext = " + text); + + // Tell the server to mark us as away. + if (type < Ci.imIStatusInfo.STATUS_AVAILABLE) { + // We have to have a string in order to set IRC as AWAY. + if (!text) { + // If no status is given, use the the default idle/away message. + const IDLE_PREF_BRANCH = "messenger.status."; + const IDLE_PREF = "defaultIdleAwayMessage"; + text = Services.prefs.getComplexValue( + IDLE_PREF_BRANCH + IDLE_PREF, + Ci.nsIPrefLocalizedString + ).data; + + if (!text) { + // Get the default value of the localized preference. + text = Services.prefs + .getDefaultBranch(IDLE_PREF_BRANCH) + .getComplexValue(IDLE_PREF, Ci.nsIPrefLocalizedString).data; + } + // The last resort, fallback to a non-localized string. + if (!text) { + text = "Away"; + } + } + this.sendMessage("AWAY", text); // Mark as away. + } else if (type == Ci.imIStatusInfo.STATUS_AVAILABLE && this.isAway) { + // Mark as back. + this.sendMessage("AWAY"); + } + }, + + // The user's user mode. + _modes: null, + _userModeReceived: false, + setUserMode(aNick, aNewModes, aSetter, aDisplayFullMode) { + if (this.normalizeNick(aNick) != this.normalizeNick(this._nickname)) { + this.WARN("Received unexpected mode for " + aNick); + return false; + } + + // Are modes being added or removed? + let addNewMode = aNewModes[0] == "+"; + if (!addNewMode && aNewModes[0] != "-") { + this.WARN("Invalid mode string: " + aNewModes); + return false; + } + _setMode.call(this, addNewMode, aNewModes.slice(1)); + + // The server informs us of the user's mode when connecting. + // We should not report this initial mode message as a mode change + // initiated by the user, but instead display the full mode + // and then remember we have done so. + this._userModeReceived = true; + + if (this._showServerTab) { + let msg; + if (aDisplayFullMode) { + msg = lazy._("message.yourmode", Array.from(this._modes).join("")); + } else { + msg = lazy._( + "message.usermode", + aNewModes, + aNick, + aSetter || this._currentServerName + ); + } + this.getConversation(this._currentServerName).writeMessage( + this._currentServerName, + msg, + { system: true } + ); + } + return true; + }, + + // Room info: maps channel names to {topic, participantCount}. + _channelList: new Map(), + _roomInfoCallbacks: new Set(), + // If true, we have sent the LIST request and are waiting for replies. + _pendingList: false, + // Callbacks receive this many channels per call while results are incoming. + _channelsPerBatch: 50, + _currentBatch: [], + _lastListTime: 0, + get isRoomInfoStale() { + return Date.now() - this._lastListTime > kListRefreshInterval; + }, + // Called by consumers that want a list of available channels, which are + // provided through the callback (prplIRoomInfoCallback instance). + requestRoomInfo(aCallback, aIsUserRequest) { + // Ignore the automaticList pref if the user explicitly requests /list. + if ( + !aIsUserRequest && + !Services.prefs.getBoolPref("chat.irc.automaticList") + ) { + // Pretend we can't return roomInfo. + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + if (this._roomInfoCallbacks.has(aCallback)) { + // Callback is not new. + return; + } + // Send a LIST request if the channel list is stale and a current request + // has not been sent. + if (this.isRoomInfoStale && !this._pendingList) { + this._channelList = new Map(); + this._currentBatch = []; + this._pendingList = true; + this._lastListTime = Date.now(); + this.sendMessage("LIST"); + } else { + // Otherwise, pass channels that have already been received to the callback. + let rooms = [...this._channelList.keys()]; + aCallback.onRoomInfoAvailable(rooms, !this._pendingList); + } + + if (this._pendingList) { + this._roomInfoCallbacks.add(aCallback); + } + }, + // Pass room info for any remaining channels to callbacks and clean up. + _sendRemainingRoomInfo() { + if (this._currentBatch.length) { + for (let callback of this._roomInfoCallbacks) { + callback.onRoomInfoAvailable(this._currentBatch, true); + } + } + this._roomInfoCallbacks.clear(); + delete this._pendingList; + delete this._currentBatch; + }, + getRoomInfo(aName) { + return new ircRoomInfo(aName, this); + }, + + // The last time a buffered command was sent. + _lastCommandSendTime: 0, + // A map from command names to the parameter buffer for that command. + // This buffer is a map from first parameter to the corresponding (optional) + // second parameter, to ensure automatic deduplication. + _commandBuffers: new Map(), + _handleCommandBuffer(aCommand) { + let buffer = this._commandBuffers.get(aCommand); + if (!buffer || !buffer.size) { + return; + } + // This short delay should usually not affect commands triggered by + // user action, but helps gather commands together which are sent + // by the prpl on connection (e.g. WHOIS sent in response to incoming + // WATCH results). + const kInterval = 1000; + let delay = kInterval - (Date.now() - this._lastCommandSendTime); + if (delay > 0) { + setTimeout(() => this._handleCommandBuffer(aCommand), delay); + return; + } + this._lastCommandSendTime = Date.now(); + + let getParams = aItems => { + // Taking the JOIN use case as an example, aItems is an array + // of [channel, key] pairs. + // To work around an inspircd bug (bug 1108596), we reorder + // the list so that entries with keys appear first. + let items = aItems.slice().sort(([c1, k1], [c2, k2]) => { + if (!k1 && k2) { + return 1; + } + if (k1 && !k2) { + return -1; + } + return 0; + }); + // To send the command, we have to group all the channels and keys + // together, i.e. grab the columns of this matrix, and build the two + // parameters of the command from that. + let channels = items.map(([channel, key]) => channel); + let keys = items.map(([channel, key]) => key).filter(key => !!key); + let params = [channels.join(",")]; + if (keys.length) { + params.push(keys.join(",")); + } + return params; + }; + let tooMany = aItems => { + let params = getParams(aItems); + let length = this.countBytes(this.buildMessage(aCommand, params)) + 2; + return this.maxMessageLength < length; + }; + let send = aItems => { + let params = getParams(aItems); + // Send the command, but don't log the keys. + this.sendMessage( + aCommand, + params, + aCommand + + " " + + params[0] + + (params.length > 1 ? " <keys not logged>" : "") + ); + }; + + let items = []; + for (let item of buffer) { + items.push(item); + if (tooMany(items)) { + items.pop(); + send(items); + items = [item]; + } + } + send(items); + buffer.clear(); + }, + // For commands which allow an arbitrary number of parameters, we use a + // buffer to send as few commands as possible, by gathering the parameters. + // On servers which impose command penalties (e.g. inspircd) this helps + // avoid triggering fakelags by minimizing the command penalty. + // aParam is the first and aKey the optional second parameter of a command + // with the syntax <param> *("," <param>) [<key> *("," <key>)] + // While this code is mostly abstracted, it is currently assumed the second + // parameter is only used for JOIN. + sendBufferedCommand(aCommand, aParam, aKey = "") { + if (!this._commandBuffers.has(aCommand)) { + this._commandBuffers.set(aCommand, new Map()); + } + let buffer = this._commandBuffers.get(aCommand); + // If the buffer is empty, schedule sending the command, otherwise + // we just need to add the parameter to the buffer. + // We use executeSoon so as to not delay the sending of these + // commands when it is not necessary. + if (!buffer.size) { + executeSoon(() => this._handleCommandBuffer(aCommand)); + } + buffer.set(aParam, aKey); + }, + + // The whois information: nicks are used as keys and refer to a map of field + // to value. + whoisInformation: null, + // Request WHOIS information on a buddy when the user requests more + // information. If we already have some WHOIS information stored for this + // nick, a notification with this (potentially out-of-date) information + // is sent out immediately. It is followed by another notification when + // the current WHOIS data is returned by the server. + // If you are only interested in the current WHOIS, requestCurrentWhois + // should be used instead. + requestBuddyInfo(aBuddyName) { + if (!this.connected) { + return; + } + + // Return what we have stored immediately. + if (this.whoisInformation.has(aBuddyName)) { + this.notifyWhois(aBuddyName); + } + + // Request the current whois and update. + this.requestCurrentWhois(aBuddyName); + }, + // Request fresh WHOIS information on a nick. + requestCurrentWhois(aNick) { + if (!this.connected) { + return; + } + + this.removeBuddyInfo(aNick); + this.sendBufferedCommand("WHOIS", aNick); + }, + notifyWhois(aNick) { + Services.obs.notifyObservers( + new nsSimpleEnumerator(this.getBuddyInfo(aNick)), + "user-info-received", + this.normalizeNick(aNick) + ); + }, + // Request WHOWAS information on a buddy when the user requests more + // information. + requestOfflineBuddyInfo(aBuddyName) { + this.removeBuddyInfo(aBuddyName); + this.sendMessage("WHOWAS", aBuddyName); + }, + // Return an array of prplITooltipInfo for a given nick. + getBuddyInfo(aNick) { + if (!this.whoisInformation.has(aNick)) { + return []; + } + + let whoisInformation = this.whoisInformation.get(aNick); + if (whoisInformation.serverName && whoisInformation.serverInfo) { + whoisInformation.server = lazy._( + "tooltip.serverValue", + whoisInformation.serverName, + whoisInformation.serverInfo + ); + } + + // Sort the list of channels, ignoring the prefixes of channel and user. + let prefixes = this.userPrefixes.concat(this.channelPrefixes); + let sortWithoutPrefix = function (a, b) { + a = this.normalize(a, prefixes); + b = this.normalize(b, prefixes); + if (a < b) { + return -1; + } + return a > b ? 1 : 0; + }.bind(this); + let sortChannels = channels => + channels.trim().split(/\s+/).sort(sortWithoutPrefix).join(" "); + + // Convert booleans into a human-readable form. + let normalizeBool = aBool => lazy._(aBool ? "yes" : "no"); + + // Convert timespan in seconds into a human-readable form. + let normalizeTime = function (aTime) { + let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits(aTime); + // If the time is exact to the first set of units, trim off + // the subsequent zeroes. + if (!valuesAndUnits[2]) { + valuesAndUnits.splice(2, 2); + } + return lazy._("tooltip.timespan", valuesAndUnits.join(" ")); + }; + + // List of the names of the info to actually show in the tooltip and + // optionally a transform function to apply to the value. Each field here + // maps to tooltip.<fieldname> in irc.properties. + // See the various RPL_WHOIS* results for the options. + const kFields = { + realname: null, + server: null, + connectedFrom: null, + registered: normalizeBool, + registeredAs: null, + secure: normalizeBool, + ircOp: normalizeBool, + bot: normalizeBool, + lastActivity: normalizeTime, + channels: sortChannels, + }; + + let tooltipInfo = []; + for (let field in kFields) { + if (whoisInformation.hasOwnProperty(field) && whoisInformation[field]) { + let value = whoisInformation[field]; + if (kFields[field]) { + value = kFields[field](value); + } + tooltipInfo.push(new TooltipInfo(lazy._("tooltip." + field), value)); + } + } + + const kSetIdleStatusAfterSeconds = 3600; + let statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; + let statusText = ""; + if ("away" in whoisInformation) { + statusType = Ci.imIStatusInfo.STATUS_AWAY; + statusText = whoisInformation.away; + } else if ("offline" in whoisInformation) { + statusType = Ci.imIStatusInfo.STATUS_OFFLINE; + } else if ( + "lastActivity" in whoisInformation && + whoisInformation.lastActivity > kSetIdleStatusAfterSeconds + ) { + statusType = Ci.imIStatusInfo.STATUS_IDLE; + } + tooltipInfo.push( + new TooltipInfo(statusType, statusText, Ci.prplITooltipInfo.status) + ); + + return tooltipInfo; + }, + // Remove a WHOIS entry. + removeBuddyInfo(aNick) { + return this.whoisInformation.delete(aNick); + }, + // Copies the fields of aFields into the whois table. If the field already + // exists, that field is ignored (it is assumed that the first server response + // is the most up to date information, as is the case for 312/314). Note that + // the whois info for a nick is reset whenever whois information is requested, + // so the first response from each whois is recorded. + setWhois(aNick, aFields = {}) { + // If the nickname isn't in the list yet, add it. + if (!this.whoisInformation.has(aNick)) { + this.whoisInformation.set(aNick, {}); + } + + // Set non-normalized nickname field. + let whoisInfo = this.whoisInformation.get(aNick); + whoisInfo.nick = aNick; + + // Set the WHOIS fields, but only the first time a field is set. + for (let field in aFields) { + if (!whoisInfo.hasOwnProperty(field)) { + whoisInfo[field] = aFields[field]; + } + } + + return true; + }, + + trackBuddy(aNick) { + // Put the username as the first to be checked on the next ISON call. + this.trackQueue.unshift(aNick); + }, + untrackBuddy(aNick) { + let index = this.trackQueue.indexOf(aNick); + if (index < 0) { + this.ERROR( + "Trying to untrack a nick that was not being tracked: " + aNick + ); + return; + } + this.trackQueue.splice(index, 1); + }, + addBuddy(aTag, aName) { + let buddy = new ircAccountBuddy(this, null, aTag, aName); + this.buddies.set(buddy.normalizedName, buddy); + this.trackBuddy(buddy.userName); + + IMServices.contacts.accountBuddyAdded(buddy); + }, + removeBuddy(aBuddy) { + this.buddies.delete(aBuddy.normalizedName); + this.untrackBuddy(aBuddy.userName); + }, + // Loads a buddy from the local storage. Called for each buddy locally stored + // before connecting to the server. + loadBuddy(aBuddy, aTag) { + let buddy = new ircAccountBuddy(this, aBuddy, aTag); + this.buddies.set(buddy.normalizedName, buddy); + this.trackBuddy(buddy.userName); + + return buddy; + }, + changeBuddyNick(aOldNick, aNewNick) { + if (this.normalizeNick(aOldNick) == this.normalizeNick(this._nickname)) { + // Your nickname changed! + this._nickname = aNewNick; + this.conversations.forEach(conversation => { + // Update the nick for chats, and inform the user in every conversation. + if (conversation.isChat) { + conversation.updateNick(aOldNick, aNewNick, true); + } else { + conversation.writeMessage( + aOldNick, + lazy._conv("nickSet.you", aNewNick), + { + system: true, + } + ); + } + }); + } else { + this.conversations.forEach(conversation => { + if (conversation.isChat && conversation._participants.has(aOldNick)) { + // Update the nick in every chat conversation it is in. + conversation.updateNick(aOldNick, aNewNick, false); + } + }); + } + + // Adjust the whois table where necessary. + this.removeBuddyInfo(aOldNick); + this.setWhois(aNewNick); + + // If a private conversation is open with that user, change its title. + if (this.conversations.has(aOldNick)) { + // Get the current conversation and rename it. + let conversation = this.getConversation(aOldNick); + + // Remove the old reference to the conversation and create a new one. + this.removeConversation(aOldNick); + this.conversations.set(aNewNick, conversation); + + conversation.updateNick(aNewNick); + conversation.writeMessage( + aOldNick, + lazy._conv("nickSet", aOldNick, aNewNick), + { system: true } + ); + } + }, + + /* + * Ask the server to change the user's nick. + */ + changeNick(aNewNick) { + this._sentNickname = aNewNick; + this.sendMessage("NICK", aNewNick); // Nick message. + }, + /* + * Generate a new nick to change to if the user requested nick is already in + * use or is otherwise invalid. + * + * First try all the alternate nicks that were chosen by the user, and if none + * of them work, then generate a new nick by: + * 1. If there was not a digit at the end of the nick, append a 1. + * 2. If there was a digit, then increment the number. + * 3. Add leading 0s back on. + * 4. Ensure the nick is an appropriate length. + */ + tryNewNick(aOldNick) { + // Split the string on commas, remove whitespace around the nicks and + // remove empty nicks. + let allNicks = this.getString("alternateNicks") + .split(",") + .map(n => n.trim()) + .filter(n => !!n); + allNicks.unshift(this._accountNickname); + + // If the previously tried nick is in the array and not the last + // element, try the next nick in the array. + let oldIndex = allNicks.indexOf(aOldNick); + if (oldIndex != -1 && oldIndex < allNicks.length - 1) { + let newNick = allNicks[oldIndex + 1]; + this.LOG(aOldNick + " is already in use, trying " + newNick); + this.changeNick(newNick); + return true; + } + + // Separate the nick into the text and digits part. + let kNickPattern = /^(.+?)(\d*)$/; + let nickParts = kNickPattern.exec(aOldNick); + let newNick = nickParts[1]; + + // No nick found from the user's preferences, so just generating one. + // If there is not a digit at the end of the nick, just append 1. + let newDigits = "1"; + // If there is a digit at the end of the nick, increment it. + if (nickParts[2]) { + newDigits = (parseInt(nickParts[2], 10) + 1).toString(); + // If there are leading 0s, add them back on, after we've incremented (e.g. + // 009 --> 010). + let numLeadingZeros = nickParts[2].length - newDigits.length; + if (numLeadingZeros > 0) { + newDigits = "0".repeat(numLeadingZeros) + newDigits; + } + } + + // Servers truncate nicks that are too long, compare the previously sent + // nickname with the returned nickname and check for truncation. + if (aOldNick.length < this._sentNickname.length) { + // The nick will be too long, overwrite the end of the nick instead of + // appending. + let maxLength = aOldNick.length; + + let sentNickParts = kNickPattern.exec(this._sentNickname); + // Resend the same digits as last time, but overwrite part of the nick + // this time. + if (nickParts[2] && sentNickParts[2]) { + newDigits = sentNickParts[2]; + } + + // Handle the silly case of a single letter followed by all nines. + if (newDigits.length == this.maxNicknameLength) { + newDigits = newDigits.slice(1); + } + newNick = newNick.slice(0, maxLength - newDigits.length); + } + // Append the digits. + newNick += newDigits; + + if (this.normalize(newNick) == this.normalize(this._nickname)) { + // The nick we were about to try next is our current nick. This means + // the user attempted to change to a version of the nick with a lower or + // absent number suffix, and this failed. + let msg = lazy._("message.nick.fail", this._nickname); + this.conversations.forEach(conversation => + conversation.writeMessage(this._nickname, msg, { system: true }) + ); + return true; + } + + this.LOG(aOldNick + " is already in use, trying " + newNick); + this.changeNick(newNick); + return true; + }, + + handlePingReply(aSource, aPongTime) { + // Received PING response, display to the user. + let sentTime = new Date(parseInt(aPongTime, 10)); + + // The received timestamp is invalid. + if (isNaN(sentTime)) { + this.WARN( + aSource + " returned an invalid timestamp from a PING: " + aPongTime + ); + return false; + } + + // Find the delay in milliseconds. + let delay = Date.now() - sentTime; + + // If the delay is negative or greater than 1 minute, something is + // feeding us a crazy value. Don't display this to the user. + if (delay < 0 || 60 * 1000 < delay) { + this.WARN(aSource + " returned an invalid delay from a PING: " + delay); + return false; + } + + let msg = lazy.PluralForm.get( + delay, + lazy._("message.ping", aSource) + ).replace("#2", delay); + this.getConversation(aSource).writeMessage(aSource, msg, { system: true }); + return true; + }, + + countBytes(aStr) { + // Assume that if it's not UTF-8 then each character is 1 byte. + if (this._encoding != "UTF-8") { + return aStr.length; + } + + // Count the number of bytes in a UTF-8 encoded string. + function charCodeToByteCount(c) { + // UTF-8 stores: + // - code points below U+0080 are 1 byte, + // - code points below U+0800 are 2 bytes, + // - code points U+D800 through U+DFFF are UTF-16 surrogate halves + // (they indicate that JS has split a 4 bytes UTF-8 character + // in two halves of 2 bytes each), + // - other code points are 3 bytes. + if (c < 0x80) { + return 1; + } + if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) { + return 2; + } + return 3; + } + let bytes = 0; + for (let i = 0; i < aStr.length; i++) { + bytes += charCodeToByteCount(aStr.charCodeAt(i)); + } + return bytes; + }, + + // To check if users are online, we need to queue multiple messages. + // An internal queue of all nicks that we wish to know the status of. + trackQueue: [], + // The nicks that were last sent to the server that we're waiting for a + // response about. + pendingIsOnQueue: [], + // The time between sending isOn messages (milliseconds). + _isOnDelay: 60 * 1000, + _isOnTimer: null, + // The number of characters that are available to be filled with nicks for + // each ISON message. + _isOnLength: null, + // Generate and send an ISON message to poll for each nick's status. + sendIsOn() { + // If no buddies, just look again after the timeout. + if (this.trackQueue.length) { + // Calculate the possible length of names we can send. + if (!this._isOnLength) { + let length = this.countBytes(this.buildMessage("ISON", " ")) + 2; + this._isOnLength = this.maxMessageLength - length + 1; + } + + // Always add the next nickname to the pending queue, this handles a silly + // case where the next nick is greater than or equal to the maximum + // message length. + this.pendingIsOnQueue = [this.trackQueue.shift()]; + + // Attempt to maximize the characters used in each message, this may mean + // that a specific user gets sent very often since they have a short name! + let buddiesLength = this.countBytes(this.pendingIsOnQueue[0]); + for (let i = 0; i < this.trackQueue.length; ++i) { + // If we can fit the nick, add it to the current buffer. + if ( + buddiesLength + this.countBytes(this.trackQueue[i]) < + this._isOnLength + ) { + // Remove the name from the list and add it to the pending queue. + let nick = this.trackQueue.splice(i--, 1)[0]; + this.pendingIsOnQueue.push(nick); + + // Keep track of the length of the string, the + 1 is for the spaces. + buddiesLength += this.countBytes(nick) + 1; + + // If we've filled up the message, stop looking for more nicks. + if (buddiesLength >= this._isOnLength) { + break; + } + } + } + + // Send the message. + this.sendMessage("ISON", this.pendingIsOnQueue.join(" ")); + + // Append the pending nicks so trackQueue contains all the nicks. + this.trackQueue = this.trackQueue.concat(this.pendingIsOnQueue); + } + + // Call this function again in _isOnDelay seconds. + // This makes the assumption that this._isOnDelay >> the response to ISON + // from the server. + this._isOnTimer = setTimeout(this.sendIsOn.bind(this), this._isOnDelay); + }, + + // The message of the day uses two fields to append messages. + _motd: null, + _motdTimer: null, + + connect() { + this.reportConnecting(); + + // Mark existing MUCs as joining if they will be rejoined. + this.conversations.forEach(conversation => { + if (conversation.isChat && conversation.chatRoomFields) { + conversation.joining = true; + } + }); + + // Load preferences. + this._port = this.getInt("port"); + this._ssl = this.getBool("ssl"); + + // Use the display name as the user's real name. + this._realname = this.imAccount.statusInfo.displayName; + this._encoding = this.getString("encoding") || "UTF-8"; + this._showServerTab = this.getBool("showServerTab"); + + // Open the socket connection. + this._socket = new ircSocket(this); + this._socket.connect(this._server, this._port, this._ssl ? ["ssl"] : []); + }, + + // Functions for keeping track of whether the Client Capabilities is done. + // If a cap is to be handled, it should be registered with addCAP, where aCAP + // is a "unique" string defining what is being handled. When the cap is done + // being handled removeCAP should be called with the same string. + _availableCAPs: new Set(), + _activeCAPs: new Set(), + _requestedCAPs: new Set(), + _negotiatedCAPs: false, + _queuedCAPs: [], + addCAP(aCAP) { + if (this.connected) { + this.ERROR("Trying to add CAP " + aCAP + " after connection."); + return; + } + + this._requestedCAPs.add(aCAP); + }, + removeCAP(aDoneCAP) { + if (!this._requestedCAPs.has(aDoneCAP)) { + this.ERROR( + "Trying to remove a CAP (" + aDoneCAP + ") which isn't added." + ); + return; + } + if (this.connected) { + this.ERROR("Trying to remove CAP " + aDoneCAP + " after connection."); + return; + } + + // Remove any reference to the given capability. + this._requestedCAPs.delete(aDoneCAP); + + // However only notify the server the first time during cap negotiation, not + // when the server exposes a new cap. + if (!this._requestedCAPs.size && !this._negotiatedCAPs) { + this.sendMessage("CAP", "END"); + this._negotiatedCAPs = true; + } + }, + + // Used to wait for a response from the server. + _quitTimer: null, + // RFC 2812 Section 3.1.7. + quit(aMessage) { + this._reportDisconnecting(Ci.prplIAccount.NO_ERROR); + this.sendMessage( + "QUIT", + aMessage || this.getString("quitmsg") || undefined + ); + }, + // When the user clicks "Disconnect" in account manager, or uses /quit. + // aMessage is an optional parameter containing the quit message. + disconnect(aMessage) { + if (this.disconnected || this.disconnecting) { + return; + } + + // If there's no socket, disconnect immediately to avoid waiting 2 seconds. + if (!this._socket || this._socket.disconnected) { + this.gotDisconnected(); + return; + } + + // Let the server know we're going to disconnect. + this.quit(aMessage); + + // Reset original nickname for the next reconnect. + this._requestedNickname = this._accountNickname; + + // Give the server 2 seconds to respond, otherwise just forcefully + // disconnect the socket. This will be cancelled if a response is heard from + // the server. + this._quitTimer = setTimeout(this.gotDisconnected.bind(this), 2 * 1000); + }, + + createConversation(aName) { + return this.getConversation(aName); + }, + + // aComponents implements prplIChatRoomFieldValues. + joinChat(aComponents) { + let channel = aComponents.getValue("channel"); + // Mildly sanitize input. + channel = channel.trimLeft().split(",")[0].split(" ")[0]; + if (!channel) { + this.ERROR("joinChat called without a valid channel name."); + return null; + } + + // A channel prefix is required. If the user didn't include one, + // we prepend # automatically to match the behavior of other + // clients. Not doing it used to cause user confusion. + if (!this.channelPrefixes.includes(channel[0])) { + channel = "#" + channel; + } + + if (this.conversations.has(channel)) { + let conv = this.getConversation(channel); + if (!conv.left) { + // No need to join a channel we are already in. + return conv; + } else if (!conv.chatRoomFields) { + // We are rejoining a channel that was parted by the user. + conv._rejoined = true; + } + } + + let key = aComponents.getValue("password"); + this.sendBufferedCommand("JOIN", channel, key); + + // Open conversation early for better responsiveness. + let conv = this.getConversation(channel); + conv.joining = true; + + // Store the prplIChatRoomFieldValues to enable later reconnections. + let defaultName = key ? channel + " " + key : channel; + conv.chatRoomFields = this.getChatRoomDefaultFieldValues(defaultName); + + return conv; + }, + + chatRoomFields: { + channel: { + get label() { + return lazy._("joinChat.channel"); + }, + required: true, + }, + password: { + get label() { + return lazy._("joinChat.password"); + }, + isPassword: true, + }, + }, + + parseDefaultChatName(aDefaultName) { + let params = aDefaultName.trim().split(/\s+/); + let chatFields = { channel: params[0] }; + if (params.length > 1) { + chatFields.password = params[1]; + } + return chatFields; + }, + + // Attributes + get canJoinChat() { + return true; + }, + + // Returns a conversation (creates it if it doesn't exist) + getConversation(aName) { + if (!this.conversations.has(aName)) { + // If the whois information has been received, we have the proper nick + // capitalization. + if (this.whoisInformation.has(aName)) { + aName = this.whoisInformation.get(aName).nick; + } + let convClass = this.isMUCName(aName) ? ircChannel : ircConversation; + this.conversations.set(aName, new convClass(this, aName, this._nickname)); + } + return this.conversations.get(aName); + }, + + removeConversation(aConversationName) { + if (this.conversations.has(aConversationName)) { + this.conversations.delete(aConversationName); + } + }, + + // This builds the message string that will be sent to the server. + buildMessage(aCommand, aParams = []) { + if (!aCommand) { + this.ERROR("IRC messages must have a command."); + return null; + } + + // Ensure a command is only characters or numbers. + if (!/^[A-Z0-9]+$/i.test(aCommand)) { + this.ERROR("IRC command invalid: " + aCommand); + return null; + } + + let message = aCommand; + // If aParams is not an array, consider it to be a single parameter and put + // it into an array. + let params = Array.isArray(aParams) ? aParams : [aParams]; + if (params.length) { + if (params.slice(0, -1).some(p => p.includes(" "))) { + this.ERROR("IRC parameters cannot have spaces: " + params.slice(0, -1)); + return null; + } + // Join the parameters with spaces. There are three cases in which the + // last parameter ("trailing" in RFC 2812) must be prepended with a colon: + // 1. If the last parameter contains a space. + // 2. If the first character of the last parameter is a colon. + // 3. If the last parameter is an empty string. + let trailing = params.slice(-1)[0]; + if ( + !trailing.length || + trailing.includes(" ") || + trailing.startsWith(":") + ) { + params.push(":" + params.pop()); + } + message += " " + params.join(" "); + } + + return message; + }, + + // Shortcut method to build & send a message at once. Use aLoggedData to log + // something different than what is actually sent. + // Returns false if the message could not be sent. + sendMessage(aCommand, aParams, aLoggedData) { + return this.sendRawMessage( + this.buildMessage(aCommand, aParams), + aLoggedData + ); + }, + + // This sends a message over the socket and catches any errors. Use + // aLoggedData to log something different than what is actually sent. + // Returns false if the message could not be sent. + sendRawMessage(aMessage, aLoggedData) { + // Low level quoting, replace \0, \n, \r or \020 with \0200, \020n, \020r or + // \020\020, respectively. + const lowQuote = { "\0": "0", "\n": "n", "\r": "r", "\x10": "\x10" }; + const lowRegex = new RegExp( + "[" + Object.keys(lowQuote).join("") + "]", + "g" + ); + aMessage = aMessage.replace(lowRegex, aChar => "\x10" + lowQuote[aChar]); + + if (!this._socket || this._socket.disconnected) { + this.gotDisconnected( + Ci.prplIAccount.ERROR_NETWORK_ERROR, + lazy._("connection.error.lost") + ); + } + + let length = this.countBytes(aMessage) + 2; + if (length > this.maxMessageLength) { + // Log if the message is too long, but try to send it anyway. + this.WARN( + "Message length too long (" + + length + + " > " + + this.maxMessageLength + + "\n" + + aMessage + ); + } + + aMessage += "\r\n"; + + try { + this._socket.sendString(aMessage, this._encoding, aLoggedData); + return true; + } catch (e) { + try { + this._socket.sendData(aMessage, aLoggedData); + this.WARN( + "Failed to convert " + + aMessage + + " from Unicode to " + + this._encoding + + "." + ); + return true; + } catch (e) { + this.ERROR("Socket error:", e); + this.gotDisconnected( + Ci.prplIAccount.ERROR_NETWORK_ERROR, + lazy._("connection.error.lost") + ); + return false; + } + } + }, + + // CTCP messages are \001<COMMAND> [<parameters>]*\001. + // Returns false if the message could not be sent. + sendCTCPMessage(aTarget, aIsNotice, aCtcpCommand, aParams = []) { + // Combine the CTCP command and parameters into the single IRC param. + let ircParam = aCtcpCommand; + // If aParams is not an array, consider it to be a single parameter and put + // it into an array. + let params = Array.isArray(aParams) ? aParams : [aParams]; + if (params.length) { + ircParam += " " + params.join(" "); + } + + // High/CTCP level quoting, replace \134 or \001 with \134\134 or \134a, + // respectively. This is only done inside the extended data message. + // eslint-disable-next-line no-control-regex + const highRegex = /\\|\x01/g; + ircParam = ircParam.replace( + highRegex, + aChar => "\\" + (aChar == "\\" ? "\\" : "a") + ); + + // Add the CTCP tagging. + ircParam = "\x01" + ircParam + "\x01"; + + // Send the IRC message as a NOTICE or PRIVMSG. + return this.sendMessage(aIsNotice ? "NOTICE" : "PRIVMSG", [ + aTarget, + ircParam, + ]); + }, + + // Implement section 3.1 of RFC 2812 + _connectionRegistration() { + // Send the Client Capabilities list command version 3.2. + this.sendMessage("CAP", ["LS", "302"]); + + if (this.prefs.prefHasUserValue("serverPassword")) { + this.sendMessage( + "PASS", + this.getString("serverPassword"), + "PASS <password not logged>" + ); + } + + // Send the nick message (section 3.1.2). + this.changeNick(this._requestedNickname); + + // Send the user message (section 3.1.3). + this.sendMessage("USER", [ + this.username, + this._mode.toString(), + "*", + this._realname || this._requestedNickname, + ]); + }, + + _reportDisconnecting(aErrorReason, aErrorMessage) { + this.reportDisconnecting(aErrorReason, aErrorMessage); + + // Cancel any pending buffered commands. + this._commandBuffers.clear(); + + // Mark all contacts on the account as having an unknown status. + this.buddies.forEach(aBuddy => + aBuddy.setStatus(Ci.imIStatusInfo.STATUS_UNKNOWN, "") + ); + }, + + gotDisconnected(aError = Ci.prplIAccount.NO_ERROR, aErrorMessage = "") { + if (!this.imAccount || this.disconnected) { + return; + } + + // If we are already disconnecting, this call to gotDisconnected + // is when the server acknowledges our disconnection. + // Otherwise it's because we lost the connection. + if (!this.disconnecting) { + this._reportDisconnecting(aError, aErrorMessage); + } + this._socket.disconnect(); + delete this._socket; + + // Reset cap negotiation. + this._availableCAPs.clear(); + this._activeCAPs.clear(); + this._requestedCAPs.clear(); + this._negotiatedCAPs = false; + this._queuedCAPs.length = 0; + + clearTimeout(this._isOnTimer); + delete this._isOnTimer; + + // No need to call gotDisconnected a second time. + clearTimeout(this._quitTimer); + delete this._quitTimer; + + // MOTD will be resent. + delete this._motd; + clearTimeout(this._motdTimer); + delete this._motdTimer; + + // We must authenticate if we reconnect. + delete this.isAuthenticated; + + // Clear any pending attempt to regain our nick. + clearTimeout(this._nickInUseTimeout); + delete this._nickInUseTimeout; + + // Clean up each conversation: mark as left and remove participant. + this.conversations.forEach(conversation => { + if (conversation.isChat) { + conversation.joining = false; // In case we never finished joining. + if (!conversation.left) { + // Remove the user's nick and mark the conversation as left as that's + // the final known state of the room. + conversation.removeParticipant(this._nickname); + conversation.left = true; + } + } + }); + + // If we disconnected during a pending LIST request, make sure callbacks + // receive any remaining channels. + if (this._pendingList) { + this._sendRemainingRoomInfo(); + } + + // Clear whois table. + this.whoisInformation.clear(); + + this.reportDisconnected(); + }, + + remove() { + this.conversations.forEach(conv => conv.close()); + delete this.conversations; + this.buddies.forEach(aBuddy => aBuddy.remove()); + delete this.buddies; + }, + + unInit() { + // Disconnect if we're online while this gets called. + if (this._socket) { + if (!this.disconnecting) { + this.quit(); + } + this._socket.disconnect(); + } + delete this.imAccount; + clearTimeout(this._isOnTimer); + clearTimeout(this._quitTimer); + }, +}; |