chat/protocols/irc/ircAccount.sys.mjs (2,115 lines of code) (raw):

/* 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 { ClassInfo, executeSoon, 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 { ChatRoomFieldValues, 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", ircHandlers: "resource:///modules/ircHandlers.sys.mjs", }); ChromeUtils.defineLazyGetter( lazy, "l10n", () => new Localization(["chat/conversations.ftl", "chat/irc.ftl"], true) ); /* * 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) { const 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]) { const 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 (const newMode of aNewModes) { const 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. const 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 (const 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. const messages = aOutgoingMessage.message.split(/[\r\n]+/); const 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) { const message = messages[i]; const length = this._account.countBytes(message); // The message is short enough. if (length <= maxLength) { continue; } // Find the location of a space before the maximum length. const 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.l10n.formatValueSync("error-send-message-failed"), { error: true, system: true, } ); return; } } else if ( !this._account.sendMessage(isNotice ? "NOTICE" : "PRIVMSG", [ this.name, message, ]) ) { this.writeMessage( this._account._currentServerName, lazy.l10n.formatValueSync("error-send-message-failed"), { 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. * * @param {string} string - The currently typed message. * @returns {number} The number of characters that can still be typed */ getRemainingCharacters(string) { const longestLineLength = Math.max.apply( null, string.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; } const nick = this.normalizeNick(aData); const 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. const 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. const 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.l10n.formatValueSync("message-unknown-nick", { 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.l10n.formatValueSync(msgType, { nick: account.whoisInformation.get(nick).nick, }); // Iterate over each field. for (const elt of aSubject.QueryInterface(Ci.nsISimpleEnumerator)) { switch (elt.type) { case Ci.prplITooltipInfo.pair: case Ci.prplITooltipInfo.sectionHeader: msg += "\n" + lazy.l10n.formatValueSync("message-whois-entry", { description: elt.label, value: 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.l10n.formatValueSync("message-whois-entry", { description: lazy.l10n.formatValueSync("tooltip-away"), value: 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) { const 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. const 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); } const 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. * * @param {string} aNewMode - The new mode string, it MUST begin with + or -. * @param {string[]} aModeParams - A list of ordered string parameters for the * mode string. * @param {string} aSetter - 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. const 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; } const addNewMode = aNewMode[0] == "+"; // Check each mode being added and update the user. const channelModes = []; const 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). const 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. if (addNewMode) { const 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.l10n.formatValueSync("message-channel-key-added", { nick: aSetter, newPassword: key, }); this.chatRoomFields.setValue("password", key); } else { msg = lazy.l10n.formatValueSync("message-channel-key-removed", { nick: aSetter, }); this.chatRoomFields.setValue("password", undefined); } this.writeMessage(aSetter, msg, { system: true }); } else if (aNewMode[i] == "b") { // A banmask was added or removed. const banMask = getNextParam(); let msgKey = "message-ban-mask"; if (addNewMode) { this.banMasks.push(banMask); msgKey += "-added"; } else { this.banMasks = this.banMasks.filter(aBanMask => banMask != aBanMask); msgKey += "-removed"; } this.writeMessage( aSetter, lazy.l10n.formatValueSync(msgKey, { locationMatches: banMask, nick: 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 (const [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.l10n.formatValueSync("message-channelmode", { mode: aNewMode[0] + channelModes.join(""), user: aSetter, }); this.writeMessage(aSetter, msg, { system: true }); this._receivedInitialMode = true; }, setModesFromRestriction(aRestriction) { // First remove all types from the list of modes. for (const key in this._account.channelRestrictionToModeMap) { const mode = this._account.channelRestrictionToModeMap[key]; this._modes.delete(mode); } // Add the new mode onto the list. if (aRestriction in this._account.channelRestrictionToModeMap) { const 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! const 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 ) { const 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. const msg = lazy.l10n.formatValueSync("message-usermode", { mode: (aAddNewMode ? "+" : "-") + aNewModes.join(""), targetUser: this.name, sourceUser: 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) { const 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 { const 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" }; const dequotedMessage = aRawMessage.replace( // eslint-disable-next-line no-control-regex /\x10./g, aStr => lowDequote[aStr[1]] || aStr[1] ); try { const 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.l10n.formatValueSync("connection-error-lost") ); } }, onConnectionReset() { this.WARN("Connection reset."); this._account.gotDisconnected( Ci.prplIAccount.ERROR_NETWORK_ERROR, lazy.l10n.formatValueSync("connection-error-lost") ); }, onConnectionTimedOut() { this.WARN("Connection timed out."); this._account.gotDisconnected( Ci.prplIAccount.ERROR_NETWORK_ERROR, lazy.l10n.formatValueSync("connection-error-time-out") ); }, onConnectionSecurityError(aTLSError, aNSSErrorMessage) { this.WARN( "Bad certificate or SSL connection for " + this._account.name + ":\n" + aNSSErrorMessage ); const 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.getChatRoomFieldValuesFromString(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) { 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? const 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.l10n.formatValueSync("message-yourmode", { mode: Array.from(this._modes).join(""), }); } else { msg = lazy.l10n.formatValueSync("message-usermode", { mode: aNewModes, targetUser: aNick, sourceUser: 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. const 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 (const 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) { const 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; const delay = kInterval - (Date.now() - this._lastCommandSendTime); if (delay > 0) { setTimeout(() => this._handleCommandBuffer(aCommand), delay); return; } this._lastCommandSendTime = Date.now(); const 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. const items = aItems.slice().sort(([, k1], [, 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. const channels = items.map(([channel]) => channel); const keys = items.map(([, key]) => key).filter(key => !!key); const params = [channels.join(",")]; if (keys.length) { params.push(keys.join(",")); } return params; }; const tooMany = aItems => { const params = getParams(aItems); const length = this.countBytes(this.buildMessage(aCommand, params)) + 2; return this.maxMessageLength < length; }; const send = aItems => { const 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 (const 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()); } const 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 []; } const whoisInformation = this.whoisInformation.get(aNick); if (whoisInformation.serverName && whoisInformation.serverInfo) { whoisInformation.server = lazy.l10n.formatValueSync( "tooltip-server-value", { serverName: whoisInformation.serverName, serverInformation: whoisInformation.serverInfo, } ); } // Sort the list of channels, ignoring the prefixes of channel and user. const prefixes = this.userPrefixes.concat(this.channelPrefixes); const 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); const sortChannels = channels => channels.trim().split(/\s+/).sort(sortWithoutPrefix).join(" "); // Convert booleans into a human-readable form. const normalizeBool = aBool => lazy.l10n.formatValueSync(aBool ? "yes-key-key" : "no-key-key"); // Convert timespan in seconds into a human-readable form. const normalizeTime = function (aTime) { const 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.l10n.formatValueSync("tooltip-timespan", { 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, }; const tooltipMessages = { realname: "tooltip-realname", server: "tooltip-server", connectedFrom: "tooltip-connected-from", registered: "tooltip-registered", registeredAs: "tooltip-registered-as", secure: "tooltip-secure", ircOp: "tooltip-irc-op", bot: "tooltip-bot", lastActivity: "tooltip-last-activity", channels: "tooltip-channels", }; const tooltipInfo = []; for (const 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.l10n.formatValueSync(tooltipMessages[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. const whoisInfo = this.whoisInformation.get(aNick); whoisInfo.nick = aNick; // Set the WHOIS fields, but only the first time a field is set. for (const 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) { const 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) { const 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) { const 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.l10n.formatValueSync("nick-set-you", { newNick: 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. const 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.l10n.formatValueSync("nick-set-key", { oldNick: aOldNick, newNick: 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. const 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. const oldIndex = allNicks.indexOf(aOldNick); if (oldIndex != -1 && oldIndex < allNicks.length - 1) { const 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. const kNickPattern = /^(.+?)(\d*)$/; const 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). const 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. const maxLength = aOldNick.length; const 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. const msg = lazy.l10n.formatValueSync("message-nick-fail", { nick: 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. const 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. const 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; } const msg = lazy.l10n.formatValueSync("message-ping", { source: aSource, 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) { const 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. const 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)) { const 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; } } // Open conversation early for better responsiveness. const conv = this.getConversation(channel); conv.joining = true; // Store the prplIChatRoomFieldValues to enable later reconnections. conv.chatRoomFields = aComponents; const key = aComponents.getValue("password"); this.sendBufferedCommand("JOIN", channel, key); return conv; }, chatRoomFields: { channel: { get label() { return lazy.l10n.formatValueSync("join-chat-channel"); }, required: true, }, password: { get label() { return lazy.l10n.formatValueSync("join-chat-password"); }, isPassword: true, }, }, getChatRoomFieldValuesFromString(aString) { const params = aString.trim().split(/\s+/); const chatFields = { channel: params[0] }; if (params.length > 1) { chatFields.password = params[1]; } return new ChatRoomFieldValues(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; } const 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. const 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. const 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.l10n.formatValueSync("connection-error-lost") ); } const 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 (ex) { this.ERROR("Socket error:", ex); this.gotDisconnected( Ci.prplIAccount.ERROR_NETWORK_ERROR, lazy.l10n.formatValueSync("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. const 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); }, };