diff options
Diffstat (limited to '')
40 files changed, 9711 insertions, 0 deletions
diff --git a/comm/chat/protocols/irc/components.conf b/comm/chat/protocols/irc/components.conf new file mode 100644 index 0000000000..08a9674884 --- /dev/null +++ b/comm/chat/protocols/irc/components.conf @@ -0,0 +1,15 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{607b2c0b-9504-483f-ad62-41de09238aec}', + 'contract_ids': ['@mozilla.org/chat/irc;1'], + 'esModule': 'resource:///modules/irc.sys.mjs', + 'constructor': 'ircProtocol', + 'categories': {'im-protocol-plugin': 'prpl-irc'}, + }, +] diff --git a/comm/chat/protocols/irc/icons/prpl-irc-32.png b/comm/chat/protocols/irc/icons/prpl-irc-32.png Binary files differnew file mode 100644 index 0000000000..003103914c --- /dev/null +++ b/comm/chat/protocols/irc/icons/prpl-irc-32.png diff --git a/comm/chat/protocols/irc/icons/prpl-irc-48.png b/comm/chat/protocols/irc/icons/prpl-irc-48.png Binary files differnew file mode 100644 index 0000000000..606425fabb --- /dev/null +++ b/comm/chat/protocols/irc/icons/prpl-irc-48.png diff --git a/comm/chat/protocols/irc/icons/prpl-irc.png b/comm/chat/protocols/irc/icons/prpl-irc.png Binary files differnew file mode 100644 index 0000000000..19d578deda --- /dev/null +++ b/comm/chat/protocols/irc/icons/prpl-irc.png diff --git a/comm/chat/protocols/irc/irc.sys.mjs b/comm/chat/protocols/irc/irc.sys.mjs new file mode 100644 index 0000000000..087dbf28d8 --- /dev/null +++ b/comm/chat/protocols/irc/irc.sys.mjs @@ -0,0 +1,122 @@ +/* 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 { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { GenericProtocolPrototype } from "resource:///modules/jsProtoHelper.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); +ChromeUtils.defineESModuleGetters(lazy, { + ircAccount: "resource:///modules/ircAccount.sys.mjs", +}); + +export function ircProtocol() { + // ircCommands.jsm exports one variable: commands. Import this directly into + // the protocol object. + this.commands = ChromeUtils.importESModule( + "resource:///modules/ircCommands.sys.mjs" + ).commands; + this.registerCommands(); +} + +ircProtocol.prototype = { + __proto__: GenericProtocolPrototype, + get name() { + return "IRC"; + }, + get normalizedName() { + return "irc"; + }, + get iconBaseURI() { + return "chrome://prpl-irc/skin/"; + }, + get usernameEmptyText() { + return lazy._("irc.usernameHint"); + }, + + usernameSplits: [ + { + get label() { + return lazy._("options.server"); + }, + separator: "@", + defaultValue: "irc.libera.chat", + }, + ], + + splitUsername(aName) { + let splitter = aName.lastIndexOf("@"); + if (splitter === -1) { + return []; + } + return [aName.slice(0, splitter), aName.slice(splitter + 1)]; + }, + + options: { + port: { + get label() { + return lazy._("options.port"); + }, + default: 6697, + }, + ssl: { + get label() { + return lazy._("options.ssl"); + }, + default: true, + }, + // TODO We should attempt to auto-detect encoding instead. + encoding: { + get label() { + return lazy._("options.encoding"); + }, + default: "UTF-8", + }, + quitmsg: { + get label() { + return lazy._("options.quitMessage"); + }, + get default() { + return Services.prefs.getCharPref("chat.irc.defaultQuitMessage"); + }, + }, + partmsg: { + get label() { + return lazy._("options.partMessage"); + }, + default: "", + }, + showServerTab: { + get label() { + return lazy._("options.showServerTab"); + }, + default: false, + }, + alternateNicks: { + get label() { + return lazy._("options.alternateNicks"); + }, + default: "", + }, + }, + + get chatHasTopic() { + return true; + }, + get slashCommandsNative() { + return true; + }, + // Passwords in IRC are optional, and are needed for certain functionality. + get passwordOptional() { + return true; + }, + + getAccount(aImAccount) { + return new lazy.ircAccount(this, aImAccount); + }, +}; 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); + }, +}; diff --git a/comm/chat/protocols/irc/ircBase.sys.mjs b/comm/chat/protocols/irc/ircBase.sys.mjs new file mode 100644 index 0000000000..9127dd4e24 --- /dev/null +++ b/comm/chat/protocols/irc/ircBase.sys.mjs @@ -0,0 +1,1768 @@ +/* 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/. */ + +/* + * This contains the implementation for the basic Internet Relay Chat (IRC) + * protocol covered by RFCs 2810, 2811, 2812 and 2813 (which obsoletes RFC + * 1459). RFC 2812 covers the client commands and protocol. + * RFC 2810: Internet Relay Chat: Architecture + * http://tools.ietf.org/html/rfc2810 + * RFC 2811: Internet Relay Chat: Channel Management + * http://tools.ietf.org/html/rfc2811 + * RFC 2812: Internet Relay Chat: Client Protocol + * http://tools.ietf.org/html/rfc2812 + * RFC 2813: Internet Relay Chat: Server Protocol + * http://tools.ietf.org/html/rfc2813 + * RFC 1459: Internet Relay Chat Protocol + * http://tools.ietf.org/html/rfc1459 + */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + l10nHelper, + nsSimpleEnumerator, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; +import { + ctcpFormatToText, + conversationErrorMessage, + displayMessage, + kListRefreshInterval, +} from "resource:///modules/ircUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); + +// Display the message and remove them from the rooms they're in. +function leftRoom(aAccount, aNicks, aChannels, aSource, aReason, aKicked) { + let msgId = "message." + (aKicked ? "kicked" : "parted"); + // If a part message was included, include it. + let reason = aReason ? lazy._(msgId + ".reason", aReason) : ""; + function __(aNick, aYou) { + // If the user is kicked, we need to say who kicked them. + let msgId2 = msgId + (aYou ? ".you" : ""); + if (aKicked) { + if (aYou) { + return lazy._(msgId2, aSource, reason); + } + return lazy._(msgId2, aNick, aSource, reason); + } + if (aYou) { + return lazy._(msgId2, reason); + } + return lazy._(msgId2, aNick, reason); + } + + for (let channelName of aChannels) { + if (!aAccount.conversations.has(channelName)) { + // Handle when we closed the window. + continue; + } + let conversation = aAccount.getConversation(channelName); + for (let nick of aNicks) { + let msg; + if (aAccount.normalize(nick) == aAccount.normalize(aAccount._nickname)) { + msg = __(nick, true); + // If the user left, mark the conversation as no longer being active. + conversation.left = true; + } else { + msg = __(nick); + } + + conversation.writeMessage(aSource, msg, { system: true }); + conversation.removeParticipant(nick); + } + } + return true; +} + +function writeMessage(aAccount, aMessage, aString, aType) { + let type = {}; + type[aType] = true; + type.tags = aMessage.tags; + aAccount + .getConversation(aMessage.origin) + .writeMessage(aMessage.origin, aString, type); + return true; +} + +// If aNoLastParam is true, the last parameter is not printed out. +function serverMessage(aAccount, aMsg, aNoLastParam) { + // If we don't want to show messages from the server, just mark it as handled. + if (!aAccount._showServerTab) { + return true; + } + + return writeMessage( + aAccount, + aMsg, + aMsg.params.slice(1, aNoLastParam ? -1 : undefined).join(" "), + "system" + ); +} + +function serverErrorMessage(aAccount, aMessage, aError) { + // If we don't want to show messages from the server, just mark it as handled. + if (!aAccount._showServerTab) { + return true; + } + + return writeMessage(aAccount, aMessage, aError, "error"); +} + +function addMotd(aAccount, aMessage) { + // If there is no current MOTD to append to, start a new one. + if (!aAccount._motd) { + aAccount._motd = []; + } + + // Traditionally, MOTD messages start with "- ", but this is not always + // true, try to handle that sanely. + let message = aMessage.params[1]; + if (message.startsWith("-")) { + message = message.slice(1).trim(); + } + // And traditionally, the initial message ends in " -", remove that. + if (message.endsWith("-")) { + message = message.slice(0, -1).trim(); + } + + // Actually add the message (if it still exists). + if (message) { + aAccount._motd.push(message); + } + + // Oh, also some servers don't send a RPL_ENDOFMOTD (e.g. irc.ppy.sh), so if + // we don't receive another MOTD message after 1 second, consider it to be + // RPL_ENDOFMOTD. + clearTimeout(aAccount._motdTimer); + aAccount._motdTimer = setTimeout( + ircBase.commands["376"].bind(aAccount), + 1000, + aMessage + ); + + return true; +} + +// See RFCs 2811 & 2812 (which obsoletes RFC 1459) for a description of these +// commands. +export var ircBase = { + // Parameters + name: "RFC 2812", // Name identifier + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + // The IRC commands that can be handled. + commands: { + ERROR(aMessage) { + // ERROR <error message> + // Client connection has been terminated. + if (!this.disconnecting) { + // We received an ERROR message when we weren't expecting it, this is + // probably the server giving us a ping timeout. + this.WARN("Received unexpected ERROR response:\n" + aMessage.params[0]); + this.gotDisconnected( + Ci.prplIAccount.ERROR_NETWORK_ERROR, + lazy._("connection.error.lost") + ); + } else { + // We received an ERROR message when expecting it (i.e. we've sent a + // QUIT command). Notify account manager. + this.gotDisconnected(); + } + return true; + }, + INVITE(aMessage) { + // INVITE <nickname> <channel> + let channel = aMessage.params[1]; + this.addChatRequest( + channel, + () => { + this.joinChat(this.getChatRoomDefaultFieldValues(channel)); + }, + request => { + // Inform the user when an invitation was automatically ignored. + if (!request) { + // Otherwise just notify the user. + this.getConversation(channel).writeMessage( + aMessage.origin, + lazy._("message.inviteReceived", aMessage.origin, channel), + { system: true } + ); + } + } + ); + return true; + }, + JOIN(aMessage) { + // JOIN ( <channel> *( "," <channel> ) [ <key> *( "," <key> ) ] ) / "0" + // Iterate over each channel. + for (let channelName of aMessage.params[0].split(",")) { + let conversation = this.getConversation(channelName); + + // Check whether we joined the channel or if someone else did. + if ( + this.normalize(aMessage.origin, this.userPrefixes) == + this.normalize(this._nickname) + ) { + // If we join, clear the participants list to avoid errors with + // repeated participants. + conversation.removeAllParticipants(); + conversation.left = false; + conversation.joining = false; + + // Update the channel name if it has improper capitalization. + if (channelName != conversation.name) { + conversation._name = channelName; + conversation.notifyObservers(null, "update-conv-title"); + } + + // If the user parted from this room earlier, confirm the rejoin. + if (conversation._rejoined) { + conversation.writeMessage( + aMessage.origin, + lazy._("message.rejoined"), + { + system: true, + } + ); + delete conversation._rejoined; + } + + // Ensure chatRoomFields information is available for reconnection. + if (!conversation.chatRoomFields) { + this.WARN( + "Opening a MUC without storing its " + + "prplIChatRoomFieldValues first." + ); + conversation.chatRoomFields = + this.getChatRoomDefaultFieldValues(channelName); + } + } else { + // Don't worry about adding ourself, RPL_NAMREPLY takes care of that + // case. + conversation.getParticipant(aMessage.origin, true); + let msg = lazy._("message.join", aMessage.origin, aMessage.source); + conversation.writeMessage(aMessage.origin, msg, { + system: true, + noLinkification: true, + }); + } + } + // If the joiner is a buddy, mark as online. + let buddy = this.buddies.get(aMessage.origin); + if (buddy) { + buddy.setStatus(Ci.imIStatusInfo.STATUS_AVAILABLE, ""); + } + return true; + }, + KICK(aMessage) { + // KICK <channel> *( "," <channel> ) <user> *( "," <user> ) [<comment>] + let comment = aMessage.params.length == 3 ? aMessage.params[2] : null; + // Some servers (moznet) send the kicker as the comment. + if (comment == aMessage.origin) { + comment = null; + } + return leftRoom( + this, + aMessage.params[1].split(","), + aMessage.params[0].split(","), + aMessage.origin, + comment, + true + ); + }, + MODE(aMessage) { + // MODE <nickname> *( ( "+" / "-") *( "i" / "w" / "o" / "O" / "r" ) ) + // MODE <channel> *( ( "-" / "+" ) *<modes> *<modeparams> ) + if (this.isMUCName(aMessage.params[0])) { + // If the first parameter is a channel name, a channel/participant mode + // was updated. + this.getConversation(aMessage.params[0]).setMode( + aMessage.params[1], + aMessage.params.slice(2), + aMessage.origin + ); + + return true; + } + + // Otherwise the user's own mode is being returned to them. + return this.setUserMode( + aMessage.params[0], + aMessage.params[1], + aMessage.origin, + !this._userModeReceived + ); + }, + NICK(aMessage) { + // NICK <nickname> + this.changeBuddyNick(aMessage.origin, aMessage.params[0]); + return true; + }, + NOTICE(aMessage) { + // NOTICE <msgtarget> <text> + // If the message is from the server, don't show it unless the user wants + // to see it. + if (!this.connected || aMessage.origin == this._currentServerName) { + return serverMessage(this, aMessage); + } + return displayMessage(this, aMessage, { notification: true }); + }, + PART(aMessage) { + // PART <channel> *( "," <channel> ) [ <Part Message> ] + return leftRoom( + this, + [aMessage.origin], + aMessage.params[0].split(","), + aMessage.source, + aMessage.params.length == 2 ? aMessage.params[1] : null + ); + }, + PING(aMessage) { + // PING <server1> [ <server2> ] + // Keep the connection alive. + this.sendMessage("PONG", aMessage.params[0]); + return true; + }, + PONG(aMessage) { + // PONG <server> [ <server2> ] + let pongTime = aMessage.params[1]; + + // Ping to keep the connection alive. + if (pongTime.startsWith("_")) { + this._socket.cancelDisconnectTimer(); + return true; + } + // Otherwise, the ping was from a user command. + return this.handlePingReply(aMessage.origin, pongTime); + }, + PRIVMSG(aMessage) { + // PRIVMSG <msgtarget> <text to be sent> + // Display message in conversation + return displayMessage(this, aMessage); + }, + QUIT(aMessage) { + // QUIT [ < Quit Message> ] + // Some IRC servers automatically prefix a "Quit: " string. Remove the + // duplication and use a localized version. + let quitMsg = aMessage.params[0] || ""; + if (quitMsg.startsWith("Quit: ")) { + quitMsg = quitMsg.slice(6); // "Quit: ".length + } + // If a quit message was included, show it. + let nick = aMessage.origin; + let msg = lazy._( + "message.quit", + nick, + quitMsg.length ? lazy._("message.quit2", quitMsg) : "" + ); + // Loop over every conversation with the user and display that they quit. + this.conversations.forEach(conversation => { + if (conversation.isChat && conversation._participants.has(nick)) { + conversation.writeMessage(nick, msg, { system: true }); + conversation.removeParticipant(nick); + } + }); + + // Remove from the whois table. + this.removeBuddyInfo(nick); + + // If the leaver is a buddy, mark as offline. + let buddy = this.buddies.get(nick); + if (buddy) { + buddy.setStatus(Ci.imIStatusInfo.STATUS_OFFLINE, ""); + } + + // If we wanted this nickname, grab it. + if (nick == this._requestedNickname && nick != this._nickname) { + this.changeNick(this._requestedNickname); + clearTimeout(this._nickInUseTimeout); + delete this._nickInUseTimeout; + } + return true; + }, + SQUIT(aMessage) { + // <server> <comment> + return true; + }, + TOPIC(aMessage) { + // TOPIC <channel> [ <topic> ] + // Show topic as a message. + let conversation = this.getConversation(aMessage.params[0]); + let topic = aMessage.params[1]; + // Set the topic in the conversation and update the UI. + conversation.setTopic( + topic ? ctcpFormatToText(topic) : "", + aMessage.origin + ); + return true; + }, + "001": function (aMessage) { + // RPL_WELCOME + // Welcome to the Internet Relay Network <nick>!<user>@<host> + this._socket.resetPingTimer(); + // This seems a little strange, but we don't differentiate between a + // nickname and the servername since it can be ambiguous. + this._currentServerName = aMessage.origin; + + // Clear user mode. + this._modes = new Set(); + this._userModeReceived = false; + + // Check if autoUserMode is set in the account preferences. If it is set, + // then notify the server that the user wants a specific mode. + if (this.prefs.prefHasUserValue("autoUserMode")) { + this.sendMessage("MODE", [ + this._nickname, + this.getString("autoUserMode"), + ]); + } + + // Check if our nick has changed. + if (aMessage.params[0] != this._nickname) { + this.changeBuddyNick(this._nickname, aMessage.params[0]); + } + + // Request our own whois entry so we can set the prefix. + this.requestCurrentWhois(this._nickname); + + // If our status is Unavailable, tell the server. + if ( + this.imAccount.statusInfo.statusType < Ci.imIStatusInfo.STATUS_AVAILABLE + ) { + this.observe(null, "status-changed"); + } + + // Check if any of our buddies are online! + const kInitialIsOnDelay = 1000; + this._isOnTimer = setTimeout(this.sendIsOn.bind(this), kInitialIsOnDelay); + + // If we didn't handle all the CAPs we added, something is wrong. + if (this._requestedCAPs.size) { + this.ERROR( + "Connected without removing CAPs: " + [...this._requestedCAPs] + ); + } + + // Done! + this.reportConnected(); + return serverMessage(this, aMessage); + }, + "002": function (aMessage) { + // RPL_YOURHOST + // Your host is <servername>, running version <ver> + return serverMessage(this, aMessage); + }, + "003": function (aMessage) { + // RPL_CREATED + // This server was created <date> + // TODO parse this date and keep it for some reason? Do we care? + return serverMessage(this, aMessage); + }, + "004": function (aMessage) { + // RPL_MYINFO + // <servername> <version> <available user modes> <available channel modes> + // TODO parse the available modes, let the UI respond and inform the user + return serverMessage(this, aMessage); + }, + "005": function (aMessage) { + // RPL_BOUNCE + // Try server <server name>, port <port number> + return serverMessage(this, aMessage); + }, + + /* + * Handle response to TRACE message + */ + 200(aMessage) { + // RPL_TRACELINK + // Link <version & debug level> <destination> <next server> + // V<protocol version> <link updateime in seconds> <backstream sendq> + // <upstream sendq> + return serverMessage(this, aMessage); + }, + 201(aMessage) { + // RPL_TRACECONNECTING + // Try. <class> <server> + return serverMessage(this, aMessage); + }, + 202(aMessage) { + // RPL_TRACEHANDSHAKE + // H.S. <class> <server> + return serverMessage(this, aMessage); + }, + 203(aMessage) { + // RPL_TRACEUNKNOWN + // ???? <class> [<client IP address in dot form>] + return serverMessage(this, aMessage); + }, + 204(aMessage) { + // RPL_TRACEOPERATOR + // Oper <class> <nick> + return serverMessage(this, aMessage); + }, + 205(aMessage) { + // RPL_TRACEUSER + // User <class> <nick> + return serverMessage(this, aMessage); + }, + 206(aMessage) { + // RPL_TRACESERVER + // Serv <class> <int>S <int>C <server> <nick!user|*!*>@<host|server> + // V<protocol version> + return serverMessage(this, aMessage); + }, + 207(aMessage) { + // RPL_TRACESERVICE + // Service <class> <name> <type> <active type> + return serverMessage(this, aMessage); + }, + 208(aMessage) { + // RPL_TRACENEWTYPE + // <newtype> 0 <client name> + return serverMessage(this, aMessage); + }, + 209(aMessage) { + // RPL_TRACECLASS + // Class <class> <count> + return serverMessage(this, aMessage); + }, + 210(aMessage) { + // RPL_TRACERECONNECTION + // Unused. + return serverMessage(this, aMessage); + }, + + /* + * Handle stats messages. + **/ + 211(aMessage) { + // RPL_STATSLINKINFO + // <linkname> <sendq> <sent messages> <sent Kbytes> <received messages> + // <received Kbytes> <time open> + return serverMessage(this, aMessage); + }, + 212(aMessage) { + // RPL_STATSCOMMAND + // <command> <count> <byte count> <remote count> + return serverMessage(this, aMessage); + }, + 213(aMessage) { + // RPL_STATSCLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 214(aMessage) { + // RPL_STATSNLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 215(aMessage) { + // RPL_STATSILINE + // Non-generic + return serverMessage(this, aMessage); + }, + 216(aMessage) { + // RPL_STATSKLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 217(aMessage) { + // RPL_STATSQLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 218(aMessage) { + // RPL_STATSYLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 219(aMessage) { + // RPL_ENDOFSTATS + // <stats letter> :End of STATS report + return serverMessage(this, aMessage); + }, + + 221(aMessage) { + // RPL_UMODEIS + // <user mode string> + return this.setUserMode( + aMessage.params[0], + aMessage.params[1], + aMessage.origin, + true + ); + }, + + /* + * Services + */ + 231(aMessage) { + // RPL_SERVICEINFO + // Non-generic + return serverMessage(this, aMessage); + }, + 232(aMessage) { + // RPL_ENDOFSERVICES + // Non-generic + return serverMessage(this, aMessage); + }, + 233(aMessage) { + // RPL_SERVICE + // Non-generic + return serverMessage(this, aMessage); + }, + + /* + * Server + */ + 234(aMessage) { + // RPL_SERVLIST + // <name> <server> <mask> <type> <hopcount> <info> + return serverMessage(this, aMessage); + }, + 235(aMessage) { + // RPL_SERVLISTEND + // <mask> <type> :End of service listing + return serverMessage(this, aMessage, true); + }, + + /* + * Stats + * TODO some of these have real information we could try to parse. + */ + 240(aMessage) { + // RPL_STATSVLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 241(aMessage) { + // RPL_STATSLLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 242(aMessage) { + // RPL_STATSUPTIME + // :Server Up %d days %d:%02d:%02d + return serverMessage(this, aMessage); + }, + 243(aMessage) { + // RPL_STATSOLINE + // O <hostmask> * <name> + return serverMessage(this, aMessage); + }, + 244(aMessage) { + // RPL_STATSHLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 245(aMessage) { + // RPL_STATSSLINE + // Non-generic + // Note that this is given as 244 in RFC 2812, this seems to be incorrect. + return serverMessage(this, aMessage); + }, + 246(aMessage) { + // RPL_STATSPING + // Non-generic + return serverMessage(this, aMessage); + }, + 247(aMessage) { + // RPL_STATSBLINE + // Non-generic + return serverMessage(this, aMessage); + }, + 250(aMessage) { + // RPL_STATSDLINE + // Non-generic + return serverMessage(this, aMessage); + }, + + /* + * LUSER messages + */ + 251(aMessage) { + // RPL_LUSERCLIENT + // :There are <integer> users and <integer> services on <integer> servers + return serverMessage(this, aMessage); + }, + 252(aMessage) { + // RPL_LUSEROP, 0 if not sent + // <integer> :operator(s) online + return serverMessage(this, aMessage); + }, + 253(aMessage) { + // RPL_LUSERUNKNOWN, 0 if not sent + // <integer> :unknown connection(s) + return serverMessage(this, aMessage); + }, + 254(aMessage) { + // RPL_LUSERCHANNELS, 0 if not sent + // <integer> :channels formed + return serverMessage(this, aMessage); + }, + 255(aMessage) { + // RPL_LUSERME + // :I have <integer> clients and <integer> servers + return serverMessage(this, aMessage); + }, + + /* + * ADMIN messages + */ + 256(aMessage) { + // RPL_ADMINME + // <server> :Administrative info + return serverMessage(this, aMessage); + }, + 257(aMessage) { + // RPL_ADMINLOC1 + // :<admin info> + // City, state & country + return serverMessage(this, aMessage); + }, + 258(aMessage) { + // RPL_ADMINLOC2 + // :<admin info> + // Institution details + return serverMessage(this, aMessage); + }, + 259(aMessage) { + // RPL_ADMINEMAIL + // :<admin info> + // TODO We could parse this for a contact email. + return serverMessage(this, aMessage); + }, + + /* + * TRACELOG + */ + 261(aMessage) { + // RPL_TRACELOG + // File <logfile> <debug level> + return serverMessage(this, aMessage); + }, + 262(aMessage) { + // RPL_TRACEEND + // <server name> <version & debug level> :End of TRACE + return serverMessage(this, aMessage, true); + }, + + /* + * Try again. + */ + 263(aMessage) { + // RPL_TRYAGAIN + // <command> :Please wait a while and try again. + if (aMessage.params[1] == "LIST" && this._pendingList) { + // We may receive this from servers which rate-limit LIST if the + // server believes us to be asking for LIST data too soon after the + // previous request. + // Tidy up as we won't be receiving any more channels. + this._sendRemainingRoomInfo(); + // Fake the last LIST time so that we may try again in one hour. + const kHour = 60 * 60 * 1000; + this._lastListTime = Date.now() - kListRefreshInterval + kHour; + return true; + } + return serverMessage(this, aMessage); + }, + + 265(aMessage) { + // nonstandard + // :Current Local Users: <integer> Max: <integer> + return serverMessage(this, aMessage); + }, + 266(aMessage) { + // nonstandard + // :Current Global Users: <integer> Max: <integer> + return serverMessage(this, aMessage); + }, + 300(aMessage) { + // RPL_NONE + // Non-generic + return serverMessage(this, aMessage); + }, + + /* + * Status messages + */ + 301(aMessage) { + // RPL_AWAY + // <nick> :<away message> + // TODO set user as away on buddy list / conversation lists + // TODO Display an autoResponse if this is after sending a private message + // If the conversation is waiting for a response, it's received one. + if (this.conversations.has(aMessage.params[1])) { + delete this.getConversation(aMessage.params[1])._pendingMessage; + } + return this.setWhois(aMessage.params[1], { away: aMessage.params[2] }); + }, + 302(aMessage) { + // RPL_USERHOST + // :*1<reply> *( " " <reply )" + // reply = nickname [ "*" ] "=" ( "+" / "-" ) hostname + // TODO Can tell op / away from this + return false; + }, + 303(aMessage) { + // RPL_ISON + // :*1<nick> *( " " <nick> )" + // Set the status of the buddies based the latest ISON response. + let receivedBuddyNames = []; + // The buddy names as returned by the server. + if (aMessage.params.length > 1) { + receivedBuddyNames = aMessage.params[1].trim().split(" "); + } + + // This was received in response to the last ISON message sent. + for (let buddyName of this.pendingIsOnQueue) { + // If the buddy name is in the list returned from the server, they're + // online. + let status = !receivedBuddyNames.includes(buddyName) + ? Ci.imIStatusInfo.STATUS_OFFLINE + : Ci.imIStatusInfo.STATUS_AVAILABLE; + + // Set the status with no status message, only if the buddy actually + // exists in the buddy list. + let buddy = this.buddies.get(buddyName); + if (buddy) { + buddy.setStatus(status, ""); + } + } + return true; + }, + 305(aMessage) { + // RPL_UNAWAY + // :You are no longer marked as being away + this.isAway = false; + return true; + }, + 306(aMessage) { + // RPL_NOWAWAY + // :You have been marked as away + this.isAway = true; + return true; + }, + + /* + * WHOIS + */ + 311(aMessage) { + // RPL_WHOISUSER + // <nick> <user> <host> * :<real name> + // <username>@<hostname> + let nick = aMessage.params[1]; + let source = aMessage.params[2] + "@" + aMessage.params[3]; + // Some servers obfuscate the host when sending messages. Therefore, + // we set the account prefix by using the host from this response. + // We store it separately to avoid glitches due to the whois entry + // being temporarily deleted during future updates of the entry. + if (this.normalize(nick) == this.normalize(this._nickname)) { + this.prefix = "!" + source; + } + return this.setWhois(nick, { + realname: aMessage.params[5], + connectedFrom: source, + }); + }, + 312(aMessage) { + // RPL_WHOISSERVER + // <nick> <server> :<server info> + return this.setWhois(aMessage.params[1], { + serverName: aMessage.params[2], + serverInfo: aMessage.params[3], + }); + }, + 313(aMessage) { + // RPL_WHOISOPERATOR + // <nick> :is an IRC operator + return this.setWhois(aMessage.params[1], { ircOp: true }); + }, + 314(aMessage) { + // RPL_WHOWASUSER + // <nick> <user> <host> * :<real name> + let source = aMessage.params[2] + "@" + aMessage.params[3]; + return this.setWhois(aMessage.params[1], { + offline: true, + realname: aMessage.params[5], + connectedFrom: source, + }); + }, + 315(aMessage) { + // RPL_ENDOFWHO + // <name> :End of WHO list + return false; + }, + 316(aMessage) { + // RPL_WHOISCHANOP + // Non-generic + return false; + }, + 317(aMessage) { + // RPL_WHOISIDLE + // <nick> <integer> :seconds idle + return this.setWhois(aMessage.params[1], { + lastActivity: parseInt(aMessage.params[2]), + }); + }, + 318(aMessage) { + // RPL_ENDOFWHOIS + // <nick> :End of WHOIS list + // We've received everything about WHOIS, tell the tooltip that is waiting + // for this information. + let nick = aMessage.params[1]; + + if (this.whoisInformation.has(nick)) { + this.notifyWhois(nick); + } else { + // If there is no whois information stored at this point, the nick + // is either offline or does not exist, so we run WHOWAS. + this.requestOfflineBuddyInfo(nick); + } + return true; + }, + 319(aMessage) { + // RPL_WHOISCHANNELS + // <nick> :*( ( "@" / "+" ) <channel> " " ) + return this.setWhois(aMessage.params[1], { + channels: aMessage.params[2], + }); + }, + + /* + * LIST + */ + 321(aMessage) { + // RPL_LISTSTART + // Channel :Users Name + // Obsolete. Not used. + return true; + }, + 322(aMessage) { + // RPL_LIST + // <channel> <# visible> :<topic> + let name = aMessage.params[1]; + let participantCount = aMessage.params[2]; + let topic = aMessage.params[3]; + // Some servers (e.g. Unreal) include the channel's modes before the topic. + // Omit this. + topic = topic.replace(/^\[\+[a-zA-Z]*\] /, ""); + // Force the allocation of a new copy of the string so as to prevent + // the JS engine from retaining the whole original socket string. See bug + // 1058584. This hack can be removed when bug 1058653 is fixed. + topic = topic ? topic.normalize() : ""; + + this._channelList.set(name, { topic, participantCount }); + this._currentBatch.push(name); + // Give callbacks a batch of channels of length _channelsPerBatch. + if (this._currentBatch.length == this._channelsPerBatch) { + for (let callback of this._roomInfoCallbacks) { + callback.onRoomInfoAvailable(this._currentBatch, false); + } + this._currentBatch = []; + } + return true; + }, + 323(aMessage) { + // RPL_LISTEND + // :End of LIST + this._sendRemainingRoomInfo(); + return true; + }, + + /* + * Channel functions + */ + 324(aMessage) { + // RPL_CHANNELMODEIS + // <channel> <mode> <mode params> + this.getConversation(aMessage.params[1]).setMode( + aMessage.params[2], + aMessage.params.slice(3), + aMessage.origin + ); + + return true; + }, + 325(aMessage) { + // RPL_UNIQOPIS + // <channel> <nickname> + // TODO parse this and have the UI respond accordingly. + return false; + }, + 331(aMessage) { + // RPL_NOTOPIC + // <channel> :No topic is set + let conversation = this.getConversation(aMessage.params[1]); + // Clear the topic. + conversation.setTopic(""); + return true; + }, + 332(aMessage) { + // RPL_TOPIC + // <channel> :<topic> + // Update the topic UI + let conversation = this.getConversation(aMessage.params[1]); + let topic = aMessage.params[2]; + conversation.setTopic(topic ? ctcpFormatToText(topic) : ""); + return true; + }, + 333(aMessage) { + // nonstandard + // <channel> <nickname> <time> + return true; + }, + + /* + * Invitations + */ + 341(aMessage) { + // RPL_INVITING + // <channel> <nick> + // Note that servers reply with parameters in the reverse order from the + // above (which is as specified by RFC 2812). + this.getConversation(aMessage.params[2]).writeMessage( + aMessage.origin, + lazy._("message.invited", aMessage.params[1], aMessage.params[2]), + { system: true } + ); + return true; + }, + 342(aMessage) { + // RPL_SUMMONING + // <user> :Summoning user to IRC + return writeMessage( + this, + aMessage, + lazy._("message.summoned", aMessage.params[0]) + ); + }, + 346(aMessage) { + // RPL_INVITELIST + // <channel> <invitemask> + // TODO what do we do? + return false; + }, + 347(aMessage) { + // RPL_ENDOFINVITELIST + // <channel> :End of channel invite list + // TODO what do we do? + return false; + }, + 348(aMessage) { + // RPL_EXCEPTLIST + // <channel> <exceptionmask> + // TODO what do we do? + return false; + }, + 349(aMessage) { + // RPL_ENDOFEXCEPTIONLIST + // <channel> :End of channel exception list + // TODO update UI? + return false; + }, + + /* + * Version + */ + 351(aMessage) { + // RPL_VERSION + // <version>.<debuglevel> <server> :<comments> + return serverMessage(this, aMessage); + }, + + /* + * WHO + */ + 352(aMessage) { + // RPL_WHOREPLY + // <channel> <user> <host> <server> <nick> ( "H" / "G" ) ["*"] [ ("@" / "+" ) ] :<hopcount> <real name> + // TODO parse and display this? + return false; + }, + + /* + * NAMREPLY + */ + 353(aMessage) { + // RPL_NAMREPLY + // <target> ( "=" / "*" / "@" ) <channel> :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> ) + let conversation = this.getConversation(aMessage.params[2]); + // Keep if this is secret (@), private (*) or public (=). + conversation.setModesFromRestriction(aMessage.params[1]); + // Add the participants. + let newParticipants = []; + aMessage.params[3] + .trim() + .split(" ") + .forEach(aNick => + newParticipants.push(conversation.getParticipant(aNick, false)) + ); + conversation.notifyObservers( + new nsSimpleEnumerator(newParticipants), + "chat-buddy-add" + ); + return true; + }, + + 361(aMessage) { + // RPL_KILLDONE + // Non-generic + // TODO What is this? + return false; + }, + 362(aMessage) { + // RPL_CLOSING + // Non-generic + // TODO What is this? + return false; + }, + 363(aMessage) { + // RPL_CLOSEEND + // Non-generic + // TODO What is this? + return false; + }, + + /* + * Links. + */ + 364(aMessage) { + // RPL_LINKS + // <mask> <server> :<hopcount> <server info> + return serverMessage(this, aMessage); + }, + 365(aMessage) { + // RPL_ENDOFLINKS + // <mask> :End of LINKS list + return true; + }, + + /* + * Names + */ + 366(aMessage) { + // RPL_ENDOFNAMES + // <target> <channel> :End of NAMES list + // All participants have already been added by the 353 handler. + + // This assumes that this is the last message received when joining a + // channel, so a few "clean up" tasks are done here. + let conversation = this.getConversation(aMessage.params[1]); + + // Update the topic as we may have added the participant for + // the user after the mode message was handled, and so + // topicSettable may have changed. + conversation.notifyObservers(this, "chat-update-topic"); + + // If we haven't received the MODE yet, request it. + if (!conversation._receivedInitialMode) { + this.sendMessage("MODE", aMessage.params[1]); + } + + return true; + }, + /* + * End of a bunch of lists + */ + 367(aMessage) { + // RPL_BANLIST + // <channel> <banmask> + let conv = this.getConversation(aMessage.params[1]); + if (!conv.banMasks.includes(aMessage.params[2])) { + conv.banMasks.push(aMessage.params[2]); + } + return true; + }, + 368(aMessage) { + // RPL_ENDOFBANLIST + // <channel> :End of channel ban list + let conv = this.getConversation(aMessage.params[1]); + let msg; + if (conv.banMasks.length) { + msg = [lazy._("message.banMasks", aMessage.params[1])] + .concat(conv.banMasks) + .join("\n"); + } else { + msg = lazy._("message.noBanMasks", aMessage.params[1]); + } + conv.writeMessage(aMessage.origin, msg, { system: true }); + return true; + }, + 369(aMessage) { + // RPL_ENDOFWHOWAS + // <nick> :End of WHOWAS + // We've received everything about WHOWAS, tell the tooltip that is waiting + // for this information. + this.notifyWhois(aMessage.params[1]); + return true; + }, + + /* + * Server info + */ + 371(aMessage) { + // RPL_INFO + // :<string> + return serverMessage(this, aMessage); + }, + 372(aMessage) { + // RPL_MOTD + // :- <text> + return addMotd(this, aMessage); + }, + 373(aMessage) { + // RPL_INFOSTART + // Non-generic + // This is unnecessary and servers just send RPL_INFO. + return true; + }, + 374(aMessage) { + // RPL_ENDOFINFO + // :End of INFO list + return true; + }, + 375(aMessage) { + // RPL_MOTDSTART + // :- <server> Message of the day - + return addMotd(this, aMessage); + }, + 376(aMessage) { + // RPL_ENDOFMOTD + // :End of MOTD command + // Show the MOTD if the user wants to see server messages or if + // RPL_WELCOME has not been received since some servers (e.g. irc.ppy.sh) + // use this as a CAPTCHA like mechanism before login can occur. + if (this._showServerTab || !this.connected) { + writeMessage(this, aMessage, this._motd.join("\n"), "incoming"); + } + // No reason to keep the MOTD in memory. + delete this._motd; + // Clear the MOTD timer. + clearTimeout(this._motdTimer); + delete this._motdTimer; + + return true; + }, + + /* + * OPER + */ + 381(aMessage) { + // RPL_YOUREOPER + // :You are now an IRC operator + // TODO update UI accordingly to show oper status + return serverMessage(this, aMessage); + }, + 382(aMessage) { + // RPL_REHASHING + // <config file> :Rehashing + return serverMessage(this, aMessage); + }, + 383(aMessage) { + // RPL_YOURESERVICE + // You are service <servicename> + this.WARN('Received "You are a service" message.'); + return true; + }, + + /* + * Info + */ + 384(aMessage) { + // RPL_MYPORTIS + // Non-generic + // TODO Parse and display? + return false; + }, + 391(aMessage) { + // RPL_TIME + // <server> :<string showing server's local time> + + let msg = lazy._("ctcp.time", aMessage.params[1], aMessage.params[2]); + // Show the date returned from the server, note that this doesn't use + // the serverMessage function: since this is in response to a command, it + // should always be shown. + return writeMessage(this, aMessage, msg, "system"); + }, + 392(aMessage) { + // RPL_USERSSTART + // :UserID Terminal Host + // TODO + return false; + }, + 393(aMessage) { + // RPL_USERS + // :<username> <ttyline> <hostname> + // TODO store into buddy list? List out? + return false; + }, + 394(aMessage) { + // RPL_ENDOFUSERS + // :End of users + // TODO Notify observers of the buddy list? + return false; + }, + 395(aMessage) { + // RPL_NOUSERS + // :Nobody logged in + // TODO clear buddy list? + return false; + }, + + // Error messages, Implement Section 5.2 of RFC 2812 + 401(aMessage) { + // ERR_NOSUCHNICK + // <nickname> :No such nick/channel + // Can arise in response to /mode, /invite, /kill, /msg, /whois. + // TODO Handled in the conversation for /whois and /mgs so far. + let msgId = + "error.noSuch" + + (this.isMUCName(aMessage.params[1]) ? "Channel" : "Nick"); + if (this.conversations.has(aMessage.params[1])) { + // If the conversation exists and we just sent a message from it, then + // notify that the user is offline. + if (this.getConversation(aMessage.params[1])._pendingMessage) { + conversationErrorMessage(this, aMessage, msgId); + } + } + + return serverErrorMessage( + this, + aMessage, + lazy._(msgId, aMessage.params[1]) + ); + }, + 402(aMessage) { + // ERR_NOSUCHSERVER + // <server name> :No such server + // TODO Parse & display an error to the user. + return false; + }, + 403(aMessage) { + // ERR_NOSUCHCHANNEL + // <channel name> :No such channel + return conversationErrorMessage( + this, + aMessage, + "error.noChannel", + true, + false + ); + }, + 404(aMessage) { + // ERR_CANNOTSENDTOCHAN + // <channel name> :Cannot send to channel + // Notify the user that they can't send to that channel. + return conversationErrorMessage( + this, + aMessage, + "error.cannotSendToChannel" + ); + }, + 405(aMessage) { + // ERR_TOOMANYCHANNELS + // <channel name> :You have joined too many channels + return conversationErrorMessage( + this, + aMessage, + "error.tooManyChannels", + true + ); + }, + 406(aMessage) { + // ERR_WASNOSUCHNICK + // <nickname> :There was no such nickname + // Can arise in response to WHOWAS. + return serverErrorMessage( + this, + aMessage, + lazy._("error.wasNoSuchNick", aMessage.params[1]) + ); + }, + 407(aMessage) { + // ERR_TOOMANYTARGETS + // <target> :<error code> recipients. <abort message> + return conversationErrorMessage( + this, + aMessage, + "error.nonUniqueTarget", + false, + false + ); + }, + 408(aMessage) { + // ERR_NOSUCHSERVICE + // <service name> :No such service + // TODO + return false; + }, + 409(aMessage) { + // ERR_NOORIGIN + // :No origin specified + // TODO failed PING/PONG message, this should never occur? + return false; + }, + 411(aMessage) { + // ERR_NORECIPIENT + // :No recipient given (<command>) + // If this happens a real error with the protocol occurred. + this.ERROR("ERR_NORECIPIENT: No recipient given for PRIVMSG."); + return true; + }, + 412(aMessage) { + // ERR_NOTEXTTOSEND + // :No text to send + // If this happens a real error with the protocol occurred: we should + // always block the user from sending empty messages. + this.ERROR("ERR_NOTEXTTOSEND: No text to send for PRIVMSG."); + return true; + }, + 413(aMessage) { + // ERR_NOTOPLEVEL + // <mask> :No toplevel domain specified + // If this response is received, a real error occurred in the protocol. + this.ERROR("ERR_NOTOPLEVEL: Toplevel domain not specified."); + return true; + }, + 414(aMessage) { + // ERR_WILDTOPLEVEL + // <mask> :Wildcard in toplevel domain + // If this response is received, a real error occurred in the protocol. + this.ERROR("ERR_WILDTOPLEVEL: Wildcard toplevel domain specified."); + return true; + }, + 415(aMessage) { + // ERR_BADMASK + // <mask> :Bad Server/host mask + // If this response is received, a real error occurred in the protocol. + this.ERROR("ERR_BADMASK: Bad server/host mask specified."); + return true; + }, + 421(aMessage) { + // ERR_UNKNOWNCOMMAND + // <command> :Unknown command + // TODO This shouldn't occur. + return false; + }, + 422(aMessage) { + // ERR_NOMOTD + // :MOTD File is missing + // No message of the day to display. + return true; + }, + 423(aMessage) { + // ERR_NOADMININFO + // <server> :No administrative info available + // TODO + return false; + }, + 424(aMessage) { + // ERR_FILEERROR + // :File error doing <file op> on <file> + // TODO + return false; + }, + 431(aMessage) { + // ERR_NONICKNAMEGIVEN + // :No nickname given + // TODO + return false; + }, + 432(aMessage) { + // ERR_ERRONEUSNICKNAME + // <nick> :Erroneous nickname + let msg = lazy._("error.erroneousNickname", this._requestedNickname); + serverErrorMessage(this, aMessage, msg); + if (this._requestedNickname == this._accountNickname) { + // The account has been set up with an illegal nickname. + this.ERROR( + "Erroneous nickname " + + this._requestedNickname + + ": " + + aMessage.params.slice(1).join(" ") + ); + this.gotDisconnected(Ci.prplIAccount.ERROR_INVALID_USERNAME, msg); + } else { + // Reset original nickname to the account nickname in case of + // later reconnections. + this._requestedNickname = this._accountNickname; + } + return true; + }, + 433(aMessage) { + // ERR_NICKNAMEINUSE + // <nick> :Nickname is already in use + // Try to get the desired nick back in 2.5 minutes if this happens when + // connecting, in case it was just due to the user's nick not having + // timed out yet on the server. + if (this.connecting && aMessage.params[1] == this._requestedNickname) { + this._nickInUseTimeout = setTimeout(() => { + this.changeNick(this._requestedNickname); + delete this._nickInUseTimeout; + }, 150000); + } + return this.tryNewNick(aMessage.params[1]); + }, + 436(aMessage) { + // ERR_NICKCOLLISION + // <nick> :Nickname collision KILL from <user>@<host> + return this.tryNewNick(aMessage.params[1]); + }, + 437(aMessage) { + // ERR_UNAVAILRESOURCE + // <nick/channel> :Nick/channel is temporarily unavailable + return conversationErrorMessage( + this, + aMessage, + "error.unavailable", + true + ); + }, + 441(aMessage) { + // ERR_USERNOTINCHANNEL + // <nick> <channel> :They aren't on that channel + // TODO + return false; + }, + 442(aMessage) { + // ERR_NOTONCHANNEL + // <channel> :You're not on that channel + this.ERROR( + "A command affecting " + + aMessage.params[1] + + " failed because you aren't in that channel." + ); + return true; + }, + 443(aMessage) { + // ERR_USERONCHANNEL + // <user> <channel> :is already on channel + this.getConversation(aMessage.params[2]).writeMessage( + aMessage.origin, + lazy._( + "message.alreadyInChannel", + aMessage.params[1], + aMessage.params[2] + ), + { system: true } + ); + return true; + }, + 444(aMessage) { + // ERR_NOLOGIN + // <user> :User not logged in + // TODO + return false; + }, + 445(aMessage) { + // ERR_SUMMONDISABLED + // :SUMMON has been disabled + // TODO keep track of this and disable UI associated? + return false; + }, + 446(aMessage) { + // ERR_USERSDISABLED + // :USERS has been disabled + // TODO Disabled all buddy list etc. + return false; + }, + 451(aMessage) { + // ERR_NOTREGISTERED + // :You have not registered + // If the server doesn't understand CAP it might return this error. + if (aMessage.params[0] == "CAP") { + this.LOG("Server doesn't support CAP."); + return true; + } + // TODO + return false; + }, + 461(aMessage) { + // ERR_NEEDMOREPARAMS + // <command> :Not enough parameters + + if (!this.connected) { + // The account has been set up with an illegal username. + this.ERROR("Erroneous username: " + this.username); + this.gotDisconnected( + Ci.prplIAccount.ERROR_INVALID_USERNAME, + lazy._("connection.error.invalidUsername", this.user) + ); + return true; + } + + return false; + }, + 462(aMessage) { + // ERR_ALREADYREGISTERED + // :Unauthorized command (already registered) + // TODO + return false; + }, + 463(aMessage) { + // ERR_NOPERMFORHOST + // :Your host isn't among the privileged + // TODO + return false; + }, + 464(aMessage) { + // ERR_PASSWDMISMATCH + // :Password incorrect + this.gotDisconnected( + Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, + lazy._("connection.error.invalidPassword") + ); + return true; + }, + 465(aMessage) { + // ERR_YOUREBANEDCREEP + // :You are banned from this server + serverErrorMessage(this, aMessage, lazy._("error.banned")); + this.gotDisconnected( + Ci.prplIAccount.ERROR_OTHER_ERROR, + lazy._("error.banned") + ); // Notify account manager. + return true; + }, + 466(aMessage) { + // ERR_YOUWILLBEBANNED + return serverErrorMessage(this, aMessage, lazy._("error.bannedSoon")); + }, + 467(aMessage) { + // ERR_KEYSET + // <channel> :Channel key already set + // TODO + return false; + }, + 471(aMessage) { + // ERR_CHANNELISFULL + // <channel> :Cannot join channel (+l) + return conversationErrorMessage( + this, + aMessage, + "error.channelFull", + true + ); + }, + 472(aMessage) { + // ERR_UNKNOWNMODE + // <char> :is unknown mode char to me for <channel> + // TODO + return false; + }, + 473(aMessage) { + // ERR_INVITEONLYCHAN + // <channel> :Cannot join channel (+i) + return conversationErrorMessage( + this, + aMessage, + "error.inviteOnly", + true, + false + ); + }, + 474(aMessage) { + // ERR_BANNEDFROMCHAN + // <channel> :Cannot join channel (+b) + return conversationErrorMessage( + this, + aMessage, + "error.channelBanned", + true, + false + ); + }, + 475(aMessage) { + // ERR_BADCHANNELKEY + // <channel> :Cannot join channel (+k) + return conversationErrorMessage( + this, + aMessage, + "error.wrongKey", + true, + false + ); + }, + 476(aMessage) { + // ERR_BADCHANMASK + // <channel> :Bad Channel Mask + // TODO + return false; + }, + 477(aMessage) { + // ERR_NOCHANMODES + // <channel> :Channel doesn't support modes + // TODO + return false; + }, + 478(aMessage) { + // ERR_BANLISTFULL + // <channel> <char> :Channel list is full + // TODO + return false; + }, + 481(aMessage) { + // ERR_NOPRIVILEGES + // :Permission Denied- You're not an IRC operator + // TODO ask to auth? + return false; + }, + 482(aMessage) { + // ERR_CHANOPRIVSNEEDED + // <channel> :You're not channel operator + return conversationErrorMessage(this, aMessage, "error.notChannelOp"); + }, + 483(aMessage) { + // ERR_CANTKILLSERVER + // :You can't kill a server! + // TODO Display error? + return false; + }, + 484(aMessage) { + // ERR_RESTRICTED + // :Your connection is restricted! + // Indicates user mode +r + // TODO + return false; + }, + 485(aMessage) { + // ERR_UNIQOPPRIVSNEEDED + // :You're not the original channel operator + // TODO ask to auth? + return false; + }, + 491(aMessage) { + // ERR_NOOPERHOST + // :No O-lines for your host + // TODO + return false; + }, + 492(aMessage) { + // ERR_NOSERVICEHOST + // Non-generic + // TODO + return false; + }, + 501(aMessage) { + // ERR_UMODEUNKNOWNFLAGS + // :Unknown MODE flag + return serverErrorMessage( + this, + aMessage, + lazy._("error.unknownMode", aMessage.params[1]) + ); + }, + 502(aMessage) { + // ERR_USERSDONTMATCH + // :Cannot change mode for other users + return serverErrorMessage(this, aMessage, lazy._("error.mode.wrongUser")); + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircCAP.sys.mjs b/comm/chat/protocols/irc/ircCAP.sys.mjs new file mode 100644 index 0000000000..3bcff48d8b --- /dev/null +++ b/comm/chat/protocols/irc/ircCAP.sys.mjs @@ -0,0 +1,170 @@ +/* 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/. */ + +/* + * This implements the IRC Client Capabilities sub-protocol. + * Client Capab Proposal + * http://www.leeh.co.uk/ircd/client-cap.txt + * RFC Drafts: IRC Client Capabilities + * http://tools.ietf.org/html/draft-baudis-irc-capab-00 + * http://tools.ietf.org/html/draft-mitchell-irc-capabilities-01 + * IRCv3 + * https://ircv3.net/specs/core/capability-negotiation.html + * + * Note that this doesn't include any implementation as these RFCs do not even + * include example parameters. + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +/* + * Parses a CAP message of the form: + * CAP [*|<user>] <subcommand> [*] [<parameters>] + * The cap field is added to the message and it has the following fields: + * subcommand + * parameters A list of capabilities. + */ +export function capMessage(aMessage, aAccount) { + // The CAP parameters are space separated as the last parameter. + let parameters = aMessage.params.slice(-1)[0].trim().split(" "); + // The subcommand is the second parameter...although sometimes it's the first + // parameter. + aMessage.cap = { + subcommand: aMessage.params[aMessage.params.length >= 3 ? 1 : 0], + }; + + const messages = parameters.map(function (aParameter) { + // Clone the original object. + let message = Object.assign({}, aMessage); + message.cap = Object.assign({}, aMessage.cap); + + // If there's a modifier...pull it off. (This is pretty much unused, but we + // have to pull it off for backward compatibility.) + if ("-=~".includes(aParameter[0])) { + message.cap.modifier = aParameter[0]; + aParameter = aParameter.substr(1); + } else { + message.cap.modifier = undefined; + } + + // CAP v3.2 capability value + if (aParameter.includes("=")) { + let paramParts = aParameter.split("="); + aParameter = paramParts[0]; + // The value itself may contain an = sign, join the rest of the parts back together. + message.cap.value = paramParts.slice(1).join("="); + } + + // The names are case insensitive, arbitrarily choose lowercase. + message.cap.parameter = aParameter.toLowerCase(); + message.cap.disable = message.cap.modifier == "-"; + message.cap.sticky = message.cap.modifier == "="; + message.cap.ack = message.cap.modifier == "~"; + + return message; + }); + + // Queue up messages if the server is indicating multiple lines of caps to list. + if ( + (aMessage.cap.subcommand === "LS" || aMessage.cap.subcommand === "LIST") && + aMessage.params.length == 4 + ) { + aAccount._queuedCAPs = aAccount._queuedCAPs.concat(messages); + return []; + } + + const retMessages = aAccount._queuedCAPs.concat(messages); + aAccount._queuedCAPs.length = 0; + return retMessages; +} + +export var ircCAP = { + name: "Client Capabilities", + // Slightly above default RFC 2812 priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + isEnabled: () => true, + + commands: { + CAP(message, ircHandlers) { + // [* | <nick>] <subcommand> :<parameters> + let messages = capMessage(message, this); + + for (const capCommandMessage of messages) { + if ( + capCommandMessage.cap.subcommand === "LS" || + capCommandMessage.cap.subcommand === "NEW" + ) { + this._availableCAPs.add(capCommandMessage.cap.parameter); + } else if (capCommandMessage.cap.subcommand === "ACK") { + this._activeCAPs.add(capCommandMessage.cap.parameter); + } else if (capCommandMessage.cap.subcommand === "DEL") { + this._availableCAPs.delete(capCommandMessage.cap.parameter); + this._activeCAPs.delete(capCommandMessage.cap.parameter); + } + } + + messages = messages.filter( + aMessage => !ircHandlers.handleCAPMessage(this, aMessage) + ); + if (messages.length) { + // Display the list of unhandled CAP messages. + let unhandledMessages = messages + .map(aMsg => aMsg.cap.parameter) + .join(" "); + this.LOG( + "Unhandled CAP messages: " + + unhandledMessages + + "\nRaw message: " + + message.rawMessage + ); + } + + // If no CAP handlers were added, just tell the server we're done. + if ( + message.cap.subcommand == "LS" && + !this._requestedCAPs.size && + !this._queuedCAPs.length + ) { + this.sendMessage("CAP", "END"); + this._negotiatedCAPs = true; + } + return true; + }, + + 410(aMessage) { + // ERR_INVALIDCAPCMD + // <unrecognized subcommand> :Invalid CAP subcommand + this.WARN("Invalid subcommand: " + aMessage.params[1]); + return true; + }, + }, +}; + +export var capNotify = { + name: "Client Capabilities", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + // This is implicitly enabled as part of CAP v3.2, so always enable it. + isEnabled: () => true, + + commands: { + "cap-notify": function (aMessage) { + // This negotiation is entirely optional. cap-notify may thus never be formally registered. + if ( + aMessage.cap.subcommand === "LS" || + aMessage.cap.subcommand === "NEW" + ) { + this.addCAP("cap-notify"); + this.sendMessage("CAP", ["REQ", "cap-notify"]); + } else if ( + aMessage.cap.subcommand === "ACK" || + aMessage.cap.subcommand === "NAK" + ) { + this.removeCAP("cap-notify"); + } else { + return false; + } + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircCTCP.sys.mjs b/comm/chat/protocols/irc/ircCTCP.sys.mjs new file mode 100644 index 0000000000..475f1f8a8d --- /dev/null +++ b/comm/chat/protocols/irc/ircCTCP.sys.mjs @@ -0,0 +1,291 @@ +/* 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/. */ + +/** + * This implements the Client-to-Client Protocol (CTCP), a subprotocol of IRC. + * REVISED AND UPDATED CTCP SPECIFICATION + * http://www.alien.net.au/irc/ctcp.txt + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; +import { displayMessage } from "resource:///modules/ircUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); + +// Split into a CTCP message which is a single command and a single parameter: +// <command> " " <parameter> +// The high level dequote is to unescape \001 in the message content. +export function CTCPMessage(aMessage, aRawCTCPMessage) { + let message = Object.assign({}, aMessage); + message.ctcp = {}; + message.ctcp.rawMessage = aRawCTCPMessage; + + // High/CTCP level dequote: replace the quote char \134 followed by a or \134 + // with \001 or \134, respectively. Any other character after \134 is replaced + // with itself. + let dequotedCTCPMessage = message.ctcp.rawMessage.replace( + /\\(.|$)/g, + aStr => { + if (aStr[1]) { + return aStr[1] == "a" ? "\x01" : aStr[1]; + } + return ""; + } + ); + + let separator = dequotedCTCPMessage.indexOf(" "); + // If there's no space, then only a command is given. + // Do not capitalize the command, case sensitive + if (separator == -1) { + message.ctcp.command = dequotedCTCPMessage; + message.ctcp.param = ""; + } else { + message.ctcp.command = dequotedCTCPMessage.slice(0, separator); + message.ctcp.param = dequotedCTCPMessage.slice(separator + 1); + } + return message; +} + +// This is the CTCP handler for IRC protocol, it will call each CTCP handler. +export var ircCTCP = { + name: "CTCP", + // Slightly above default RFC 2812 priority. + priority: ircHandlerPriorities.HIGH_PRIORITY, + isEnabled: () => true, + + // CTCP uses only PRIVMSG and NOTICE commands. + commands: { + PRIVMSG: ctcpHandleMessage, + NOTICE: ctcpHandleMessage, + }, +}; + +// Parse the message and call all CTCP handlers on the message. +function ctcpHandleMessage(message, ircHandlers) { + // If there are no CTCP handlers, then don't parse the CTCP message. + if (!ircHandlers.hasCTCPHandlers) { + return false; + } + + // The raw CTCP message is in the last parameter of the IRC message. + let rawCTCPParam = message.params.slice(-1)[0]; + + // Split the raw message into the multiple CTCP messages and pull out the + // command and parameters. + let ctcpMessages = []; + let otherMessage = rawCTCPParam.replace( + // eslint-disable-next-line no-control-regex + /\x01([^\x01]*)\x01/g, + function (aMatch, aMsg) { + if (aMsg) { + ctcpMessages.push(new CTCPMessage(message, aMsg)); + } + return ""; + } + ); + + // If no CTCP messages were found, return false. + if (!ctcpMessages.length) { + return false; + } + + // If there's some message left, send it back through the IRC handlers after + // stripping out the CTCP information. I highly doubt this will ever happen, + // but just in case. ;) + if (otherMessage) { + message.params.pop(); + message.params.push(otherMessage); + ircHandlers.handleMessage(this, message); + } + + // Loop over each raw CTCP message. + for (let message of ctcpMessages) { + if (!ircHandlers.handleCTCPMessage(this, message)) { + this.WARN( + "Unhandled CTCP message: " + + message.ctcp.rawMessage + + "\nin IRC message: " + + message.rawMessage + ); + // For unhandled CTCP message, respond with a NOTICE ERRMSG that echoes + // back the original command. + this.sendCTCPMessage(message.origin, true, "ERRMSG", [ + message.ctcp.rawMessage, + ":Unhandled CTCP command", + ]); + } + } + + // We have handled this message as much as we can. + return true; +} + +// This is the the basic CTCP protocol. +export var ctcpBase = { + // Parameters + name: "CTCP", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + // These represent CTCP commands. + commands: { + ACTION(aMessage) { + // ACTION <text> + // Display message in conversation + return displayMessage( + this, + aMessage, + { action: true }, + aMessage.ctcp.param + ); + }, + + // Used when an error needs to be replied with. + ERRMSG(aMessage) { + this.WARN( + aMessage.origin + + " failed to handle CTCP message: " + + aMessage.ctcp.param + ); + return true; + }, + + // This is commented out since CLIENTINFO automatically returns the + // supported CTCP parameters and this is not supported. + + // Returns the user's full name, and idle time. + // "FINGER": function(aMessage) { return false; }, + + // Dynamic master index of what a client knows. + CLIENTINFO(message, ircHandlers) { + if (message.command == "PRIVMSG") { + // Received a CLIENTINFO request, respond with the support CTCP + // messages. + let info = new Set(); + for (let handler of ircHandlers._ctcpHandlers) { + for (let command in handler.commands) { + info.add(command); + } + } + + let supportedCtcp = [...info].join(" "); + this.LOG( + "Reporting support for the following CTCP messages: " + supportedCtcp + ); + this.sendCTCPMessage(message.origin, true, "CLIENTINFO", supportedCtcp); + } else { + // Received a CLIENTINFO response, store the information for future + // use. + let info = message.ctcp.param.split(" "); + this.setWhois(message.origin, { clientInfo: info }); + } + return true; + }, + + // Used to measure the delay of the IRC network between clients. + PING(aMessage) { + // PING timestamp + if (aMessage.command == "PRIVMSG") { + // Received PING request, send PING response. + this.LOG( + "Received PING request from " + + aMessage.origin + + '. Sending PING response: "' + + aMessage.ctcp.param + + '".' + ); + this.sendCTCPMessage( + aMessage.origin, + true, + "PING", + aMessage.ctcp.param + ); + return true; + } + return this.handlePingReply(aMessage.origin, aMessage.ctcp.param); + }, + + // These are commented out since CLIENTINFO automatically returns the + // supported CTCP parameters and this is not supported. + + // An encryption protocol between clients without any known reference. + // "SED": function(aMessage) { return false; }, + + // Where to obtain a copy of a client. + // "SOURCE": function(aMessage) { return false; }, + + // Gets the local date and time from other clients. + TIME(aMessage) { + if (aMessage.command == "PRIVMSG") { + // TIME + // Received a TIME request, send a human readable response. + let now = new Date().toString(); + this.LOG( + "Received TIME request from " + + aMessage.origin + + '. Sending TIME response: "' + + now + + '".' + ); + this.sendCTCPMessage(aMessage.origin, true, "TIME", ":" + now); + } else { + // TIME :<human-readable-time-string> + // Received a TIME reply, display it. + // Remove the : prefix, if it exists and display the result. + let time = aMessage.ctcp.param.slice(aMessage.ctcp.param[0] == ":"); + this.getConversation(aMessage.origin).writeMessage( + aMessage.origin, + lazy._("ctcp.time", aMessage.origin, time), + { system: true, tags: aMessage.tags } + ); + } + return true; + }, + + // This is commented out since CLIENTINFO automatically returns the + // supported CTCP parameters and this is not supported. + + // A string set by the user (never the client coder) + // "USERINFO": function(aMessage) { return false; }, + + // The version and type of the client. + VERSION(aMessage) { + if (aMessage.command == "PRIVMSG") { + // VERSION + // Received VERSION request, send VERSION response. + let version = Services.appinfo.name + " " + Services.appinfo.version; + this.LOG( + "Received VERSION request from " + + aMessage.origin + + '. Sending VERSION response: "' + + version + + '".' + ); + this.sendCTCPMessage(aMessage.origin, true, "VERSION", version); + } else if (aMessage.command == "NOTICE" && aMessage.ctcp.param.length) { + // VERSION #:#:# + // Received VERSION response, display to the user. + let response = lazy._( + "ctcp.version", + aMessage.origin, + aMessage.ctcp.param + ); + this.getConversation(aMessage.origin).writeMessage( + aMessage.origin, + response, + { + system: true, + tags: aMessage.tags, + } + ); + } + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircCommands.sys.mjs b/comm/chat/protocols/irc/ircCommands.sys.mjs new file mode 100644 index 0000000000..78d984b179 --- /dev/null +++ b/comm/chat/protocols/irc/ircCommands.sys.mjs @@ -0,0 +1,599 @@ +/* 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/. */ + +// This is to be exported directly onto the IRC prplIProtocol object, directly +// implementing the commands field before we register them. +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); + +// Shortcut to get the JavaScript conversation object. +function getConv(aConv) { + return aConv.wrappedJSObject; +} + +// Shortcut to get the JavaScript account object. +function getAccount(aConv) { + return getConv(aConv)._account; +} + +// Trim leading and trailing spaces and split a string by any type of space. +function splitInput(aString) { + return aString.trim().split(/\s+/); +} + +// Kick a user from a channel +// aMsg is <user> [comment] +function kickCommand(aMsg, aConv) { + if (!aMsg.length) { + return false; + } + + let params = [aConv.name]; + let offset = aMsg.indexOf(" "); + if (offset != -1) { + params.push(aMsg.slice(0, offset)); + params.push(aMsg.slice(offset + 1)); + } else { + params.push(aMsg); + } + + getAccount(aConv).sendMessage("KICK", params); + return true; +} + +// Send a message directly to a user. +// aMsg is <user> <message> +// aReturnedConv is optional and returns the resulting conversation. +function messageCommand(aMsg, aConv, aReturnedConv, aIsNotice = false) { + // Trim leading whitespace. + aMsg = aMsg.trimLeft(); + + let nickname = aMsg; + let message = ""; + + let sep = aMsg.indexOf(" "); + if (sep > -1) { + nickname = aMsg.slice(0, sep); + message = aMsg.slice(sep + 1); + } + if (!nickname.length) { + return false; + } + + let conv = getAccount(aConv).getConversation(nickname); + if (aReturnedConv) { + aReturnedConv.value = conv; + } + + if (!message.length) { + return true; + } + + return privateMessage(aConv, message, nickname, aReturnedConv, aIsNotice); +} + +// aAdd is true to add a mode, false to remove a mode. +function setMode(aNickname, aConv, aMode, aAdd) { + if (!aNickname.length) { + return false; + } + + // Change the mode for each nick, as separator by spaces. + return splitInput(aNickname).every(aNick => + simpleCommand(aConv, "MODE", [ + aConv.name, + (aAdd ? "+" : "-") + aMode, + aNick, + ]) + ); +} + +function actionCommand(aMsg, aConv) { + // Don't try to send an empty action. + if (!aMsg || !aMsg.trim().length) { + return false; + } + + let conv = getConv(aConv); + + conv.sendMsg(aMsg, true); + + return true; +} + +// This will open the conversation, and send and display the text. +// aReturnedConv is optional and returns the resulting conversation. +// aIsNotice is optional and sends a NOTICE instead of a PRIVMSG. +function privateMessage(aConv, aMsg, aNickname, aReturnedConv, aIsNotice) { + if (!aMsg.length) { + return false; + } + + let conv = getAccount(aConv).getConversation(aNickname); + conv.sendMsg(aMsg, false, aIsNotice); + if (aReturnedConv) { + aReturnedConv.value = conv; + } + return true; +} + +// This will send a command to the server, if no parameters are given, it is +// assumed that the command takes no parameters. aParams can be either a single +// string or an array of parameters. +function simpleCommand(aConv, aCommand, aParams) { + if (!aParams || !aParams.length) { + getAccount(aConv).sendMessage(aCommand); + } else { + getAccount(aConv).sendMessage(aCommand, aParams); + } + return true; +} + +// Sends a CTCP message to aTarget using the CTCP command aCommand and aMsg as +// a CTCP parameter. +function ctcpCommand(aConv, aTarget, aCommand, aParams) { + return getAccount(aConv).sendCTCPMessage(aTarget, false, aCommand, aParams); +} + +// Replace the command name in the help string so translators do not attempt to +// translate it. +export var commands = [ + { + name: "action", + get helpString() { + return lazy._("command.action", "action"); + }, + run: actionCommand, + }, + { + name: "ban", + get helpString() { + return lazy._("command.ban", "ban"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: (aMsg, aConv) => setMode(aMsg, aConv, "b", true), + }, + { + name: "ctcp", + get helpString() { + return lazy._("command.ctcp", "ctcp"); + }, + run(aMsg, aConv) { + let separator = aMsg.indexOf(" "); + // Ensure we have two non-empty parameters. + if (separator < 1 || separator + 1 == aMsg.length) { + return false; + } + + // The first word is used as the target, the rest is used as CTCP command + // and parameters. + ctcpCommand(aConv, aMsg.slice(0, separator), aMsg.slice(separator + 1)); + return true; + }, + }, + { + name: "chanserv", + get helpString() { + return lazy._("command.chanserv", "chanserv"); + }, + run: (aMsg, aConv) => privateMessage(aConv, aMsg, "ChanServ"), + }, + { + name: "deop", + get helpString() { + return lazy._("command.deop", "deop"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: (aMsg, aConv) => setMode(aMsg, aConv, "o", false), + }, + { + name: "devoice", + get helpString() { + return lazy._("command.devoice", "devoice"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: (aMsg, aConv) => setMode(aMsg, aConv, "v", false), + }, + { + name: "invite", + get helpString() { + return lazy._("command.invite2", "invite"); + }, + run(aMsg, aConv) { + let params = splitInput(aMsg); + + // Try to find one, and only one, channel in the list of parameters. + let channel; + let account = getAccount(aConv); + // Find the first param that could be a channel name. + for (let i = 0; i < params.length; ++i) { + if (account.isMUCName(params[i])) { + // If channel is set, two channel names have been found. + if (channel) { + return false; + } + + // Remove that parameter and store it. + channel = params.splice(i, 1)[0]; + } + } + + // If no parameters or only a channel are given. + if (!params[0].length) { + return false; + } + + // Default to using the current conversation as the channel to invite to. + if (!channel) { + channel = aConv.name; + } + + params.forEach(p => simpleCommand(aConv, "INVITE", [p, channel])); + return true; + }, + }, + { + name: "join", + get helpString() { + return lazy._("command.join", "join"); + }, + run(aMsg, aConv, aReturnedConv) { + let params = aMsg.trim().split(/,\s*/); + let account = getAccount(aConv); + let conv; + if (!params[0]) { + conv = getConv(aConv); + if (!conv.isChat || !conv.left) { + return false; + } + // Rejoin the current channel. If the channel was explicitly parted + // by the user, chatRoomFields will have been deleted. + // Otherwise, make use of it (e.g. if the user was kicked). + if (conv.chatRoomFields) { + account.joinChat(conv.chatRoomFields); + return true; + } + params = [conv.name]; + } + params.forEach(function (joinParam) { + if (joinParam) { + let chatroomfields = account.getChatRoomDefaultFieldValues(joinParam); + conv = account.joinChat(chatroomfields); + } + }); + if (aReturnedConv) { + aReturnedConv.value = conv; + } + return true; + }, + }, + { + name: "kick", + get helpString() { + return lazy._("command.kick", "kick"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: kickCommand, + }, + { + name: "list", + get helpString() { + return lazy._("command.list", "list"); + }, + run(aMsg, aConv, aReturnedConv) { + let account = getAccount(aConv); + let serverName = account._currentServerName; + let serverConv = account.getConversation(serverName); + let pendingChats = []; + account.requestRoomInfo( + { + onRoomInfoAvailable(aRooms) { + if (!pendingChats.length) { + (async function () { + // pendingChats has no rooms added yet, so ensure we wait a tick. + let t = 0; + const kMaxBlockTime = 10; // Unblock every 10ms. + do { + if (Date.now() > t) { + await new Promise(resolve => + Services.tm.dispatchToMainThread(resolve) + ); + t = Date.now() + kMaxBlockTime; + } + let name = pendingChats.pop(); + let roomInfo = account.getRoomInfo(name); + serverConv.writeMessage( + serverName, + name + + " (" + + roomInfo.participantCount + + ") " + + roomInfo.topic, + { + incoming: true, + noLog: true, + } + ); + } while (pendingChats.length); + })(); + } + pendingChats = pendingChats.concat(aRooms); + }, + }, + true + ); + if (aReturnedConv) { + aReturnedConv.value = serverConv; + } + return true; + }, + }, + { + name: "me", + get helpString() { + return lazy._("command.action", "me"); + }, + run: actionCommand, + }, + { + name: "memoserv", + get helpString() { + return lazy._("command.memoserv", "memoserv"); + }, + run: (aMsg, aConv) => privateMessage(aConv, aMsg, "MemoServ"), + }, + { + name: "mode", + get helpString() { + return ( + lazy._("command.modeUser2", "mode") + + "\n" + + lazy._("command.modeChannel2", "mode") + ); + }, + run(aMsg, aConv) { + function isMode(aString) { + return "+-".includes(aString[0]); + } + let params = splitInput(aMsg); + let channel = aConv.name; + // Add the channel as parameter when the target is not specified. i.e + // 1. message is empty. + // 2. the first parameter is a mode. + if (!aMsg) { + params = [channel]; + } else if (isMode(params[0])) { + params.unshift(channel); + } + + // Ensure mode string to be the second argument. + if (params.length >= 2 && !isMode(params[1])) { + return false; + } + + return simpleCommand(aConv, "MODE", params); + }, + }, + { + name: "msg", + get helpString() { + return lazy._("command.msg", "msg"); + }, + run: messageCommand, + }, + { + name: "nick", + get helpString() { + return lazy._("command.nick", "nick"); + }, + run(aMsg, aConv) { + let newNick = aMsg.trim(); + // eslint-disable-next-line mozilla/use-includes-instead-of-indexOf + if (newNick.indexOf(/\s+/) != -1) { + return false; + } + + let account = getAccount(aConv); + // The user wants to change their nick, so overwrite the account + // nickname for this session. + account._requestedNickname = newNick; + account.changeNick(newNick); + + return true; + }, + }, + { + name: "nickserv", + get helpString() { + return lazy._("command.nickserv", "nickserv"); + }, + run: (aMsg, aConv) => privateMessage(aConv, aMsg, "NickServ"), + }, + { + name: "notice", + get helpString() { + return lazy._("command.notice", "notice"); + }, + run: (aMsg, aConv, aReturnedConv) => + messageCommand(aMsg, aConv, aReturnedConv, true), + }, + { + name: "op", + get helpString() { + return lazy._("command.op", "op"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: (aMsg, aConv) => setMode(aMsg, aConv, "o", true), + }, + { + name: "operserv", + get helpString() { + return lazy._("command.operserv", "operserv"); + }, + run: (aMsg, aConv) => privateMessage(aConv, aMsg, "OperServ"), + }, + { + name: "part", + get helpString() { + return lazy._("command.part", "part"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + getConv(aConv).part(aMsg); + return true; + }, + }, + { + name: "ping", + get helpString() { + return lazy._("command.ping", "ping"); + }, + run(aMsg, aConv) { + // Send a ping to the entered nick using the current time (in + // milliseconds) as the param. If no nick is entered, ping the + // server. + if (aMsg && aMsg.trim().length) { + ctcpCommand(aConv, aMsg, "PING", Date.now()); + } else { + getAccount(aConv).sendMessage("PING", Date.now()); + } + + return true; + }, + }, + { + name: "query", + get helpString() { + return lazy._("command.msg", "query"); + }, + run: messageCommand, + }, + { + name: "quit", + get helpString() { + return lazy._("command.quit", "quit"); + }, + run(aMsg, aConv) { + let account = getAccount(aConv); + account.disconnect(aMsg); + // While prpls shouldn't usually touch imAccount, this disconnection + // is an action the user requested via the UI. Without this call, + // the imAccount would immediately reconnect the account. + account.imAccount.disconnect(); + return true; + }, + }, + { + name: "quote", + get helpString() { + return lazy._("command.quote", "quote"); + }, + run(aMsg, aConv) { + if (!aMsg.length) { + return false; + } + + getAccount(aConv).sendRawMessage(aMsg); + return true; + }, + }, + { + name: "remove", + get helpString() { + return lazy._("command.kick", "remove"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: kickCommand, + }, + { + name: "time", + get helpString() { + return lazy._("command.time", "time"); + }, + run(aMsg, aConv) { + // Send a time command to the entered nick using the current time (in + // milliseconds) as the param. If no nick is entered, get the current + // server time. + if (aMsg && aMsg.trim().length) { + ctcpCommand(aConv, aMsg, "TIME"); + } else { + getAccount(aConv).sendMessage("TIME"); + } + + return true; + }, + }, + { + name: "topic", + get helpString() { + return lazy._("command.topic", "topic"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run(aMsg, aConv) { + aConv.topic = aMsg; + return true; + }, + }, + { + name: "umode", + get helpString() { + return lazy._("command.umode", "umode"); + }, + run(aMsg, aConv) { + let params = aMsg ? splitInput(aMsg) : []; + params.unshift(getAccount(aConv)._nickname); + return simpleCommand(aConv, "MODE", params); + }, + }, + { + name: "version", + get helpString() { + return lazy._("command.version", "version"); + }, + run(aMsg, aConv) { + if (!aMsg || !aMsg.trim().length) { + return false; + } + ctcpCommand(aConv, aMsg, "VERSION"); + return true; + }, + }, + { + name: "voice", + get helpString() { + return lazy._("command.voice", "voice"); + }, + usageContext: Ci.imICommand.CMD_CONTEXT_CHAT, + run: (aMsg, aConv) => setMode(aMsg, aConv, "v", true), + }, + { + name: "whois", + get helpString() { + return lazy._("command.whois2", "whois"); + }, + run(aMsg, aConv) { + // Note that this will automatically run whowas if the nick is offline. + aMsg = aMsg.trim(); + // If multiple parameters are given, this is an error. + if (aMsg.includes(" ")) { + return false; + } + // If the user does not provide a nick, but is in a private conversation, + // assume the user is trying to whois the person they are talking to. + if (!aMsg) { + if (aConv.isChat) { + return false; + } + aMsg = aConv.name; + } + getConv(aConv).requestCurrentWhois(aMsg); + return true; + }, + }, +]; diff --git a/comm/chat/protocols/irc/ircDCC.sys.mjs b/comm/chat/protocols/irc/ircDCC.sys.mjs new file mode 100644 index 0000000000..afd88f52be --- /dev/null +++ b/comm/chat/protocols/irc/ircDCC.sys.mjs @@ -0,0 +1,66 @@ +/* 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/. */ + +/* + * This contains an implementation of the Direct Client-to-Client (DCC) + * protocol. + * A description of the DCC protocol + * http://www.irchelp.org/irchelp/rfc/dccspec.html + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +// Parse a CTCP message into a DCC message. A DCC message is a CTCP message of +// the form: +// DCC <type> <argument> <address> <port> [<size>] +function DCCMessage(aMessage, aAccount) { + let message = aMessage; + let params = message.ctcp.param.split(" "); + if (params.length < 4) { + aAccount.ERROR("Not enough DCC parameters:\n" + JSON.stringify(aMessage)); + return null; + } + + try { + // Address, port and size should be treated as unsigned long, unsigned short + // and unsigned long, respectively. The protocol is designed to handle + // further arguments, if necessary. + message.ctcp.dcc = { + type: params[0], + argument: params[1], + address: Number(params[2]), + port: Number(params[3]), + size: params.length == 5 ? Number(params[4]) : null, + furtherArguments: params.length > 5 ? params.slice(5) : [], + }; + } catch (e) { + aAccount.ERROR( + "Error parsing DCC parameters:\n" + JSON.stringify(aMessage) + ); + return null; + } + + return message; +} + +// This is the DCC handler for CTCP, it will call each DCC handler. +export var ctcpDCC = { + name: "DCC", + // Slightly above default CTCP priority. + priority: ircHandlerPriorities.HIGH_PRIORITY + 10, + isEnabled: () => true, + + commands: { + // Handle a DCC message by parsing the message and executing any handlers. + DCC(message, ircHandlers) { + // If there are no DCC handlers, then don't parse the DCC message. + if (!ircHandlers.hasDCCHandlers) { + return false; + } + + // Parse the message and attempt to handle it. + return ircHandlers.handleDCCMessage(this, DCCMessage(message, this)); + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircEchoMessage.sys.mjs b/comm/chat/protocols/irc/ircEchoMessage.sys.mjs new file mode 100644 index 0000000000..24a27be902 --- /dev/null +++ b/comm/chat/protocols/irc/ircEchoMessage.sys.mjs @@ -0,0 +1,41 @@ +/* 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/. */ + +/* + * This implements the echo-message capability for IRC. + * https://ircv3.net/specs/extensions/echo-message-3.2 + * + * When enabled, displaying of a sent messages is disabled (until it is received + * by the server and sent back to the sender). This helps to ensure the ordering + * of messages is consistent for all participants in a channel and also helps + * signify whether a message was properly sent to a channel during disconnect. + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +export var capEchoMessage = { + name: "echo-message CAP", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + commands: { + "echo-message": function (aMessage) { + if ( + aMessage.cap.subcommand === "LS" || + aMessage.cap.subcommand === "NEW" + ) { + this.addCAP("echo-message"); + this.sendMessage("CAP", ["REQ", "echo-message"]); + } else if ( + aMessage.cap.subcommand === "ACK" || + aMessage.cap.subcommand === "NAK" + ) { + this.removeCAP("echo-message"); + } else { + return false; + } + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs b/comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs new file mode 100644 index 0000000000..68d48d51b8 --- /dev/null +++ b/comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs @@ -0,0 +1,16 @@ +/* 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/. */ + +export const ircHandlerPriorities = { + // Some constant priorities. + get LOW_PRIORITY() { + return -100; + }, + get DEFAULT_PRIORITY() { + return 0; + }, + get HIGH_PRIORITY() { + return 100; + }, +}; diff --git a/comm/chat/protocols/irc/ircHandlers.sys.mjs b/comm/chat/protocols/irc/ircHandlers.sys.mjs new file mode 100644 index 0000000000..c461d158db --- /dev/null +++ b/comm/chat/protocols/irc/ircHandlers.sys.mjs @@ -0,0 +1,306 @@ +/* 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 { ircBase } from "resource:///modules/ircBase.sys.mjs"; +import { + ircISUPPORT, + isupportBase, +} from "resource:///modules/ircISUPPORT.sys.mjs"; +import { ircCAP, capNotify } from "resource:///modules/ircCAP.sys.mjs"; +import { ircCTCP, ctcpBase } from "resource:///modules/ircCTCP.sys.mjs"; +import { + ircServices, + servicesBase, +} from "resource:///modules/ircServices.sys.mjs"; +import { ctcpDCC } from "resource:///modules/ircDCC.sys.mjs"; +import { capEchoMessage } from "resource:///modules/ircEchoMessage.sys.mjs"; +import { + isupportNAMESX, + capMultiPrefix, +} from "resource:///modules/ircMultiPrefix.sys.mjs"; +import { ircNonStandard } from "resource:///modules/ircNonStandard.sys.mjs"; +import { + ircWATCH, + isupportWATCH, + ircMONITOR, + isupportMONITOR, +} from "resource:///modules/ircWatchMonitor.sys.mjs"; +import { ircSASL, capSASL } from "resource:///modules/ircSASL.sys.mjs"; +import { + capServerTime, + tagServerTime, +} from "resource:///modules/ircServerTime.sys.mjs"; + +export var ircHandlers = { + /* + * Object to hold the IRC handlers, each handler is an object that implements: + * name The display name of the handler. + * priority The priority of the handler (0 is default, positive is + * higher priority) + * isEnabled A function where 'this' is bound to the account object. This + * should reflect whether this handler should be used for this + * account. + * commands An object of commands, each command is a function which + * accepts a message object and has 'this' bound to the account + * object. It should return whether the message was successfully + * handler or not. + */ + _ircHandlers: [ + // High priority + ircCTCP, + ircServices, + // Default priority + 10 + ircCAP, + ircISUPPORT, + ircWATCH, + ircMONITOR, + // Default priority + 1 + ircNonStandard, + // Default priority + ircSASL, + ircBase, + ], + // Object to hold the ISUPPORT handlers, expects the same fields as + // _ircHandlers. + _isupportHandlers: [ + // Default priority + 10 + isupportNAMESX, + isupportWATCH, + isupportMONITOR, + // Default priority + isupportBase, + ], + // Object to hold the Client Capabilities handlers, expects the same fields as + // _ircHandlers. + _capHandlers: [ + // High priority + capMultiPrefix, + // Default priority + capNotify, + capEchoMessage, + capSASL, + capServerTime, + ], + // Object to hold the CTCP handlers, expects the same fields as _ircHandlers. + _ctcpHandlers: [ + // High priority + 10 + ctcpDCC, + // Default priority + ctcpBase, + ], + // Object to hold the DCC handlers, expects the same fields as _ircHandlers. + _dccHandlers: [], + // Object to hold the Services handlers, expects the same fields as + // _ircHandlers. + _servicesHandlers: [servicesBase], + // Object to hold irc message tag handlers, expects the same fields as + // _ircHandlers. + _tagHandlers: [tagServerTime], + + _registerHandler(aArray, aHandler) { + // Protect ourselves from adding broken handlers. + if (!("commands" in aHandler)) { + console.error( + new Error( + 'IRC handlers must have a "commands" property: ' + aHandler.name + ) + ); + return false; + } + if (!("isEnabled" in aHandler)) { + console.error( + new Error( + 'IRC handlers must have a "isEnabled" property: ' + aHandler.name + ) + ); + return false; + } + + aArray.push(aHandler); + aArray.sort((a, b) => b.priority - a.priority); + return true; + }, + + _unregisterHandler(aArray, aHandler) { + return aArray.filter(h => h.name != aHandler.name); + }, + + registerHandler(aHandler) { + return this._registerHandler(this._ircHandlers, aHandler); + }, + unregisterHandler(aHandler) { + this._ircHandlers = this._unregisterHandler(this._ircHandlers, aHandler); + }, + + registerISUPPORTHandler(aHandler) { + return this._registerHandler(this._isupportHandlers, aHandler); + }, + unregisterISUPPORTHandler(aHandler) { + this._isupportHandlers = this._unregisterHandler( + this._isupportHandlers, + aHandler + ); + }, + + registerCAPHandler(aHandler) { + return this._registerHandler(this._capHandlers, aHandler); + }, + unregisterCAPHandler(aHandler) { + this._capHandlers = this._unregisterHandler(this._capHandlers, aHandler); + }, + + registerCTCPHandler(aHandler) { + return this._registerHandler(this._ctcpHandlers, aHandler); + }, + unregisterCTCPHandler(aHandler) { + this._ctcpHandlers = this._unregisterHandler(this._ctcpHandlers, aHandler); + }, + + registerDCCHandler(aHandler) { + return this._registerHandler(this._dccHandlers, aHandler); + }, + unregisterDCCHandler(aHandler) { + this._dccHandlers = this._unregisterHandler(this._dccHandlers, aHandler); + }, + + registerServicesHandler(aHandler) { + return this._registerHandler(this._servicesHandlers, aHandler); + }, + unregisterServicesHandler(aHandler) { + this._servicesHandlers = this._unregisterHandler( + this._servicesHandlers, + aHandler + ); + }, + + registerTagHandler(aHandler) { + return this._registerHandler(this._tagHandlers, aHandler); + }, + unregisterTagHandler(aHandler) { + this._tagHandlers = this._unregisterHandler(this._tagHandlers, aHandler); + }, + + // Handle a message based on a set of handlers. + _handleMessage(aHandlers, aAccount, aMessage, aCommand) { + // Loop over each handler and run the command until one handles the message. + for (let handler of aHandlers) { + try { + // Attempt to execute the command, by checking if the handler has the + // command. + // Parse the command with the JavaScript account object as "this". + if ( + handler.isEnabled.call(aAccount) && + aCommand in handler.commands && + handler.commands[aCommand].call(aAccount, aMessage, ircHandlers) + ) { + return true; + } + } catch (e) { + // We want to catch an error here because one of our handlers are + // broken, if we don't catch the error, the whole IRC plug-in will die. + aAccount.ERROR( + "Error running command " + + aCommand + + " with handler " + + handler.name + + ":\n" + + JSON.stringify(aMessage), + e + ); + } + } + + return false; + }, + + handleMessage(aAccount, aMessage) { + return this._handleMessage( + this._ircHandlers, + aAccount, + aMessage, + aMessage.command.toUpperCase() + ); + }, + + handleISUPPORTMessage(aAccount, aMessage) { + return this._handleMessage( + this._isupportHandlers, + aAccount, + aMessage, + aMessage.isupport.parameter + ); + }, + + handleCAPMessage(aAccount, aMessage) { + return this._handleMessage( + this._capHandlers, + aAccount, + aMessage, + aMessage.cap.parameter + ); + }, + + // aMessage is a CTCP Message, which inherits from an IRC Message. + handleCTCPMessage(aAccount, aMessage) { + return this._handleMessage( + this._ctcpHandlers, + aAccount, + aMessage, + aMessage.ctcp.command + ); + }, + + // aMessage is a DCC Message, which inherits from a CTCP Message. + handleDCCMessage(aAccount, aMessage) { + return this._handleMessage( + this._dccHandlers, + aAccount, + aMessage, + aMessage.ctcp.dcc.type + ); + }, + + // aMessage is a Services Message. + handleServicesMessage(aAccount, aMessage) { + return this._handleMessage( + this._servicesHandlers, + aAccount, + aMessage, + aMessage.serviceName + ); + }, + + // aMessage is a Tag Message. + handleTag(aAccount, aMessage) { + return this._handleMessage( + this._tagHandlers, + aAccount, + aMessage, + aMessage.tagName + ); + }, + + // Checking if handlers exist. + get hasHandlers() { + return this._ircHandlers.length > 0; + }, + get hasISUPPORTHandlers() { + return this._isupportHandlers.length > 0; + }, + get hasCAPHandlers() { + return this._capHandlers.length > 0; + }, + get hasCTCPHandlers() { + return this._ctcpHandlers.length > 0; + }, + get hasDCCHandlers() { + return this._dccHandlers.length > 0; + }, + get hasServicesHandlers() { + return this._servicesHandlers.length > 0; + }, + get hasTagHandlers() { + return this._tagHandlers.length > 0; + }, +}; diff --git a/comm/chat/protocols/irc/ircISUPPORT.sys.mjs b/comm/chat/protocols/irc/ircISUPPORT.sys.mjs new file mode 100644 index 0000000000..9d2ce29afb --- /dev/null +++ b/comm/chat/protocols/irc/ircISUPPORT.sys.mjs @@ -0,0 +1,246 @@ +/* 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/. */ + +/* + * This implements the ISUPPORT parameters for the 005 numeric to allow a server + * to notify a client of what capabilities it supports. + * The 005 numeric + * http://www.irc.org/tech_docs/005.html + * RFC Drafts: IRC RPL_ISUPPORT Numeric Definition + * https://tools.ietf.org/html/draft-brocklesby-irc-isupport-03 + * https://tools.ietf.org/html/draft-hardy-irc-isupport-00 + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +/* + * Parses an ircMessage into an ISUPPORT message for each token of the form: + * <parameter>=<value> or -<value> + * The isupport field is added to the message and it has the following fields: + * parameter What is being configured by this ISUPPORT token. + * useDefault Whether this parameter should be reset to the default value, as + * defined by the RFC. + * value The new value for the parameter. + */ +function isupportMessage(aMessage) { + // Separate the ISUPPORT parameters. + let tokens = aMessage.params.slice(1, -1); + + let message = aMessage; + message.isupport = {}; + + return tokens.map(function (aToken) { + let newMessage = JSON.parse(JSON.stringify(message)); + newMessage.isupport.useDefault = aToken[0] == "-"; + let token = ( + newMessage.isupport.useDefault ? aToken.slice(1) : aToken + ).split("="); + newMessage.isupport.parameter = token[0]; + newMessage.isupport.value = token[1] || null; + return newMessage; + }); +} + +export var ircISUPPORT = { + name: "ISUPPORT", + // Slightly above default RFC 2812 priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + isEnabled: () => true, + + commands: { + // RPL_ISUPPORT + // [-]<parameter>[=<value>] :are supported by this server + "005": function (message, ircHandlers) { + let messages = isupportMessage(message); + + messages = messages.filter( + aMessage => !ircHandlers.handleISUPPORTMessage(this, aMessage) + ); + if (messages.length) { + // Display the list of unhandled ISUPPORT messages. + let unhandledMessages = messages + .map(aMsg => aMsg.isupport.parameter) + .join(" "); + this.LOG( + "Unhandled ISUPPORT messages: " + + unhandledMessages + + "\nRaw message: " + + message.rawMessage + ); + } + + return true; + }, + }, +}; + +function setSimpleNumber(aAccount, aField, aMessage, aDefaultValue) { + let value = aMessage.isupport.value ? Number(aMessage.isupport.value) : null; + aAccount[aField] = value && !isNaN(value) ? value : aDefaultValue; + return true; +} + +// Generates an expression to search for the ASCII range of a-b. +function generateNormalize(a, b) { + return new RegExp( + "[\\x" + a.toString(16) + "-\\x" + b.toString(16) + "]", + "g" + ); +} + +export var isupportBase = { + name: "ISUPPORT", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + commands: { + CASEMAPPING(aMessage) { + // CASEMAPPING=<mapping> + // Allows the server to specify which method it uses to compare equality + // of case-insensitive strings. + + // By default, use rfc1459 type case mapping. + let value = aMessage.isupport.useDefault + ? "rfc1493" + : aMessage.isupport.value; + + // Set the normalize function of the account to use the proper case + // mapping. + if (value == "ascii") { + // The ASCII characters 97 to 122 (decimal) are the lower-case + // characters of ASCII 65 to 90 (decimal). + this.normalizeExpression = generateNormalize(65, 90); + } else if (value == "rfc1493") { + // The ASCII characters 97 to 126 (decimal) are the lower-case + // characters of ASCII 65 to 94 (decimal). + this.normalizeExpression = generateNormalize(65, 94); + } else if (value == "strict-rfc1459") { + // The ASCII characters 97 to 125 (decimal) are the lower-case + // characters of ASCII 65 to 93 (decimal). + this.normalizeExpression = generateNormalize(65, 93); + } + return true; + }, + CHANLIMIT(aMessage) { + // CHANLIMIT=<prefix>:<number>[,<prefix>:<number>]* + // Note that each <prefix> can actually contain multiple prefixes, this + // means the sum of those prefixes is given. + this.maxChannels = {}; + + let pairs = aMessage.isupport.value.split(","); + for (let pair of pairs) { + let [prefix, num] = pair.split(":"); + this.maxChannels[prefix] = num; + } + return true; + }, + CHANMODES: aMessage => false, + CHANNELLEN(aMessage) { + // CHANNELLEN=<number> + // Default is from RFC 1493. + return setSimpleNumber(this, "maxChannelLength", aMessage, 200); + }, + CHANTYPES(aMessage) { + // CHANTYPES=[<channel prefix>]* + let value = aMessage.isupport.useDefault ? "#&" : aMessage.isupport.value; + this.channelPrefixes = value.split(""); + return true; + }, + EXCEPTS: aMessage => false, + IDCHAN: aMessage => false, + INVEX: aMessage => false, + KICKLEN(aMessage) { + // KICKLEN=<number> + // Default value is Infinity. + return setSimpleNumber(this, "maxKickLength", aMessage, Infinity); + }, + MAXLIST: aMessage => false, + MODES: aMessage => false, + NETWORK: aMessage => false, + NICKLEN(aMessage) { + // NICKLEN=<number> + // Default value is from RFC 1493. + return setSimpleNumber(this, "maxNicknameLength", aMessage, 9); + }, + PREFIX(aMessage) { + // PREFIX=[(<mode character>*)<prefix>*] + let value = aMessage.isupport.useDefault + ? "(ov)@+" + : aMessage.isupport.value; + + this.userPrefixToModeMap = {}; + // A null value specifier indicates that no prefixes are supported. + if (!value.length) { + return true; + } + + let matches = /\(([a-z]*)\)(.*)/i.exec(value); + if (!matches) { + // The pattern doesn't match. + this.WARN("Invalid PREFIX value: " + value); + return false; + } + if (matches[1].length != matches[2].length) { + this.WARN( + "Invalid PREFIX value, does not provide one-to-one mapping:" + value + ); + return false; + } + + for (let i = 0; i < matches[2].length; i++) { + this.userPrefixToModeMap[matches[2][i]] = matches[1][i]; + } + return true; + }, + // SAFELIST allows the client to request the server buffer LIST responses to + // avoid flooding the client. This is not an issue for us, so just ignore + // it. + SAFELIST: aMessage => true, + // SECURELIST tells us that the server won't send LIST data directly after + // connection. Unfortunately, the exact time the client has to wait is + // configurable, so we can't do anything with this information. + SECURELIST: aMessage => true, + STATUSMSG: aMessage => false, + STD(aMessage) { + // This was never updated as the RFC was never formalized. + if (aMessage.isupport.value != "rfcnnnn") { + this.WARN("Unknown ISUPPORT numeric form: " + aMessage.isupport.value); + } + return true; + }, + TARGMAX(aMessage) { + // TARGMAX=<command>:<max targets>[,<command>:<max targets>]* + if (aMessage.isupport.useDefault) { + this.maxTargets = 1; + return true; + } + + this.maxTargets = {}; + let commands = aMessage.isupport.value.split(","); + for (let i = 0; i < commands.length; i++) { + let [command, limitStr] = commands[i].split("="); + let limit = limitStr ? Number(limit) : Infinity; + if (isNaN(limit)) { + this.WARN("Invalid maximum number of targets: " + limitStr); + continue; + } + this.maxTargets[command] = limit; + } + return true; + }, + TOPICLEN(aMessage) { + // TOPICLEN=<number> + // Default value is Infinity. + return setSimpleNumber(this, "maxTopicLength", aMessage, Infinity); + }, + + // The following are considered "obsolete" by the RFC, but are still in use. + CHARSET: aMessage => false, + MAXBANS: aMessage => false, + MAXCHANNELS: aMessage => false, + MAXTARGETS(aMessage) { + return setSimpleNumber(this, "maxTargets", aMessage, 1); + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircMultiPrefix.sys.mjs b/comm/chat/protocols/irc/ircMultiPrefix.sys.mjs new file mode 100644 index 0000000000..abf9727981 --- /dev/null +++ b/comm/chat/protocols/irc/ircMultiPrefix.sys.mjs @@ -0,0 +1,60 @@ +/* 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/. */ + +/* + * This contains an implementation of the multi-prefix IRC extension. This fixes + * a protocol level bug where the following can happen: + * foo MODE +h + * foo MODE +o + * bar JOINs the channel (and receives @foo) + * foo MODE -o + * foo knows that it has mode +h, but bar does not know foo has +h set. + * + * https://docs.inspircd.org/2/modules/namesx/ + * https://ircv3.net/specs/extensions/multi-prefix-3.1 + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +export var isupportNAMESX = { + name: "ISUPPORT NAMESX", + // Slightly above default ISUPPORT priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + isEnabled: () => true, + + commands: { + NAMESX(aMessage) { + this.sendMessage("PROTOCTL", "NAMESX"); + return true; + }, + }, +}; + +export var capMultiPrefix = { + name: "CAP multi-prefix", + // Slightly above default ISUPPORT priority. + priority: ircHandlerPriorities.HIGH_PRIORITY, + isEnabled: () => true, + + commands: { + "multi-prefix": function (aMessage) { + // Request to use multi-prefix if it is supported. + if ( + aMessage.cap.subcommand === "LS" || + aMessage.cap.subcommand === "NEW" + ) { + this.addCAP("multi-prefix"); + this.sendMessage("CAP", ["REQ", "multi-prefix"]); + } else if ( + aMessage.cap.subcommand === "ACK" || + aMessage.cap.subcommand === "NAK" + ) { + this.removeCAP("multi-prefix"); + } else { + return false; + } + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircNonStandard.sys.mjs b/comm/chat/protocols/irc/ircNonStandard.sys.mjs new file mode 100644 index 0000000000..aeb373feb9 --- /dev/null +++ b/comm/chat/protocols/irc/ircNonStandard.sys.mjs @@ -0,0 +1,262 @@ +/* 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/. */ + +/* + * There are a variety of non-standard extensions to IRC that are implemented by + * different servers. This implementation is based on a combination of + * documentation and reverse engineering. Each handler must include a comment + * listing the known servers that support this extension. + * + * Resources for these commands include: + * https://github.com/atheme/charybdis/blob/master/include/numeric.h + * https://github.com/unrealircd/unrealircd/blob/unreal42/include/numeric.h + */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; +import { + conversationErrorMessage, + kListRefreshInterval, +} from "resource:///modules/ircUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); + +export var ircNonStandard = { + name: "Non-Standard IRC Extensions", + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 1, + isEnabled: () => true, + + commands: { + NOTICE(aMessage) { + // NOTICE <msgtarget> <text> + + if ( + aMessage.params[1].startsWith("*** You cannot list within the first") + ) { + // SECURELIST: "You cannot list within the first N seconds of connecting. + // Please try again later." This NOTICE will be followed by a 321/323 + // pair, but no list data. + // We fake the last LIST time so that we will retry LIST the next time + // the user requires it after the interval specified. + const kMinute = 60000; + let waitTime = aMessage.params[1].split(" ")[7] * 1000 || kMinute; + this._lastListTime = Date.now() + waitTime - kListRefreshInterval; + return true; + } + + // If the user is connected, fallback to normal processing, everything + // past this points deals with NOTICE messages that occur before 001 is + // received. + if (this.connected) { + return false; + } + + let target = aMessage.params[0].toLowerCase(); + + // If we receive a ZNC error message requesting a password, the + // serverPassword preference was not set by the user. Attempt to log into + // ZNC using the account password. + if ( + target == "auth" && + aMessage.params[1].startsWith("*** You need to send your password.") + ) { + if (this.imAccount.password) { + // Send the password now, if it is available. + this.shouldAuthenticate = false; + this.sendMessage( + "PASS", + this.imAccount.password, + "PASS <password not logged>" + ); + } else { + // Otherwise, put the account in an error state. + this.gotDisconnected( + Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, + lazy._("connection.error.passwordRequired") + ); + } + + // All done for ZNC. + return true; + } + + // Some servers, e.g. irc.umich.edu, use NOTICE during connection + // negotiation to give directions to users, these MUST be shown to the + // user. If the message starts with ***, we assume it is probably an AUTH + // message, which falls through to normal NOTICE processing. + // Note that if the user's nick is auth this COULD be a notice directed at + // them. For reference: moznet sends Auth (previously sent AUTH), freenode + // sends *. + let isAuth = target == "auth" && this._nickname.toLowerCase() != "auth"; + if (!aMessage.params[1].startsWith("***") && !isAuth) { + this.getConversation(aMessage.origin).writeMessage( + aMessage.origin, + aMessage.params[1], + { + incoming: true, + tags: aMessage.tags, + } + ); + return true; + } + + return false; + }, + + "042": function (aMessage) { + // RPL_YOURID (IRCnet) + // <nick> <id> :your unique ID + return true; + }, + + 307(aMessage) { + // TODO RPL_SUSERHOST (AustHex) + // TODO RPL_USERIP (Undernet) + // <user ips> + + // RPL_WHOISREGNICK (Unreal & Bahamut) + // <nick> :is a registered nick + if (aMessage.params.length == 3) { + return this.setWhois(aMessage.params[1], { registered: true }); + } + + return false; + }, + + 317(aMessage) { + // RPL_WHOISIDLE (Unreal & Charybdis) + // <nick> <integer> <integer> :seconds idle, signon time + // This is a non-standard extension to RPL_WHOISIDLE which includes the + // sign-on time. + if (aMessage.params.length == 5) { + this.setWhois(aMessage.params[1], { signonTime: aMessage.params[3] }); + } + + return false; + }, + + 328(aMessage) { + // RPL_CHANNEL_URL (Bahamut & Austhex) + // <channel> :<URL> + return true; + }, + + 329(aMessage) { + // RPL_CREATIONTIME (Bahamut & Unreal) + // <channel> <creation time> + return true; + }, + + 330(aMessage) { + // TODO RPL_WHOWAS_TIME + + // RPL_WHOISACCOUNT (Charybdis, ircu & Quakenet) + // <nick> <authname> :is logged in as + if (aMessage.params.length == 4) { + let [, nick, authname] = aMessage.params; + // If the authname differs from the nickname, add it to the WHOIS + // information; otherwise, ignore it. + if (this.normalize(nick) != this.normalize(authname)) { + this.setWhois(nick, { registeredAs: authname }); + } + } + return true; + }, + + 335(aMessage) { + // RPL_WHOISBOT (Unreal) + // <nick> :is a \002Bot\002 on <network> + return this.setWhois(aMessage.params[1], { bot: true }); + }, + + 338(aMessage) { + // RPL_CHANPASSOK + // RPL_WHOISACTUALLY (ircu, Bahamut, Charybdis) + // <nick> <user> <ip> :actually using host + return true; + }, + + 378(aMessage) { + // RPL_WHOISHOST (Unreal & Charybdis) + // <nick> :is connecting from <host> <ip> + let [host, ip] = aMessage.params[2].split(" ").slice(-2); + return this.setWhois(aMessage.params[1], { host, ip }); + }, + + 379(aMessage) { + // RPL_WHOISMODES (Unreal, Inspircd) + // <nick> :is using modes <modes> + // Sent in response to a WHOIS on the user. + return true; + }, + + 396(aMessage) { + // RPL_HOSTHIDDEN (Charybdis, Hybrid, ircu, etc.) + // RPL_VISIBLEHOST (Plexus) + // RPL_YOURDISPLAYEDHOST (Inspircd) + // <host> :is now your hidden host + + // This is the host that will be sent to other users. + this.prefix = "!" + aMessage.user + "@" + aMessage.params[1]; + return true; + }, + + 464(aMessage) { + // :Password required + // If we receive a ZNC error message requesting a password, eat it since + // a NOTICE AUTH will follow causing us to send the password. This numeric + // is, unfortunately, also sent if you give a wrong password. The + // parameter in that case is "Invalid Password". + return ( + aMessage.origin == "irc.znc.in" && + aMessage.params[1] == "Password required" + ); + }, + + 470(aMessage) { + // Channel forward (Unreal, inspircd) + // <requested channel> <redirect channel>: You may not join this channel, + // so you are automatically being transferred to the redirect channel. + // Join redirect channel so when the automatic join happens, we are + // not surprised. + this.joinChat(this.getChatRoomDefaultFieldValues(aMessage.params[2])); + // Mark requested channel as left and add a system message. + return conversationErrorMessage( + this, + aMessage, + "error.channelForward", + true, + false + ); + }, + + 499(aMessage) { + // ERR_CHANOWNPRIVNEEDED (Unreal) + // <channel> :You're not the channel owner (status +q is needed) + return conversationErrorMessage(this, aMessage, "error.notChannelOwner"); + }, + + 671(aMessage) { + // RPL_WHOISSECURE (Unreal & Charybdis) + // <nick> :is using a Secure connection + return this.setWhois(aMessage.params[1], { secure: true }); + }, + + 998(aMessage) { + // irc.umich.edu shows an ASCII captcha that must be typed in by the user. + this.getConversation(aMessage.origin).writeMessage( + aMessage.origin, + aMessage.params[1], + { + incoming: true, + noFormat: true, + } + ); + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircSASL.sys.mjs b/comm/chat/protocols/irc/ircSASL.sys.mjs new file mode 100644 index 0000000000..0708d0180a --- /dev/null +++ b/comm/chat/protocols/irc/ircSASL.sys.mjs @@ -0,0 +1,179 @@ +/* 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/. */ + +/* + * This implements SASL for IRC. + * https://raw.github.com/atheme/atheme/master/doc/SASL + * https://ircv3.net/specs/extensions/sasl-3.2 + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +export var ircSASL = { + name: "SASL AUTHENTICATE", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled() { + return this._activeCAPs.has("sasl"); + }, + + commands: { + AUTHENTICATE(aMessage) { + // Expect an empty response, if something different is received abort. + if (aMessage.params[0] != "+") { + this.sendMessage("AUTHENTICATE", "*"); + this.WARN( + "Aborting SASL authentication, unexpected message " + + "received:\n" + + aMessage.rawMessage + ); + return true; + } + + // An authentication identity, authorization identity and password are + // used, separated by null. + let data = [ + this._requestedNickname, + this._requestedNickname, + this.imAccount.password, + ].join("\0"); + // btoa for Unicode, see https://developer.mozilla.org/en-US/docs/DOM/window.btoa + let base64Data = btoa(unescape(encodeURIComponent(data))); + this.sendMessage( + "AUTHENTICATE", + base64Data, + "AUTHENTICATE <base64 encoded nick, user and password not logged>" + ); + return true; + }, + + 900(aMessage) { + // RPL_LOGGEDIN + // <nick>!<ident>@<host> <account> :You are now logged in as <user> + // Now logged in ("whether by SASL or otherwise"). + this.isAuthenticated = true; + return true; + }, + + 901(aMessage) { + // RPL_LOGGEDOUT + // The user's account name is unset (whether by SASL or otherwise). + this.isAuthenticated = false; + return true; + }, + + 902(aMessage) { + // ERR_NICKLOCKED + // Authentication failed because the account is currently locked out, + // held, or otherwise administratively made unavailable. + this.WARN( + "You must use a nick assigned to you. SASL authentication failed." + ); + this.removeCAP("sasl"); + return true; + }, + + 903(aMessage) { + // RPL_SASLSUCCESS + // Authentication was successful. + this.isAuthenticated = true; + this.LOG("SASL authentication successful."); + // We may receive this again while already connected if the user manually + // identifies with Nickserv. + if (!this.connected) { + this.removeCAP("sasl"); + } + return true; + }, + + 904(aMessage) { + // ERR_SASLFAIL + // Sent when the SASL authentication fails because of invalid credentials + // or other errors not explicitly mentioned by other numerics. + this.WARN("Authentication with SASL failed."); + this.removeCAP("sasl"); + return true; + }, + + 905(aMessage) { + // ERR_SASLTOOLONG + // Sent when credentials are valid, but the SASL authentication fails + // because the client-sent `AUTHENTICATE` command was too long. + this.ERROR("SASL: AUTHENTICATE command was too long."); + this.removeCAP("sasl"); + return true; + }, + + 906(aMessage) { + // ERR_SASLABORTED + // The client completed registration before SASL authentication completed, + // or because we sent `AUTHENTICATE` with `*` as the parameter. + // + // Freenode sends 906 in addition to 904, ignore 906 in this case. + if (this._requestedCAPs.has("sasl")) { + this.ERROR( + "Registration completed before SASL authentication completed." + ); + this.removeCAP("sasl"); + } + return true; + }, + + 907(aMessage) { + // ERR_SASLALREADY + // Response if client attempts to AUTHENTICATE after successful + // authentication. + this.ERROR("Attempting SASL authentication twice?!"); + this.removeCAP("sasl"); + return true; + }, + + 908(aMessage) { + // RPL_SASLMECHS + // <nick> <mechanisms> :are available SASL mechanisms + // List of SASL mechanisms supported by the server (or network, services). + // The numeric contains a comma-separated list of mechanisms. + return false; + }, + }, +}; + +export var capSASL = { + name: "SASL CAP", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + commands: { + sasl(aMessage) { + // Return early if we are already authenticated (can happen due to cap-notify) + if (this.isAuthenticated) { + return true; + } + + if ( + (aMessage.cap.subcommand === "LS" || + aMessage.cap.subcommand === "NEW") && + this.imAccount.password + ) { + if (aMessage.cap.value) { + const mechanisms = aMessage.cap.value.split(","); + // We only support the plain authentication mechanism for now, abort if it's not available. + if (!mechanisms.includes("PLAIN")) { + return true; + } + } + // If it supports SASL, let the server know we're requiring SASL. + this.addCAP("sasl"); + this.sendMessage("CAP", ["REQ", "sasl"]); + } else if (aMessage.cap.subcommand === "ACK") { + // The server acknowledges our choice to use SASL, send the first + // message. + this.sendMessage("AUTHENTICATE", "PLAIN"); + } else if (aMessage.cap.subcommand === "NAK") { + this.removeCAP("sasl"); + } + + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircServerTime.sys.mjs b/comm/chat/protocols/irc/ircServerTime.sys.mjs new file mode 100644 index 0000000000..14ce2436f3 --- /dev/null +++ b/comm/chat/protocols/irc/ircServerTime.sys.mjs @@ -0,0 +1,80 @@ +/* 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/. */ + +/* + * This implements server-time for IRC. + * https://ircv3.net/specs/extensions/server-time-3.2 + */ + +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +function handleServerTimeTag(aMsg) { + if (aMsg.tagValue) { + // Normalize leap seconds to the next second before it. + const time = aMsg.tagValue.replace(/60.\d{3}(?=Z$)/, "59.999"); + aMsg.message.time = Math.floor(Date.parse(time) / 1000); + aMsg.message.delayed = true; + } +} + +export var tagServerTime = { + name: "server-time Tags", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled() { + return ( + this._activeCAPs.has("server-time") || + this._activeCAPs.has("znc.in/server-time-iso") + ); + }, + + commands: { + time: handleServerTimeTag, + "znc.in/server-time-iso": handleServerTimeTag, + }, +}; + +export var capServerTime = { + name: "server-time CAP", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + commands: { + "server-time": function (aMessage) { + if ( + aMessage.cap.subcommand === "LS" || + aMessage.cap.subcommand === "NEW" + ) { + this.addCAP("server-time"); + this.sendMessage("CAP", ["REQ", "server-time"]); + } else if ( + aMessage.cap.subcommand === "ACK" || + aMessage.cap.subcommand === "NAK" + ) { + this.removeCAP("server-time"); + } else { + return false; + } + return true; + }, + "znc.in/server-time-iso": function (aMessage) { + // Only request legacy server time CAP if the standard one is not available. + if ( + (aMessage.cap.subcommand === "LS" || + aMessage.cap.subcommand === "NEW") && + !this._availableCAPs.has("server-time") + ) { + this.addCAP("znc.in/server-time-iso"); + this.sendMessage("CAP", ["REQ", "znc.in/server-time-iso"]); + } else if ( + aMessage.cap.subcommand === "ACK" || + aMessage.cap.subcommand === "NAK" + ) { + this.removeCAP("znc.in/server-time-iso"); + } else { + return false; + } + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircServices.sys.mjs b/comm/chat/protocols/irc/ircServices.sys.mjs new file mode 100644 index 0000000000..4f39bda237 --- /dev/null +++ b/comm/chat/protocols/irc/ircServices.sys.mjs @@ -0,0 +1,317 @@ +/* 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/. */ + +/* + * This attempts to handle dealing with IRC services, which are a diverse set of + * programs to automate and add features to IRCd. Often these services are seen + * with the names NickServ, ChanServ, OperServ and MemoServ; but other services + * do exist and are in use. + * + * Since the "protocol" behind services is really just text-based, human + * readable messages, attempt to parse them, but always fall back to just + * showing the message to the user if we're unsure what to do. + * + * Anope + * https://www.anope.org/docgen/1.8/ + */ + +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +/* + * If a service is found, an extra field (serviceName) is added with the + * "generic" service name (e.g. a bot which performs NickServ like functionality + * will be mapped to NickServ). + */ +function ServiceMessage(aAccount, aMessage) { + // This should be a property of the account or configurable somehow, it maps + // from server specific service names to our generic service names (e.g. if + // irc.foo.net has a service called bar, which acts as a NickServ, we would + // map "bar": "NickServ"). Note that the keys of this map should be + // normalized. + let nicknameToServiceName = { + chanserv: "ChanServ", + infoserv: "InfoServ", + nickserv: "NickServ", + saslserv: "SaslServ", + "freenode-connect": "freenode-connect", + }; + + let nickname = aAccount.normalize(aMessage.origin); + if (nicknameToServiceName.hasOwnProperty(nickname)) { + aMessage.serviceName = nicknameToServiceName[nickname]; + } + + return aMessage; +} + +export var ircServices = { + name: "IRC Services", + priority: ircHandlerPriorities.HIGH_PRIORITY, + isEnabled: () => true, + sendIdentify(aAccount) { + if ( + aAccount.imAccount.password && + aAccount.shouldAuthenticate && + !aAccount.isAuthenticated + ) { + aAccount.sendMessage( + "IDENTIFY", + aAccount.imAccount.password, + "IDENTIFY <password not logged>" + ); + } + }, + + commands: { + // If we automatically reply to a NOTICE message this does not abide by RFC + // 2812. Oh well. + NOTICE(ircMessage, ircHandlers) { + if (!ircHandlers.hasServicesHandlers) { + return false; + } + + let message = ServiceMessage(this, ircMessage); + + // If no service was found, return early. + if (!message.hasOwnProperty("serviceName")) { + return false; + } + + // If the name is recognized as a service name, add the service name field + // and run it through the handlers. + return ircHandlers.handleServicesMessage(this, message); + }, + + NICK(aMessage) { + let newNick = aMessage.params[0]; + // We only auto-authenticate for the account nickname. + if (this.normalize(newNick) != this.normalize(this._accountNickname)) { + return false; + } + + // If we're not identified already, try to identify. + if (!this.isAuthenticated) { + ircServices.sendIdentify(this); + } + + // We always want the RFC 2812 handler to handle NICK, so return false. + return false; + }, + + "001": function (aMessage) { + // RPL_WELCOME + // If SASL authentication failed, attempt IDENTIFY. + ircServices.sendIdentify(this); + + // We always want the RFC 2812 handler to handle 001, so return false. + return false; + }, + + 421(aMessage) { + // ERR_UNKNOWNCOMMAND + // <command> :Unknown command + // IDENTIFY failed, try NICKSERV IDENTIFY. + if ( + aMessage.params[1] == "IDENTIFY" && + this.imAccount.password && + this.shouldAuthenticate && + !this.isAuthenticated + ) { + this.sendMessage( + "NICKSERV", + ["IDENTIFY", this.imAccount.password], + "NICKSERV IDENTIFY <password not logged>" + ); + return true; + } + if (aMessage.params[1] == "NICKSERV") { + this.WARN("NICKSERV command does not exist."); + return true; + } + return false; + }, + }, +}; + +export var servicesBase = { + name: "IRC Services", + priority: ircHandlerPriorities.DEFAULT_PRIORITY, + isEnabled: () => true, + + commands: { + ChanServ(aMessage) { + // [<channel name>] <message> + let channel = aMessage.params[1].split(" ", 1)[0]; + if (!channel || channel[0] != "[" || channel.slice(-1)[0] != "]") { + return false; + } + + // Remove the [ and ]. + channel = channel.slice(1, -1); + // If it isn't a channel or doesn't exist, return early. + if (!this.isMUCName(channel) || !this.conversations.has(channel)) { + return false; + } + + // Otherwise, display the message in that conversation. + let params = { incoming: true }; + if (aMessage.command == "NOTICE") { + params.notification = true; + } + + // The message starts after the channel name, plus [, ] and a space. + let message = aMessage.params[1].slice(channel.length + 3); + this.getConversation(channel).writeMessage( + aMessage.origin, + message, + params + ); + return true; + }, + + InfoServ(aMessage) { + let text = aMessage.params[1]; + + // Show the message of the day in the server tab. + if (text == "*** \u0002Message(s) of the Day\u0002 ***") { + this._infoServMotd = [text]; + return true; + } else if (text == "*** \u0002End of Message(s) of the Day\u0002 ***") { + if (this._showServerTab && this._infoServMotd) { + this._infoServMotd.push(text); + this.getConversation(aMessage.origin).writeMessage( + aMessage.origin, + this._infoServMotd.join("\n"), + { + incoming: true, + } + ); + delete this._infoServMotd; + } + return true; + } else if (this.hasOwnProperty("_infoServMotd")) { + this._infoServMotd.push(text); + return true; + } + + return false; + }, + + NickServ(message, ircHandlers) { + // Since we feed the messages back through the system at the end of the + // timeout when waiting for a log-in, we need to NOT try to handle them + // here and let them fall through to the default handler. + if (this.isHandlingQueuedMessages) { + return false; + } + + let text = message.params[1]; + + // If we have a queue of messages, we're waiting for authentication. + if (this.nickservMessageQueue) { + if ( + text == "Password accepted - you are now recognized." || // Anope. + text.startsWith("You are now identified for \x02") + ) { + // Atheme. + // Password successfully accepted by NickServ, don't display the + // queued messages. + this.LOG("Successfully authenticated with NickServ."); + this.isAuthenticated = true; + clearTimeout(this.nickservAuthTimeout); + delete this.nickservAuthTimeout; + delete this.nickservMessageQueue; + } else { + // Queue any other messages that occur during the timeout so they + // appear in the proper order. + this.nickservMessageQueue.push(message); + } + return true; + } + + // NickServ wants us to identify. + if ( + text == "This nick is owned by someone else. Please choose another." || // Anope. + text == "This nickname is registered and protected. If it is your" || // Anope (SECURE enabled). + text == + "This nickname is registered. Please choose a different nickname, or identify via \x02/msg NickServ identify <password>\x02." + ) { + // Atheme. + this.LOG("Authentication requested by NickServ."); + + // Wait one second before showing the message to the user (giving the + // the server time to process the log-in). + this.nickservMessageQueue = [message]; + this.nickservAuthTimeout = setTimeout( + function () { + this.isHandlingQueuedMessages = true; + this.nickservMessageQueue.every(aMessage => + ircHandlers.handleMessage(this, aMessage) + ); + delete this.isHandlingQueuedMessages; + delete this.nickservMessageQueue; + }.bind(this), + 10000 + ); + return true; + } + + if ( + !this.isAuthenticated && + (text == "You are already identified." || // Anope. + text.startsWith("You are already logged in as \x02")) + ) { + // Atheme. + // Do not show the message if caused by the automatic reauthentication. + this.isAuthenticated = true; + return true; + } + + return false; + }, + + /** + * Ignore useless messages from SaslServ (unless showing of server messages + * is enabled). + * + * @param {object} aMessage The IRC message object. + * @returns {boolean} True if the message was handled, false if it should be + * processed by another handler. + */ + SaslServ(aMessage) { + // If the user would like to see server messages, fall through to the + // standard handler. + if (this._showServerTab) { + return false; + } + + // Only ignore the message notifying of last login. + let text = aMessage.params[1]; + return text.startsWith("Last login from: "); + }, + + /* + * freenode sends some annoying messages on start-up from a freenode-connect + * bot. Only show these if the user wants to see server messages. See bug + * 1521761. + */ + "freenode-connect": function (aMessage) { + // If the user would like to see server messages, fall through to the + // standard handler. + if (this._showServerTab) { + return false; + } + + // Only ignore the message notifying of scanning (and include additional + // checking of the hostname). + return ( + aMessage.host.startsWith("freenode/utility-bot/") && + aMessage.params[1].includes( + "connections will be scanned for vulnerabilities" + ) + ); + }, + }, +}; diff --git a/comm/chat/protocols/irc/ircUtils.sys.mjs b/comm/chat/protocols/irc/ircUtils.sys.mjs new file mode 100644 index 0000000000..190ed8f830 --- /dev/null +++ b/comm/chat/protocols/irc/ircUtils.sys.mjs @@ -0,0 +1,303 @@ +/* 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 { l10nHelper } from "resource:///modules/imXPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/irc.properties") +); + +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTXT => cs.scanTXT(aTXT, cs.kEntities); +}); + +// The timespan after which we consider LIST roomInfo to be stale. +export var kListRefreshInterval = 12 * 60 * 60 * 1000; // 12 hours. + +/* + * The supported formatting control characters, as described in + * http://www.invlogic.com/irc/ctcp.html#3.11 + * If a string is given, it will replace the control character; if null is + * given, the current HTML tag stack will be closed; if a function is given, + * it expects two parameters: + * aStack The ordered list of open HTML tags. + * aInput The current input string. + * There are three output values returned in an array: + * The new ordered list of open HTML tags. + * The new text output to append. + * The number of characters (from the start of the input string) that the + * function handled. + */ +var CTCP_TAGS = { + "\x02": "b", // \002, ^B, Bold + "\x16": "i", // \026, ^V, Reverse or Inverse (Italics) + "\x1D": "i", // \035, ^], Italics (mIRC) + "\x1F": "u", // \037, ^_, Underline + "\x03": mIRCColoring, // \003, ^C, Coloring + "\x0F": null, // \017, ^O, Clear all formatting +}; + +// Generate an expression that will search for any of the control characters. +var CTCP_TAGS_EXP = new RegExp("[" + Object.keys(CTCP_TAGS).join("") + "]"); + +// Remove all CTCP formatting characters. +export function ctcpFormatToText(aString) { + let next, + input = aString, + output = "", + length; + + while ((next = CTCP_TAGS_EXP.exec(input))) { + if (next.index > 0) { + output += input.substr(0, next.index); + } + // We assume one character will be stripped. + length = 1; + let tag = CTCP_TAGS[input[next.index]]; + // If the tag is a function, calculate how many characters are handled. + if (typeof tag == "function") { + [, , length] = tag([], input.substr(next.index)); + } + + // Avoid infinite loops. + length = Math.max(1, length); + // Skip to after the last match. + input = input.substr(next.index + length); + } + // Append the unmatched bits before returning the output. + return output + input; +} + +function openStack(aStack) { + return aStack.map(aTag => "<" + aTag + ">").join(""); +} + +// Close the tags in the opposite order they were opened. +function closeStack(aStack) { + return aStack + .reverse() + .map(aTag => "</" + aTag.split(" ", 1) + ">") + .join(""); +} + +/** + * Convert a string from CTCP escaped formatting to HTML markup. + * + * @param aString the string with CTCP formatting to parse + * @returns The HTML output string + */ +export function ctcpFormatToHTML(aString) { + let next, + stack = [], + input = lazy.TXTToHTML(aString), + output = "", + newOutput, + length; + + while ((next = CTCP_TAGS_EXP.exec(input))) { + if (next.index > 0) { + output += input.substr(0, next.index); + } + length = 1; + let tag = CTCP_TAGS[input[next.index]]; + if (tag === null) { + // Clear all formatting. + output += closeStack(stack); + stack = []; + } else if (typeof tag == "function") { + [stack, newOutput, length] = tag(stack, input.substr(next.index)); + output += newOutput; + } else { + let offset = stack.indexOf(tag); + if (offset == -1) { + // Tag not found; open new tag. + output += "<" + tag + ">"; + stack.push(tag); + } else { + // Tag found; close existing tag (and all tags after it). + output += closeStack(stack.slice(offset)); + // Reopen the tags that came after it. + output += openStack(stack.slice(offset + 1)); + // Remove the tag from the stack. + stack.splice(offset, 1); + } + } + + // Avoid infinite loops. + length = Math.max(1, length); + // Skip to after the last match. + input = input.substr(next.index + length); + } + // Return unmatched bits and close any open tags at the end. + return output + input + closeStack(stack); +} + +// mIRC colors are defined at http://www.mirc.com/colors.html. +// This expression matches \003<one or two digits>[,<one or two digits>]. +// eslint-disable-next-line no-control-regex +var M_IRC_COLORS_EXP = /^\x03(?:(\d\d?)(?:,(\d\d?))?)?/; +var M_IRC_COLOR_MAP = { + 0: "white", + 1: "black", + 2: "navy", // blue (navy) + 3: "green", + 4: "red", + 5: "maroon", // brown (maroon) + 6: "purple", + 7: "orange", // orange (olive) + 8: "yellow", + 9: "lime", // light green (lime) + 10: "teal", // teal (a green/blue cyan) + 11: "aqua", // light cyan (cyan) (aqua) + 12: "blue", // light blue (royal)", + 13: "fuchsia", // pink (light purple) (fuchsia) + 14: "grey", + 15: "silver", // light grey (silver) + 99: "transparent", +}; + +function mIRCColoring(aStack, aInput) { + function getColor(aKey) { + let key = aKey; + // Single digit numbers can (must?) be prefixed by a zero. + if (key.length == 2 && key[0] == "0") { + key = key[1]; + } + + if (M_IRC_COLOR_MAP.hasOwnProperty(key)) { + return M_IRC_COLOR_MAP[key]; + } + + return null; + } + + let matches, + stack = aStack, + input = aInput, + output = "", + length = 1; + + if ((matches = M_IRC_COLORS_EXP.exec(input))) { + let format = ["font"]; + + // Only \003 was found with no formatting digits after it, close the + // first open font tag. + if (!matches[1]) { + // Find the first font tag. + let offset = stack.map(aTag => aTag.indexOf("font") === 0).indexOf(true); + + // Close all tags from the first font tag on. + output = closeStack(stack.slice(offset)); + // Remove the font tags from the stack. + stack = stack.filter(aTag => aTag.indexOf("font")); + // Reopen the other tags. + output += openStack(stack.slice(offset)); + } else { + // Otherwise we have a match and are setting new colors. + // The foreground color. + let color = getColor(matches[1]); + if (color) { + format.push('color="' + color + '"'); + } + + // The background color. + if (matches[2]) { + let color = getColor(matches[2]); + if (color) { + format.push('background="' + color + '"'); + } + } + + if (format.length > 1) { + let tag = format.join(" "); + output = "<" + tag + ">"; + stack.push(tag); + length = matches[0].length; + } + } + } + + return [stack, output, length]; +} + +// Print an error message into a conversation, optionally mark the conversation +// as not joined and/or not rejoinable. +export function conversationErrorMessage( + aAccount, + aMessage, + aError, + aJoinFailed = false, + aRejoinable = true +) { + let conv = aAccount.getConversation(aMessage.params[1]); + conv.writeMessage( + aMessage.origin, + lazy._(aError, aMessage.params[1], aMessage.params[2] || undefined), + { + error: true, + system: true, + } + ); + delete conv._pendingMessage; + + // Channels have a couple extra things that can be done to them. + if (aAccount.isMUCName(aMessage.params[1])) { + // If a value for joining is explicitly given, mark it. + if (aJoinFailed) { + conv.joining = false; + } + // If the conversation cannot be rejoined automatically, delete + // chatRoomFields. + if (!aRejoinable) { + delete conv.chatRoomFields; + } + } + + return true; +} + +/** + * Display a PRIVMSG or NOTICE in a conversation. + * + * @param {ircAccount} aAccount - The current account. + * @param {ircMessage} aMessage - The IRC message to display, provides the IRC + * tags, conversation name, and sender. + * @param {object} aExtraParams - (Extra) parameters to pass to ircConversation.writeMessage. + * @param {string|null} aText - The text to display, defaults to the second parameter + * on aMessage. + * @returns {boolean} True if the message was sent successfully. + */ +export function displayMessage(aAccount, aMessage, aExtraParams, aText) { + let params = { tags: aMessage.tags, ...aExtraParams }; + // If the the message is from our nick, it is outgoing to the conversation it + // is targeting. Otherwise, the message is incoming, but could be for a + // private message or a channel. + // + // Note that the only time it is expected to receive a message from us is if + // the echo-message capability is enabled. + let convName; + if ( + aAccount.normalizeNick(aMessage.origin) == + aAccount.normalizeNick(aAccount._nickname) + ) { + params.outgoing = true; + // The conversation name is who it is being sent to. + convName = aMessage.params[0]; + } else { + params.incoming = true; + // If the target is a MUC name, use the target as the conversation name. + // Otherwise, this is a private message: use the sender as the conversation + // name. + convName = aAccount.isMUCName(aMessage.params[0]) + ? aMessage.params[0] + : aMessage.origin; + } + aAccount + .getConversation(convName) + .writeMessage(aMessage.origin, aText || aMessage.params[1], params); + return true; +} diff --git a/comm/chat/protocols/irc/ircWatchMonitor.sys.mjs b/comm/chat/protocols/irc/ircWatchMonitor.sys.mjs new file mode 100644 index 0000000000..c7bdb2bf7b --- /dev/null +++ b/comm/chat/protocols/irc/ircWatchMonitor.sys.mjs @@ -0,0 +1,467 @@ +/* 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/. */ + +/* + * This implements the WATCH and MONITOR commands: ways to more efficiently + * (compared to ISON) keep track of a user's status. + * + * MONITOR (supported by Charybdis) + * https://github.com/atheme/charybdis/blob/master/doc/monitor.txt + * WATCH (supported by Bahamut and UnrealIRCd) + * http://www.stack.nl/~jilles/cgi-bin/hgwebdir.cgi/irc-documentation-jilles/raw-file/tip/reference/draft-meglio-irc-watch-00.txt + */ + +import { clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { ircHandlerPriorities } from "resource:///modules/ircHandlerPriorities.sys.mjs"; + +function setStatus(aAccount, aNick, aStatus) { + if (!aAccount.watchEnabled && !aAccount.monitorEnabled) { + return false; + } + + if (aStatus == "AWAY") { + // We need to request the away message. + aAccount.requestCurrentWhois(aNick); + } else { + // Clear the WHOIS information. + aAccount.removeBuddyInfo(aNick); + } + + let buddy = aAccount.buddies.get(aNick); + if (!buddy) { + return false; + } + buddy.setStatus(Ci.imIStatusInfo["STATUS_" + aStatus], ""); + return true; +} + +function trackBuddyWatch(aNicks) { + // aNicks is an array when WATCH is initialized, and a single nick + // in all later calls. + if (!Array.isArray(aNicks)) { + // We update the trackQueue if an individual nick is being added, + // so the nick will also be monitored after a reconnect. + Object.getPrototypeOf(this).trackBuddy.call(this, aNicks); + aNicks = [aNicks]; + } + + let nicks = aNicks.map(aNick => "+" + aNick); + if (!nicks.length) { + return; + } + + let newWatchLength = this.watchLength + nicks.length; + if (newWatchLength > this.maxWatchLength) { + this.WARN( + "Attempting to WATCH " + + newWatchLength + + " nicks; maximum size is " + + this.maxWatchLength + + "." + ); + // TODO We should trim the list and add the extra users to an ISON queue, + // but that's not currently implemented, so just hope the server doesn't + // enforce it's own limit. + } + this.watchLength = newWatchLength; + + // Watch away as well as online. + let params = []; + if (this.watchAwayEnabled) { + params.push("A"); + } + let maxLength = + this.maxMessageLength - + 2 - + this.countBytes(this.buildMessage("WATCH", params)); + for (let nick of nicks) { + if (this.countBytes(params + " " + nick) >= maxLength) { + // If the message would be too long, first send this message. + this.sendMessage("WATCH", params); + // Reset for the next message. + params = []; + if (this.watchAwayEnabled) { + params.push("A"); + } + } + params.push(nick); + } + this.sendMessage("WATCH", params); +} +function untrackBuddyWatch(aNick) { + --this.watchLength; + this.sendMessage("WATCH", "-" + aNick); + Object.getPrototypeOf(this).untrackBuddy.call(this, aNick); +} + +export var isupportWATCH = { + name: "WATCH", + // Slightly above default ISUPPORT priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + isEnabled: () => true, + + commands: { + WATCH(aMessage) { + if (!aMessage.isupport.useDefault) { + this.maxWatchLength = 128; + } else { + let size = parseInt(aMessage.isupport.value, 10); + if (isNaN(size)) { + return false; + } + this.maxWatchLength = size; + } + + this.watchEnabled = true; + + // Clear our watchlist in case there is garbage in it. + this.sendMessage("WATCH", "C"); + this.watchLength = 0; + + // Kill the ISON polling loop. + clearTimeout(this._isOnTimer); + + return true; + }, + + WATCHOPTS(aMessage) { + const watchOptToOption = { + H: "watchMasksEnabled", + A: "watchAwayEnabled", + }; + + // For each option, mark it as supported. + aMessage.isupport.value.split("").forEach(function (aWatchOpt) { + if (watchOptToOption.hasOwnProperty(aWatchOpt)) { + this[watchOptToOption[aWatchOpt]] = true; + } + }, this); + + return true; + }, + }, +}; + +export var ircWATCH = { + name: "WATCH", + // Slightly above default IRC priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + // Use WATCH if it is supported. + isEnabled() { + return !!this.watchEnabled; + }, + + commands: { + 251(aMessage) { + // RPL_LUSERCLIENT + // ":There are <integer> users and <integer> services on <integer> servers" + // Assume that this will always be sent after the 005 handler on + // connection registration. If WATCH is enabled, then set the new function + // to keep track of nicks and send the messages to watch the nicks. + + // Ensure that any new buddies are set to be watched, and removed buddies + // are no longer watched. + this.trackBuddy = trackBuddyWatch; + this.untrackBuddy = untrackBuddyWatch; + + // Build the watchlist from the current list of nicks. + this.trackBuddy(this.trackQueue); + + // Fall through to other handlers since we're only using this as an entry + // point and not actually handling the message. + return false; + }, + + 301(aMessage) { + // RPL_AWAY + // <nick> :<away message> + // Set the received away message. + let buddy = this.buddies.get(aMessage.params[1]); + if (buddy) { + buddy.setStatus(Ci.imIStatusInfo.STATUS_AWAY, aMessage.params[2]); + } + + // Fall through to the other implementations after setting the status + // message. + return false; + }, + + 303(aMessage) { + // RPL_ISON + // :*1<nick> *( " " <nick> ) + // We don't want ircBase to interfere with us, so override the ISON + // handler to do nothing. + return true; + }, + + 512(aMessage) { + // ERR_TOOMANYWATCH + // Maximum size for WATCH-list is <watchlimit> entries + this.ERROR( + "Maximum size for WATCH list exceeded (" + this.watchLength + ")." + ); + return true; + }, + + 597(aMessage) { + // RPL_REAWAY + // <nickname> <username> <hostname> <awaysince> :<away reason> + return setStatus(this, aMessage.params[1], "AWAY"); + }, + + 598(aMessage) { + // RPL_GONEAWAY + // <nickname> <username> <hostname> <awaysince> :<away reason> + // We use a negative index as inspircd versions < 2.0.18 don't send + // the user's nick as the first parameter (see bug 1078223). + return setStatus( + this, + aMessage.params[aMessage.params.length - 5], + "AWAY" + ); + }, + + 599(aMessage) { + // RPL_NOTAWAY + // <nickname> <username> <hostname> <awaysince> :is no longer away + // We use a negative index as inspircd versions < 2.0.18 don't send + // the user's nick as the first parameter (see bug 1078223). + return setStatus( + this, + aMessage.params[aMessage.params.length - 5], + "AVAILABLE" + ); + }, + + 600(aMessage) { + // RPL_LOGON + // <nickname> <username> <hostname> <signontime> :logged on + return setStatus(this, aMessage.params[1], "AVAILABLE"); + }, + + 601(aMessage) { + // RPL_LOGOFF + // <nickname> <username> <hostname> <lastnickchange> :logged off + return setStatus(this, aMessage.params[1], "OFFLINE"); + }, + + 602(aMessage) { + // RPL_WATCHOFF + // <nickname> <username> <hostname> <lastnickchange> :stopped watching + return true; + }, + + 603(aMessage) { + // RPL_WATCHSTAT + // You have <entrycount> and are on <onlistcount> WATCH entries + // TODO I don't think we really need to care about this. + return false; + }, + + 604(aMessage) { + // RPL_NOWON + // <nickname> <username> <hostname> <lastnickchange> :is online + return setStatus(this, aMessage.params[1], "AVAILABLE"); + }, + + 605(aMessage) { + // RPL_NOWOFF + // <nickname> <username> <hostname> <lastnickchange> :is offline + return setStatus(this, aMessage.params[1], "OFFLINE"); + }, + + 606(aMessage) { + // RPL_WATCHLIST + // <entrylist> + // TODO + return false; + }, + + 607(aMessage) { + // RPL_ENDOFWATCHLIST + // End of WATCH <parameter> + // TODO + return false; + }, + + 608(aMessage) { + // RPL_CLEARWATCH + // Your WATCH list is now empty + // Note that this is optional for servers to send, so ignore it. + return true; + }, + + 609(aMessage) { + // RPL_NOWISAWAY + // <nickname> <username> <hostname> <awaysince> :<away reason> + return setStatus(this, aMessage.params[1], "AWAY"); + }, + }, +}; + +export var isupportMONITOR = { + name: "MONITOR", + // Slightly above default ISUPPORT priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + isEnabled: () => true, + + commands: { + MONITOR(aMessage) { + if (!aMessage.isupport.useDefault) { + this.maxMonitorLength = Infinity; + } else { + let size = parseInt(aMessage.isupport.value, 10); + if (isNaN(size)) { + return false; + } + this.maxMonitorLength = size; + } + + this.monitorEnabled = true; + + // Clear our monitor list in case there is garbage in it. + this.sendMessage("MONITOR", "C"); + this.monitorLength = 0; + + // Kill the ISON polling loop. + clearTimeout(this._isOnTimer); + + return true; + }, + }, +}; + +function trackBuddyMonitor(aNicks) { + // aNicks is an array when MONITOR is initialized, and a single nick + // in all later calls. + if (!Array.isArray(aNicks)) { + // We update the trackQueue if an individual nick is being added, + // so the nick will also be monitored after a reconnect. + Object.getPrototypeOf(this).trackBuddy.call(this, aNicks); + aNicks = [aNicks]; + } + + let nicks = aNicks; + if (!nicks.length) { + return; + } + + let newMonitorLength = this.monitorLength + nicks.length; + if (newMonitorLength > this.maxMonitorLength) { + this.WARN( + "Attempting to MONITOR " + + newMonitorLength + + " nicks; maximum size is " + + this.maxMonitorLength + + "." + ); + // TODO We should trim the list and add the extra users to an ISON queue, + // but that's not currently implemented, so just hope the server doesn't + // enforce it's own limit. + } + this.monitorLength = newMonitorLength; + + let params = []; + let maxLength = + this.maxMessageLength - + 2 - + this.countBytes(this.buildMessage("MONITOR", "+")); + for (let nick of nicks) { + if (this.countBytes(params + " " + nick) >= maxLength) { + // If the message would be too long, first send this message. + this.sendMessage("MONITOR", ["+", params.join(",")]); + // Reset for the next message. + params = []; + } + params.push(nick); + } + this.sendMessage("MONITOR", ["+", params.join(",")]); +} +function untrackBuddyMonitor(aNick) { + --this.monitorLength; + this.sendMessage("MONITOR", ["-", aNick]); + Object.getPrototypeOf(this).untrackBuddy.call(this, aNick); +} + +export var ircMONITOR = { + name: "MONITOR", + // Slightly above default IRC priority. + priority: ircHandlerPriorities.DEFAULT_PRIORITY + 10, + // Use MONITOR only if MONITOR is enabled and WATCH is not enabled, as WATCH + // supports more features. + isEnabled() { + return this.monitorEnabled && !this.watchEnabled; + }, + + commands: { + 251(aMessage) { + // RPL_LUSERCLIENT + // ":There are <integer> users and <integer> services on <integer> servers" + // Assume that this will always be sent after the 005 handler on + // connection registration. If MONITOR is enabled, then set the new + // function to keep track of nicks and send the messages to watch the + // nicks. + + // Ensure that any new buddies are set to be watched, and removed buddies + // are no longer watched. + this.trackBuddy = trackBuddyMonitor; + this.untrackBuddy = untrackBuddyMonitor; + + // Build the watchlist from the current list of nicks. + this.trackBuddy(this.trackQueue); + + // Fall through to other handlers since we're only using this as an entry + // point and not actually handling the message. + return false; + }, + + 303(aMessage) { + // RPL_ISON + // :*1<nick> *( " " <nick> ) + // We don't want ircBase to interfere with us, so override the ISON + // handler to do nothing if we're using MONITOR. + return true; + }, + + 730(aMessage) { + // RPL_MONONLINE + // :<server> 730 <nick> :nick!user@host[,nick!user@host]* + // Mark each nick as online. + return aMessage.params[1] + .split(",") + .map(aNick => setStatus(this, aNick.split("!", 1)[0], "AVAILABLE")) + .every(aResult => aResult); + }, + + 731(aMessage) { + // RPL_MONOFFLINE + // :<server> 731 <nick> :nick[,nick1]* + return aMessage.params[1] + .split(",") + .map(aNick => setStatus(this, aNick, "OFFLINE")) + .every(aResult => aResult); + }, + + 732(aMessage) { + // RPL_MONLIST + // :<server> 732 <nick> :nick[,nick1]* + return false; + }, + + 733(aMessage) { + // RPL_ENDOFMONLIST + // :<server> 733 <nick> :End of MONITOR list + return false; + }, + + 734(aMessage) { + // ERR_MONLISTFULL + // :<server> 734 <nick> <limit> <nicks> :Monitor list is full. + this.ERROR( + "Maximum size for MONITOR list exceeded (" + this.params[1] + ")." + ); + return true; + }, + }, +}; diff --git a/comm/chat/protocols/irc/jar.mn b/comm/chat/protocols/irc/jar.mn new file mode 100644 index 0000000000..4ef677131e --- /dev/null +++ b/comm/chat/protocols/irc/jar.mn @@ -0,0 +1,9 @@ +# 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/. + +chat.jar: +% skin prpl-irc classic/1.0 %skin/classic/prpl/irc/ + skin/classic/prpl/irc/icon32.png (icons/prpl-irc-32.png) + skin/classic/prpl/irc/icon48.png (icons/prpl-irc-48.png) + skin/classic/prpl/irc/icon.png (icons/prpl-irc.png) diff --git a/comm/chat/protocols/irc/moz.build b/comm/chat/protocols/irc/moz.build new file mode 100644 index 0000000000..8e72a57f4e --- /dev/null +++ b/comm/chat/protocols/irc/moz.build @@ -0,0 +1,33 @@ +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"] + +EXTRA_JS_MODULES += [ + "irc.sys.mjs", + "ircAccount.sys.mjs", + "ircBase.sys.mjs", + "ircCAP.sys.mjs", + "ircCommands.sys.mjs", + "ircCTCP.sys.mjs", + "ircDCC.sys.mjs", + "ircEchoMessage.sys.mjs", + "ircHandlerPriorities.sys.mjs", + "ircHandlers.sys.mjs", + "ircISUPPORT.sys.mjs", + "ircMultiPrefix.sys.mjs", + "ircNonStandard.sys.mjs", + "ircSASL.sys.mjs", + "ircServerTime.sys.mjs", + "ircServices.sys.mjs", + "ircUtils.sys.mjs", + "ircWatchMonitor.sys.mjs", +] + +JAR_MANIFESTS += ["jar.mn"] + +XPCOM_MANIFESTS += [ + "components.conf", +] diff --git a/comm/chat/protocols/irc/test/test_ctcpColoring.js b/comm/chat/protocols/irc/test/test_ctcpColoring.js new file mode 100644 index 0000000000..2875bdff36 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ctcpColoring.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ctcpFormatToText, ctcpFormatToHTML } = ChromeUtils.importESModule( + "resource:///modules/ircUtils.sys.mjs" +); + +var input = [ + // From http://www.mirc.com/colors.html + "\x035,12colored text and background\x03", + "\x035colored text\x03", + "\x033colored text \x035,2more colored text and background\x03", + "\x033,5colored text and background \x038other colored text but same background\x03", + "\x033,5colored text and background \x038,7other colored text and different background\x03", + + // Based on above, but more complicated. + "\x02\x035,12colored \x1Ftext and background\x03. You sure about this?", + + // Implied by above. + "So a \x03,8 attribute is not valid and thus ignored.", + + // Try some of the above with two digits. + "\x0303,5colored text and background \x0308other colored text but same background\x03", + "\x0303,05colored text and background \x038,7other colored text and different background\x03", +]; + +function run_test() { + add_test(test_mIRCColoring); + add_test(test_ctcpFormatToText); + + run_next_test(); +} + +function test_mIRCColoring() { + let expectedOutput = [ + '<font color="maroon" background="blue">colored text and background</font>', + '<font color="maroon">colored text</font>', + '<font color="green">colored text <font color="maroon" background="navy">more colored text and background</font></font>', + '<font color="green" background="maroon">colored text and background <font color="yellow">other colored text but same background</font></font>', + '<font color="green" background="maroon">colored text and background <font color="yellow" background="orange">other colored text and different background</font></font>', + '<b><font color="maroon" background="blue">colored <u>text and background</u></font><u>. You sure about this?</u></b>', + "So a ,8 attribute is not valid and thus ignored.", + '<font color="green" background="maroon">colored text and background <font color="yellow">other colored text but same background</font></font>', + '<font color="green" background="maroon">colored text and background <font color="yellow" background="orange">other colored text and different background</font></font>', + ]; + + for (let i = 0; i < input.length; i++) { + equal(expectedOutput[i], ctcpFormatToHTML(input[i])); + } + + run_next_test(); +} + +function test_ctcpFormatToText() { + let expectedOutput = [ + "colored text and background", + "colored text", + "colored text more colored text and background", + "colored text and background other colored text but same background", + "colored text and background other colored text and different background", + "colored text and background. You sure about this?", + "So a ,8 attribute is not valid and thus ignored.", + "colored text and background other colored text but same background", + "colored text and background other colored text and different background", + ]; + + for (let i = 0; i < input.length; i++) { + equal(expectedOutput[i], ctcpFormatToText(input[i])); + } + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_ctcpDequote.js b/comm/chat/protocols/irc/test/test_ctcpDequote.js new file mode 100644 index 0000000000..1a1e7fcc9d --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ctcpDequote.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { CTCPMessage } = ChromeUtils.importESModule( + "resource:///modules/ircCTCP.sys.mjs" +); + +var input = [ + "ACTION", + "ACTION test", + "ACTION \x5Ctest", + "ACTION te\x5Cst", + "ACTION test\x5C", + "ACTION \x5C\x5Ctest", + "ACTION te\x5C\x5Cst", + "ACTION test\x5C\x5C", + "ACTION \x5C\x5C\x5Ctest", + "ACTION te\x5C\x5C\x5Cst", + "ACTION test\x5C\x5C\x5C", + "ACTION \x5Catest", + "ACTION te\x5Cast", + "ACTION test\x5Ca", + "ACTION \x5C\x5C\x5Catest", + "ACTION \x5C\x5Catest", +]; + +var expectedOutputParam = [ + "", + "test", + "test", + "test", + "test", + "\x5Ctest", + "te\x5Cst", + "test\x5C", + "\x5Ctest", + "te\x5Cst", + "test\x5C", + "\x01test", + "te\x01st", + "test\x01", + "\x5C\x01test", + "\x5Catest", +]; + +function run_test() { + let output = input.map(aStr => CTCPMessage({}, aStr)); + // Ensure both arrays have the same length. + equal(expectedOutputParam.length, output.length); + // Ensure the values in the arrays are equal. + for (let i = 0; i < output.length; ++i) { + equal(expectedOutputParam[i], output[i].ctcp.param); + equal("ACTION", output[i].ctcp.command); + } +} diff --git a/comm/chat/protocols/irc/test/test_ctcpFormatting.js b/comm/chat/protocols/irc/test/test_ctcpFormatting.js new file mode 100644 index 0000000000..022b194d4c --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ctcpFormatting.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ctcpFormatToText, ctcpFormatToHTML } = ChromeUtils.importESModule( + "resource:///modules/ircUtils.sys.mjs" +); + +// TODO add a test for special JS characters (|, etc...) + +var input = [ + "The quick brown fox \x02jumps\x02 over the lazy dog.", + "The quick brown fox \x02jumps\x0F over the lazy dog.", + "The quick brown \x16fox jumps\x16 over the lazy dog.", + "The quick brown \x16fox jumps\x0F over the lazy dog.", + "The quick \x1Fbrown fox jumps over the lazy\x1F dog.", + "The quick \x1Fbrown fox jumps over the lazy\x0F dog.", + "The quick \x1Fbrown fox \x02jumps over the lazy\x1F dog.", + "The quick \x1Fbrown fox \x02jumps\x1F over the lazy\x02 dog.", + "The quick \x1Fbrown \x16fox \x02jumps\x1F over\x16 the lazy\x02 dog.", + "The quick \x1Fbrown \x16fox \x02jumps\x0F over \x16the lazy \x02dog.", +]; + +function run_test() { + add_test(test_ctcpFormatToHTML); + add_test(test_ctcpFormatToText); + + run_next_test(); +} + +function test_ctcpFormatToHTML() { + let expectedOutput = [ + "The quick brown fox <b>jumps</b> over the lazy dog.", + "The quick brown fox <b>jumps</b> over the lazy dog.", + "The quick brown <i>fox jumps</i> over the lazy dog.", + "The quick brown <i>fox jumps</i> over the lazy dog.", + "The quick <u>brown fox jumps over the lazy</u> dog.", + "The quick <u>brown fox jumps over the lazy</u> dog.", + "The quick <u>brown fox <b>jumps over the lazy</b></u><b> dog.</b>", + "The quick <u>brown fox <b>jumps</b></u><b> over the lazy</b> dog.", + "The quick <u>brown <i>fox <b>jumps</b></i></u><i><b> over</b></i><b> the lazy</b> dog.", + "The quick <u>brown <i>fox <b>jumps</b></i></u> over <i>the lazy <b>dog.</b></i>", + ]; + + for (let i = 0; i < input.length; i++) { + equal(expectedOutput[i], ctcpFormatToHTML(input[i])); + } + + run_next_test(); +} + +function test_ctcpFormatToText() { + let expectedOutput = "The quick brown fox jumps over the lazy dog."; + + for (let i = 0; i < input.length; ++i) { + equal(expectedOutput, ctcpFormatToText(input[i])); + } + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_ctcpQuote.js b/comm/chat/protocols/irc/test/test_ctcpQuote.js new file mode 100644 index 0000000000..0c919236b9 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ctcpQuote.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ircAccount } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +var input = [ + undefined, + "test", + "\\test", + "te\\st", + "test\\", + "\\\\test", + "te\\\\st", + "test\\\\", + "\\\\\\test", + "te\\\\\\st", + "test\\\\\\", + "\x01test", + "te\x01st", + "test\x01", + "\\\\\x01test", + "\\\\atest", +]; + +var expectedOutputParams = [ + "ACTION", + "ACTION test", + "ACTION \\\\test", + "ACTION te\\\\st", + "ACTION test\\\\", + "ACTION \\\\\\\\test", + "ACTION te\\\\\\\\st", + "ACTION test\\\\\\\\", + "ACTION \\\\\\\\\\\\test", + "ACTION te\\\\\\\\\\\\st", + "ACTION test\\\\\\\\\\\\", + "ACTION \\atest", + "ACTION te\\ast", + "ACTION test\\a", + "ACTION \\\\\\\\\\atest", + "ACTION \\\\\\\\atest", +]; + +var outputParams = []; + +ircAccount.prototype.sendMessage = function (aCommand, aParams) { + equal("PRIVMSG", aCommand); + outputParams.push(aParams[1]); +}; + +function run_test() { + input.map(aStr => + ircAccount.prototype.sendCTCPMessage("", false, "ACTION", aStr) + ); + + // Ensure both arrays have the same length. + equal(expectedOutputParams.length, outputParams.length); + // Ensure the values in the arrays are equal. + for (let i = 0; i < outputParams.length; ++i) { + equal("\x01" + expectedOutputParams[i] + "\x01", outputParams[i]); + } +} diff --git a/comm/chat/protocols/irc/test/test_ircCAP.js b/comm/chat/protocols/irc/test/test_ircCAP.js new file mode 100644 index 0000000000..a79a926efc --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircCAP.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { capMessage } = ChromeUtils.importESModule( + "resource:///modules/ircCAP.sys.mjs" +); + +var testData = [ + // A normal LS from the server. + [ + ["*", "LS", "multi-prefix sasl userhost-in-names"], + [ + { + subcommand: "LS", + parameter: "multi-prefix", + }, + { + subcommand: "LS", + parameter: "sasl", + }, + { + subcommand: "LS", + parameter: "userhost-in-names", + }, + ], + ], + + // LS with both valid and invalid vendor specific capabilities. + [ + [ + "*", + "LS", + "sasl server-time znc.in/server-time-iso znc.in/playback palaverapp.com", + ], + [ + { + subcommand: "LS", + parameter: "sasl", + }, + { + subcommand: "LS", + parameter: "server-time", + }, + // Valid vendor prefixes (of the form <domain name>/<capability>). + { + subcommand: "LS", + parameter: "znc.in/server-time-iso", + }, + { + subcommand: "LS", + parameter: "znc.in/playback", + }, + // Invalid vendor prefix, but we should treat it as an opaque identifier. + { + subcommand: "LS", + parameter: "palaverapp.com", + }, + ], + ], + + // Some implementations include one less parameter. + [ + ["LS", "sasl"], + [ + { + subcommand: "LS", + parameter: "sasl", + }, + ], + ], + + // Modifier tests, ensure the modified is stripped from the capaibility and is + // parsed correctly. + [ + ["LS", "-disable =sticky ~ack"], + [ + { + subcommand: "LS", + parameter: "disable", + modifier: "-", + disable: true, + }, + { + subcommand: "LS", + parameter: "sticky", + modifier: "=", + sticky: true, + }, + { + subcommand: "LS", + parameter: "ack", + modifier: "~", + ack: true, + }, + ], + ], + + // IRC v3.2 multi-line LS response + [ + ["*", "LS", "*", "sasl"], + ["*", "LS", "server-time"], + [ + { + subcommand: "LS", + parameter: "sasl", + }, + { + subcommand: "LS", + parameter: "server-time", + }, + ], + ], + + // IRC v3.2 multi-line LIST response + [ + ["*", "LIST", "*", "sasl"], + ["*", "LIST", "server-time"], + [ + { + subcommand: "LIST", + parameter: "sasl", + }, + { + subcommand: "LIST", + parameter: "server-time", + }, + ], + ], + + // IRC v3.2 cap value + [ + ["*", "LS", "multi-prefix sasl=EXTERNAL sts=port=6697"], + [ + { + subcommand: "LS", + parameter: "multi-prefix", + }, + { + subcommand: "LS", + parameter: "sasl", + value: "EXTERNAL", + }, + { + subcommand: "LS", + parameter: "sts", + value: "port=6697", + }, + ], + ], + + // cap-notify new cap + [ + ["*", "NEW", "batch"], + [ + { + subcommand: "NEW", + parameter: "batch", + }, + ], + ], + + // cap-notify delete cap + [ + ["*", "DEL", "multi-prefix"], + [ + { + subcommand: "DEL", + parameter: "multi-prefix", + }, + ], + ], +]; + +function run_test() { + add_test(testCapMessages); + + run_next_test(); +} + +/* + * Test round tripping parsing and then rebuilding the messages from RFC 2812. + */ +function testCapMessages() { + for (let data of testData) { + // Generate an ircMessage to send into capMessage. + let i = 0; + let message; + let outputs; + const account = { + _queuedCAPs: [], + }; + + // Generate an ircMessage to send into capMessage. + while (typeof data[i][0] == "string") { + message = { + params: data[i], + }; + + // Create the CAP message. + outputs = capMessage(message, account); + ++i; + } + + // The original message should get a cap object added with the subcommand + // set. + ok(message.cap); + equal(message.cap.subcommand, data[i][0].subcommand); + + // We only care about the "cap" part of each return message. + outputs = outputs.map(o => o.cap); + + // Ensure the expected output is an array. + let expectedCaps = data[i]; + if (!Array.isArray(expectedCaps)) { + expectedCaps = [expectedCaps]; + } + + // Add defaults to the expected output. + for (let expectedCap of expectedCaps) { + // By default there's no modifier. + if (!("modifier" in expectedCap)) { + expectedCap.modifier = undefined; + } + for (let param of ["disable", "sticky", "ack"]) { + if (!(param in expectedCap)) { + expectedCap[param] = false; + } + } + } + + // Ensure each item in the arrays are equal. + deepEqual(outputs, expectedCaps); + } + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_ircChannel.js b/comm/chat/protocols/irc/test/test_ircChannel.js new file mode 100644 index 0000000000..eb8b04dcc7 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircChannel.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ircChannel } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +function waitForTopic(target, targetTopic) { + return new Promise(resolve => { + let observer = { + observe(subject, topic, data) { + if (topic === targetTopic) { + resolve({ subject, data }); + target.removeObserver(observer); + } + }, + }; + target.addObserver(observer); + }); +} + +function getChannel(account) { + const channelStub = { + _observers: [], + _name: "#test", + _account: { + _currentServerName: "test", + imAccount: { + statusInfo: {}, + }, + _nickname: "user", + _activeCAPs: new Set(), + ...account, + }, + }; + Object.setPrototypeOf(channelStub, ircChannel.prototype); + return channelStub; +} + +add_task(async function test_dispatchMessage_normal() { + let didSend = false; + const channelStub = getChannel({ + sendMessage(type, data) { + equal(type, "PRIVMSG"); + deepEqual(data, ["#test", "foo"]); + didSend = true; + return true; + }, + }); + const newText = waitForTopic(channelStub, "new-text"); + channelStub.dispatchMessage("foo"); + ok(didSend); + const { subject: sentMessage } = await newText; + equal(sentMessage.message, "foo"); + ok(sentMessage.outgoing); + ok(!sentMessage.notification); + equal(sentMessage.who, "user"); +}); + +add_task(async function test_dispatchMessage_empty() { + let didSend = false; + const channelStub = getChannel({ + sendMessage(type, data) { + ok(false, "Should not send empty message"); + didSend = true; + return true; + }, + }); + channelStub.writeMessage = () => { + ok(false, "Should not display empty unsent message"); + didSend = true; + }; + ircChannel.prototype.dispatchMessage.call(channelStub, ""); + ok(!didSend); +}); + +add_task(async function test_dispatchMessage_echoed() { + let didSend = false; + let didWrite = false; + const channelStub = getChannel({ + sendMessage(type, data) { + equal(type, "PRIVMSG"); + deepEqual(data, ["#test", "foo"]); + didSend = true; + return true; + }, + }); + channelStub._account._activeCAPs.add("echo-message"); + channelStub.writeMessage = () => { + ok(false, "Should not write message when echo is on"); + didWrite = true; + }; + ircChannel.prototype.dispatchMessage.call(channelStub, "foo"); + ok(didSend); + ok(!didWrite); +}); + +add_task(async function test_dispatchMessage_error() { + let didSend = false; + const channelStub = getChannel({ + sendMessage(type, data) { + equal(type, "PRIVMSG"); + deepEqual(data, ["#test", "foo"]); + didSend = true; + return false; + }, + }); + const newText = waitForTopic(channelStub, "new-text"); + ircChannel.prototype.dispatchMessage.call(channelStub, "foo"); + ok(didSend); + const { subject: writtenMessage } = await newText; + ok(writtenMessage.error); + ok(writtenMessage.system); + equal(writtenMessage.who, "test"); +}); + +add_task(async function test_dispatchMessage_action() { + let didSend = false; + const channelStub = getChannel({ + sendMessage(type, data) { + ok(false, "Action should not be sent as normal message"); + return false; + }, + sendCTCPMessage(target, isNotice, command, params) { + equal(target, "#test"); + ok(!isNotice); + equal(command, "ACTION"); + equal(params, "foo"); + didSend = true; + return true; + }, + }); + const newText = waitForTopic(channelStub, "new-text"); + ircChannel.prototype.dispatchMessage.call(channelStub, "foo", true); + ok(didSend); + const { subject: sentMessage } = await newText; + equal(sentMessage.message, "foo"); + ok(sentMessage.outgoing); + ok(!sentMessage.notification); + ok(sentMessage.action); + equal(sentMessage.who, "user"); +}); + +add_task(async function test_dispatchMessage_actionError() { + let didSend = false; + const channelStub = getChannel({ + sendMessage(type, data) { + ok(false, "Action should not be sent as normal message"); + return false; + }, + sendCTCPMessage(target, isNotice, command, params) { + equal(target, "#test"); + ok(!isNotice); + equal(command, "ACTION"); + equal(params, "foo"); + didSend = true; + return false; + }, + }); + const newText = waitForTopic(channelStub, "new-text"); + ircChannel.prototype.dispatchMessage.call(channelStub, "foo", true); + ok(didSend, "Message was sent"); + const { subject: sentMessage } = await newText; + ok(sentMessage.error, "Shown message is error"); + ok(sentMessage.system, "Shown message is from system"); + equal(sentMessage.who, "test"); +}); + +add_task(async function test_dispatchMessage_notice() { + let didSend = false; + const channelStub = getChannel({ + sendMessage(type, data) { + equal(type, "NOTICE"); + deepEqual(data, ["#test", "foo"]); + didSend = true; + return true; + }, + }); + const newText = waitForTopic(channelStub, "new-text"); + ircChannel.prototype.dispatchMessage.call(channelStub, "foo", false, true); + ok(didSend); + const { subject: sentMessage } = await newText; + equal(sentMessage.message, "foo"); + ok(sentMessage.outgoing); + ok(sentMessage.notification); + equal(sentMessage.who, "user"); +}); diff --git a/comm/chat/protocols/irc/test/test_ircCommands.js b/comm/chat/protocols/irc/test/test_ircCommands.js new file mode 100644 index 0000000000..4bd6ab2954 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircCommands.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { commands } = ChromeUtils.importESModule( + "resource:///modules/ircCommands.sys.mjs" +); +var { ircProtocol } = ChromeUtils.importESModule( + "resource:///modules/irc.sys.mjs" +); +var { ircAccount, ircConversation } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +// Ensure the commands have been initialized. +IMServices.conversations.initConversations(); + +var fakeProto = { + id: "fake-proto", + usernameSplits: ircProtocol.prototype.usernameSplits, + splitUsername: ircProtocol.prototype.splitUsername, +}; + +function run_test() { + add_test(testUserModeCommand); + add_test(testModeCommand); + run_next_test(); +} + +// Test the /mode command. +function testModeCommand() { + const testChannelCommands = [ + { + msg: "", + channel: "#instantbird", + expectedMessage: "MODE #instantbird", + }, + { + msg: "#instantbird", + channel: "#instantbird", + expectedMessage: "MODE #instantbird", + }, + { + msg: "-s", + channel: "#Fins", + expectedMessage: "MODE #Fins -s", + }, + { + msg: "#introduction +is", + channel: "#introduction", + expectedMessage: "MODE #introduction +is", + }, + { + msg: "-s", + channel: "&Gills", + expectedMessage: "MODE &Gills -s", + }, + { + msg: "#Gamers +o KennyS", + channel: "#Gamers", + expectedMessage: "MODE #Gamers +o KennyS", + }, + { + msg: "+o lisp", + channel: "&IB", + expectedMessage: "MODE &IB +o lisp", + }, + { + msg: "+b nick!abc@server", + channel: "#Alphabet", + expectedMessage: "MODE #Alphabet +b nick!abc@server", + }, + { + msg: "+b nick", + channel: "#Alphabet", + expectedMessage: "MODE #Alphabet +b nick", + }, + { + msg: "#instantbird +b nick!abc@server", + channel: "#instantbird", + expectedMessage: "MODE #instantbird +b nick!abc@server", + }, + { + msg: "+v Wiz", + channel: "#TheMatrix", + expectedMessage: "MODE #TheMatrix +v Wiz", + }, + { + msg: "+k passcode", + channel: "#TheMatrix", + expectedMessage: "MODE #TheMatrix +k passcode", + }, + { + msg: "#Mafia +k keyword", + channel: "#Mafia", + expectedMessage: "MODE #Mafia +k keyword", + }, + { + msg: "#introduction +l 100", + channel: "#introduction", + expectedMessage: "MODE #introduction +l 100", + }, + { + msg: "+l 100", + channel: "#introduction", + expectedMessage: "MODE #introduction +l 100", + }, + ]; + + const testUserCommands = [ + { + msg: "nickolas +x", + expectedMessage: "MODE nickolas +x", + }, + { + msg: "matrixisreal -x", + expectedMessage: "MODE matrixisreal -x", + }, + { + msg: "matrixisreal_19 +oWp", + expectedMessage: "MODE matrixisreal_19 +oWp", + }, + { + msg: "nick", + expectedMessage: "MODE nick", + }, + ]; + + let account = new ircAccount(fakeProto, { + name: "defaultnick@instantbird.org", + }); + + // check if the message being sent is same as expected message. + account.sendRawMessage = aMessage => { + equal(aMessage, account._expectedMessage); + }; + + const command = _getRunCommand("mode"); + + // First test Channel Commands. + for (let test of testChannelCommands) { + let conv = new ircConversation(account, test.channel); + account._expectedMessage = test.expectedMessage; + command(test.msg, conv); + } + + // Now test the User Commands. + let conv = new ircConversation(account, "dummyConversation"); + account._nickname = "test_nick"; + for (let test of testUserCommands) { + account._expectedMessage = test.expectedMessage; + command(test.msg, conv); + } + + run_next_test(); +} + +// Test the /umode command. +function testUserModeCommand() { + const testData = [ + { + msg: "+x", + expectedMessage: "MODE test_nick +x", + }, + { + msg: "-x", + expectedMessage: "MODE test_nick -x", + }, + { + msg: "-pa", + expectedMessage: "MODE test_nick -pa", + }, + { + msg: "+oWp", + expectedMessage: "MODE test_nick +oWp", + }, + { + msg: "", + expectedMessage: "MODE test_nick", + }, + ]; + + let account = new ircAccount(fakeProto, { + name: "test_nick@instantbird.org", + }); + account._nickname = "test_nick"; + let conv = new ircConversation(account, "newconv"); + + // check if the message being sent is same as expected message. + account.sendRawMessage = aMessage => { + equal(aMessage, account._expectedMessage); + }; + + const command = _getRunCommand("umode"); + + // change the nick and runUserModeCommand for each test + for (let test of testData) { + account._expectedMessage = test.expectedMessage; + command(test.msg, conv); + } + + run_next_test(); +} + +// Fetch the run() of a named command. +function _getRunCommand(aCommandName) { + for (let command of commands) { + if (command.name == aCommandName) { + return command.run; + } + } + + // Fail if no command was found. + ok(false, "Could not find the '" + aCommandName + "' command."); + return null; // Shut-up eslint. +} diff --git a/comm/chat/protocols/irc/test/test_ircMessage.js b/comm/chat/protocols/irc/test/test_ircMessage.js new file mode 100644 index 0000000000..4420856c84 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircMessage.js @@ -0,0 +1,336 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ircAccount, ircMessage } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +var testData = [ + // First off, let's test the messages from RFC 2812. + "PASS secretpasswordhere", + "NICK Wiz", + ":WiZ!jto@tolsun.oulu.fi NICK Kilroy", + "USER guest 0 * :Ronnie Reagan", + "USER guest 8 * :Ronnie Reagan", + "OPER foo bar", + "MODE WiZ -w", + "MODE Angel +i", + "MODE WiZ -o", + "SERVICE dict * *.fr 0 0 :French Dictionary", + "QUIT :Gone to have lunch", + ":syrk!kalt@millennium.stealth.net QUIT :Gone to have lunch", + "SQUIT tolsun.oulu.fi :Bad Link ?", + ":Trillian SQUIT cm22.eng.umd.edu :Server out of control", + "JOIN #foobar", + "JOIN &foo fubar", + "JOIN #foo,&bar fubar", + "JOIN #foo,#bar fubar,foobar", + "JOIN #foo,#bar", + "JOIN 0", + ":WiZ!jto@tolsun.oulu.fi JOIN #Twilight_zone", + "PART #twilight_zone", + "PART #oz-ops,&group5", + ":WiZ!jto@tolsun.oulu.fi PART #playzone :I lost", + "MODE #Finnish +imI *!*@*.fi", + "MODE #Finnish +o Kilroy", + "MODE #Finnish +v Wiz", + "MODE #Fins -s", + "MODE #42 +k oulu", + "MODE #42 -k oulu", + "MODE #eu-opers +l 10", + ":WiZ!jto@tolsun.oulu.fi MODE #eu-opers -l", + "MODE &oulu +b", + "MODE &oulu +b *!*@*", + "MODE &oulu +b *!*@*.edu +e *!*@*.bu.edu", + "MODE #bu +be *!*@*.edu *!*@*.bu.edu", + "MODE #meditation e", + "MODE #meditation I", + "MODE !12345ircd O", + ":WiZ!jto@tolsun.oulu.fi TOPIC #test :New topic", + "TOPIC #test :another topic", + "TOPIC #test :", + "TOPIC #test", + "NAMES #twilight_zone,#42", + "NAMES", + "LIST", + "LIST #twilight_zone,#42", + ":Angel!wings@irc.org INVITE Wiz #Dust", + "INVITE Wiz #Twilight_Zone", + "KICK &Melbourne Matthew", + "KICK #Finnish John :Speaking English", + ":WiZ!jto@tolsun.oulu.fi KICK #Finnish John", + ":Angel!wings@irc.org PRIVMSG Wiz :Are you receiving this message ?", + "PRIVMSG Angel :yes I'm receiving it !", + "PRIVMSG jto@tolsun.oulu.fi :Hello !", + "PRIVMSG kalt%millennium.stealth.net@irc.stealth.net :Are you a frog?", + "PRIVMSG kalt%millennium.stealth.net :Do you like cheese?", + "PRIVMSG Wiz!jto@tolsun.oulu.fi :Hello !", + "PRIVMSG $*.fi :Server tolsun.oulu.fi rebooting.", + "PRIVMSG #*.edu :NSFNet is undergoing work, expect interruptions", + "VERSION tolsun.oulu.fi", + "STATS m", + "LINKS *.au", + "LINKS *.edu *.bu.edu", + "TIME tolsun.oulu.fi", + "CONNECT tolsun.oulu.fi 6667", + "TRACE *.oulu.fi", + "ADMIN tolsun.oulu.fi", + "ADMIN syrk", + "INFO csd.bu.edu", + "INFO Angel", + "SQUERY irchelp :HELP privmsg", + "SQUERY dict@irc.fr :fr2en blaireau", + "WHO *.fi", + "WHO jto* o", + "WHOIS wiz", + "WHOIS eff.org trillian", + "WHOWAS Wiz", + "WHOWAS Mermaid 9", + "WHOWAS Trillian 1 *.edu", + "PING tolsun.oulu.fi", + "PING WiZ tolsun.oulu.fi", + // Below fails, we don't use the (unnecessary) colon. + // "PING :irc.funet.fi", + "PONG csd.bu.edu tolsun.oulu.fi", + "ERROR :Server *.fi already exists", + "NOTICE WiZ :ERROR from csd.bu.edu -- Server *.fi already exists", + "AWAY :Gone to lunch. Back in 5", + "REHASH", + "DIE", + "RESTART", + "SUMMON jto", + "SUMMON jto tolsun.oulu.fi", + "USERS eff.org", + ":csd.bu.edu WALLOPS :Connect '*.uiuc.edu 6667' from Joshua", + "USERHOST Wiz Michael syrk", + // Below fails, we don't use the (unnecessary) colon. + // ":ircd.stealth.net 302 yournick :syrk=+syrk@millennium.stealth.net", + "ISON phone trillian WiZ jarlek Avalon Angel Monstah syrk", + + // Now for the torture test, specially crafted messages that might be + // "difficult" to handle. + "PRIVMSG foo ::)", // Test sending a colon as the first character. + "PRIVMSG foo :This is a test.", // Test sending a space. + "PRIVMSG foo :", // Empty last parameter. + "PRIVMSG foo :This is :a test.", // A "second" last parameter. +]; + +function run_test() { + add_test(testRFC2812Messages); + add_test(testBrokenUnrealMessages); + add_test(testNewLinesInMessages); + add_test(testLocalhost); + add_test(testTags); + + run_next_test(); +} + +/* + * Test round tripping parsing and then rebuilding the messages from RFC 2812. + */ +function testRFC2812Messages() { + for (let expectedStringMessage of testData) { + // Pass in an empty default origin in order to check this below. + let message = ircMessage(expectedStringMessage, ""); + + let stringMessage = ircAccount.prototype.buildMessage( + message.command, + message.params + ); + + // Let's do a little dance here...we don't rebuild the "source" of the + // message (the server does that), so when comparing our output message, we + // need to avoid comparing to that part. + if (message.origin) { + expectedStringMessage = expectedStringMessage.slice( + expectedStringMessage.indexOf(" ") + 1 + ); + } + + equal(stringMessage, expectedStringMessage); + } + + run_next_test(); +} + +// Unreal sends a couple of broken messages, see ircMessage in irc.jsm for a +// description of what's wrong. +function testBrokenUnrealMessages() { + let messages = { + // Two spaces after command. + ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters": { + rawMessage: + ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters", + command: "432", + params: ["", "#momo", "Erroneous Nickname: Illegal characters"], + origin: "gravel.mozilla.org", + user: undefined, + host: undefined, + source: "", + tags: new Map(), + }, + // An extraneous space at the end. + ":gravel.mozilla.org MODE #tckk +n ": { + rawMessage: ":gravel.mozilla.org MODE #tckk +n ", + command: "MODE", + params: ["#tckk", "+n"], + origin: "gravel.mozilla.org", + user: undefined, + host: undefined, + source: "", + tags: new Map(), + }, + // Two extraneous spaces at the end. + ":services.esper.net MODE #foo-bar +o foobar ": { + rawMessage: ":services.esper.net MODE #foo-bar +o foobar ", + command: "MODE", + params: ["#foo-bar", "+o", "foobar"], + origin: "services.esper.net", + user: undefined, + host: undefined, + source: "", + tags: new Map(), + }, + }; + + for (let messageStr in messages) { + deepEqual(messages[messageStr], ircMessage(messageStr, "")); + } + + run_next_test(); +} + +// After unescaping we can end up with line breaks inside of IRC messages. Test +// this edge case specifically. +function testNewLinesInMessages() { + let messages = { + ":test!Instantbir@host PRIVMSG #instantbird :First line\nSecond line": { + rawMessage: + ":test!Instantbir@host PRIVMSG #instantbird :First line\nSecond line", + command: "PRIVMSG", + params: ["#instantbird", "First line\nSecond line"], + origin: "test", + user: "Instantbir", + host: "host", + tags: new Map(), + source: "Instantbir@host", + }, + ":test!Instantbir@host PRIVMSG #instantbird :First line\r\nSecond line": { + rawMessage: + ":test!Instantbir@host PRIVMSG #instantbird :First line\r\nSecond line", + command: "PRIVMSG", + params: ["#instantbird", "First line\r\nSecond line"], + origin: "test", + user: "Instantbir", + host: "host", + tags: new Map(), + source: "Instantbir@host", + }, + }; + + for (let messageStr in messages) { + deepEqual(messages[messageStr], ircMessage(messageStr)); + } + + run_next_test(); +} + +// Sometimes it is a bit hard to tell whether a prefix is a nickname or a +// servername. Generally this happens when connecting to localhost or a local +// hostname and is likely seen with bouncers. +function testLocalhost() { + let messages = { + ":localhost 001 clokep :Welcome to the BitlBee gateway, clokep": { + rawMessage: + ":localhost 001 clokep :Welcome to the BitlBee gateway, clokep", + command: "001", + params: ["clokep", "Welcome to the BitlBee gateway, clokep"], + origin: "localhost", + user: undefined, + host: undefined, + tags: new Map(), + source: "", + }, + }; + + for (let messageStr in messages) { + deepEqual(messages[messageStr], ircMessage(messageStr)); + } + + run_next_test(); +} + +function testTags() { + let messages = { + "@aaa=bBb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello": { + rawMessage: + "@aaa=bBb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello", + command: "PRIVMSG", + params: ["me", "Hello"], + origin: "nick", + user: "ident", + host: "host.com", + tags: new Map([ + ["aaa", "bBb"], + ["ccc", undefined], + ["example.com/ddd", "eee"], + ]), + source: "ident@host.com", + }, + "@xn--e1afmkfd.org/foo :nick@host.com PRIVMSG him :Test": { + rawMessage: "@xn--e1afmkfd.org/foo :nick@host.com PRIVMSG him :Test", + command: "PRIVMSG", + params: ["him", "Test"], + origin: "nick", + // Note that this is a bug, it should be undefined for user and host.com + // for host/source. + user: "host.com", + host: undefined, + tags: new Map([["xn--e1afmkfd.org/foo", undefined]]), + source: "host.com@undefined", + }, + "@aaa=\\\\n\\:\\n\\r\\s :nick@host.com PRIVMSG it :Yes": { + rawMessage: "@aaa=\\\\n\\:\\n\\r\\s :nick@host.com PRIVMSG it :Yes", + command: "PRIVMSG", + params: ["it", "Yes"], + origin: "nick", + // Note that this is a bug, it should be undefined for user and host.com + // for host/source. + user: "host.com", + host: undefined, + tags: new Map([["aaa", "\\n;\n\r "]]), + source: "host.com@undefined", + }, + "@c;h=;a=b :quux ab cd": { + rawMessage: "@c;h=;a=b :quux ab cd", + command: "ab", + params: ["cd"], + origin: "quux", + user: undefined, + host: undefined, + tags: new Map([ + ["c", undefined], + ["h", ""], + ["a", "b"], + ]), + source: "", + }, + "@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan": { + rawMessage: + "@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan", + command: "JOIN", + params: ["#chan"], + origin: "John", + user: "~john", + host: "1.2.3.4", + tags: new Map([["time", "2012-06-30T23:59:60.419Z"]]), + source: "~john@1.2.3.4", + }, + }; + + for (let messageStr in messages) { + deepEqual(messages[messageStr], ircMessage(messageStr, "")); + } + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_ircNonStandard.js b/comm/chat/protocols/irc/test/test_ircNonStandard.js new file mode 100644 index 0000000000..bcc445e661 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircNonStandard.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ircMessage } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); +const { ircNonStandard } = ChromeUtils.importESModule( + "resource:///modules/ircNonStandard.sys.mjs" +); + +// The function that is under test here. +var NOTICE = ircNonStandard.commands.NOTICE; + +function FakeConversation() {} +FakeConversation.prototype = { + writeMessage(aSender, aTarget, aOpts) {}, +}; + +function FakeAccount(aPassword) { + this.imAccount = { + password: aPassword, + }; + this.buffer = []; + this.convs = []; +} +FakeAccount.prototype = { + connected: false, + shouldAuthenticate: undefined, + _nickname: "nick", // Can be anything except "auth" for most tests. + sendMessage(aCommand, aParams) { + this.buffer.push([aCommand, aParams]); + }, + gotDisconnected(aReason, aMsg) { + this.connected = false; + }, + getConversation(aName) { + this.convs.push(aName); + return new FakeConversation(); + }, +}; + +function run_test() { + add_test(testSecureList); + add_test(testZncAuth); + add_test(testUMich); + add_test(testAuthNick); + add_test(testIgnoredNotices); + + run_next_test(); +} + +/* + * Test that SECURELIST properly sets the timer such that another LIST call can + * happen soon. See bug 1082501. + */ +function testSecureList() { + const kSecureListMsg = + ":fripp.mozilla.org NOTICE aleth-build :*** You cannot list within the first 60 seconds of connecting. Please try again later."; + + let message = ircMessage(kSecureListMsg, ""); + let account = new FakeAccount(); + account.connected = true; + let result = NOTICE.call(account, message); + + // Yes, it was handled. + ok(result); + + // Undo the expected calculation, this should be near 0. + let value = account._lastListTime - Date.now() - 60000 + 12 * 60 * 60 * 1000; + // Give some wiggle room. + less(Math.abs(value), 5 * 1000); + + run_next_test(); +} + +/* + * ZNC allows a client to send PASS after connection has occurred if it has not + * yet been provided. See bug 955244, bug 1197584. + */ +function testZncAuth() { + const kZncMsgs = [ + ":irc.znc.in NOTICE AUTH :*** You need to send your password. Try /quote PASS <username>:<password>", + ":irc.znc.in NOTICE AUTH :*** You need to send your password. Configure your client to send a server password.", + ]; + + for (let msg of kZncMsgs) { + let message = ircMessage(msg, ""); + // No provided password. + let account = new FakeAccount(); + let result = NOTICE.call(account, message); + + // Yes, it was handled. + Assert.ok(result); + + // No sent data and parameters should be unchanged. + equal(account.buffer.length, 0); + equal(account.shouldAuthenticate, undefined); + + // With a password. + account = new FakeAccount("password"); + result = NOTICE.call(account, message); + + // Yes, it was handled. + ok(result); + + // Check if the proper message was sent. + let sent = account.buffer[0]; + equal(sent[0], "PASS"); + equal(sent[1], "password"); + equal(account.buffer.length, 1); + + // Don't try to authenticate with NickServ. + equal(account.shouldAuthenticate, false); + + // Finally, check if the message is wrong. + account = new FakeAccount("password"); + message.params[1] = "Test"; + result = NOTICE.call(account, message); + + // This would be handled as a normal NOTICE. + equal(result, false); + } + + run_next_test(); +} + +/* + * irc.umich.edu sends a lot of garbage and has a non-standard captcha. See bug + * 954350. + */ +function testUMich() { + // The above should not print out. + const kMsgs = [ + "NOTICE AUTH :*** Processing connection to irc.umich.edu", + "NOTICE AUTH :*** Looking up your hostname...", + "NOTICE AUTH :*** Checking Ident", + "NOTICE AUTH :*** Found your hostname", + "NOTICE AUTH :*** No Ident response", + ]; + + const kFinalMsg = + ':irc.umich.edu NOTICE clokep :To complete your connection to this server, type "/QUOTE PONG :cookie", where cookie is the following ascii.'; + + let account = new FakeAccount(); + for (let msg of kMsgs) { + let message = ircMessage(msg, ""); + let result = NOTICE.call(account, message); + + // These initial notices are not handled (i.e. they'll be subject to + // _showServerTab). + equal(result, false); + } + + // And finally the last one should be printed out, always. It contains the + // directions of what to do next. + let message = ircMessage(kFinalMsg, ""); + let result = NOTICE.call(account, message); + ok(result); + equal(account.convs.length, 1); + equal(account.convs[0], "irc.umich.edu"); + + run_next_test(); +} + +/* + * Test an edge-case of the user having the nickname of auth. See bug 1083768. + */ +function testAuthNick() { + const kMsg = + ':irc.umich.edu NOTICE AUTH :To complete your connection to this server, type "/QUOTE PONG :cookie", where cookie is the following ascii.'; + + let account = new FakeAccount(); + account._nickname = "AUTH"; + + let message = ircMessage(kMsg, ""); + let result = NOTICE.call(account, message); + + // Since it is ambiguous if it was an authentication message or a message + // directed at the user, print it out. + ok(result); + + run_next_test(); +} + +/* + * We ignore some messages that are annoying to the user and offer little value. + * "Ignore" in this context means subject to the normal NOTICE processing. + */ +function testIgnoredNotices() { + const kMsgs = [ + // moznet sends a welcome message which is useless. + ":levin.mozilla.org NOTICE Auth :Welcome to \u0002Mozilla\u0002!", + // Some servers (oftc) send a NOTICE that isn't an auth, but notifies about + // the connection. See bug 1182735. + ":beauty.oftc.net NOTICE myusername :*** Connected securely via UNKNOWN AES128-SHA-128", + ]; + + for (let msg of kMsgs) { + let account = new FakeAccount(); + + let message = ircMessage(msg, ""); + let result = NOTICE.call(account, message); + + // This message should *NOT* be shown. + equal(result, false); + } + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_ircProtocol.js b/comm/chat/protocols/irc/test/test_ircProtocol.js new file mode 100644 index 0000000000..f4394b4115 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircProtocol.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ircProtocol } = ChromeUtils.importESModule( + "resource:///modules/irc.sys.mjs" +); + +add_task(function test_splitUsername() { + const bareUsername = "foobar"; + const bareSplit = ircProtocol.prototype.splitUsername(bareUsername); + deepEqual(bareSplit, []); + + const fullAccountName = "foobar@example.com"; + const fullSplit = ircProtocol.prototype.splitUsername(fullAccountName); + deepEqual(fullSplit, ["foobar", "example.com"]); + + const extraAt = "foo@bar@example.com"; + const extraSplit = ircProtocol.prototype.splitUsername(extraAt); + deepEqual(extraSplit, ["foo@bar", "example.com"]); +}); diff --git a/comm/chat/protocols/irc/test/test_ircServerTime.js b/comm/chat/protocols/irc/test/test_ircServerTime.js new file mode 100644 index 0000000000..9f91ab7432 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_ircServerTime.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { tagServerTime } = ChromeUtils.importESModule( + "resource:///modules/ircServerTime.sys.mjs" +); +var { ircMessage } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +function getTags(aRawMsg) { + const { tags } = ircMessage(aRawMsg, "does.not@matter"); + + return tags; +} + +function run_test() { + add_test(specMessages); + + run_next_test(); +} + +function specMessages() { + const kMessages = [ + { + tags: getTags( + "@time=2011-10-19T16:40:51.620Z :Angel!angel@example.com PRIVMSG #test :Hello" + ), + who: "Angel!angel@example.com", + get originalMessage() { + return "Hello"; + }, + message: "Hello", + incoming: true, + }, + { + tags: getTags( + "@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan" + ), + who: "John!~john@1.2.3.4", + message: "John joined #chan", + get originalMessage() { + return "John joined #chan"; + }, + system: true, + incoming: true, + }, + { + tags: getTags( + "@znc.in/server-time-iso=2016-11-13T19:20:45.284Z :John!~john@1.2.3.4 JOIN #chan" + ), + who: "John!~john@1.2.3.4", + message: "John joined #chan", + get originalMessage() { + return "John joined #chan"; + }, + system: true, + incoming: true, + }, + { + tags: getTags("@time= :empty!Empty@host.local JOIN #test"), + who: "empty!Empty@localhost", + message: "Empty joined #test", + get originalMessage() { + return "Empty joined #test"; + }, + system: true, + incoming: true, + }, + { + tags: getTags("NoTags!notags@1.2.3.4 PART #test"), + who: "NoTags!notags@1.2.3.4", + message: "NoTags left #test", + get originalMessage() { + return "NoTags left #test"; + }, + system: true, + incoming: true, + }, + ]; + + const kExpectedTimes = [ + Math.floor(Date.parse(kMessages[0].tags.get("time")) / 1000), + Math.floor(Date.parse("2012-06-30T23:59:59.999Z") / 1000), + Math.floor( + Date.parse(kMessages[2].tags.get("znc.in/server-time-iso")) / 1000 + ), + undefined, + undefined, + ]; + + for (let m in kMessages) { + const msg = kMessages[m]; + const isZNC = kMessages[m].tags.has("znc.in/server-time-iso"); + const tag = isZNC ? "znc.in/server-time-iso" : "time"; + const tagMessage = { + message: Object.assign({}, msg), + tagName: tag, + tagValue: msg.tags.get(tag), + }; + tagServerTime.commands[tag](tagMessage); + + // Ensuring that the expected properties and their values as given in + // kMessages are still the same after the handler. + for (let i in msg) { + equal( + tagMessage.message[i], + msg[i], + "Property '" + i + "' was not modified" + ); + } + // The time should only be adjusted when we expect a valid server-time tag. + equal( + "time" in tagMessage.message, + kExpectedTimes[m] !== undefined, + "Message time was set when expected" + ); + + if (kExpectedTimes[m] !== undefined) { + ok(tagMessage.message.delayed, "Delayed flag was set"); + equal( + kExpectedTimes[m], + tagMessage.message.time, + "Time was parsed properly" + ); + } + } + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_sendBufferedCommand.js b/comm/chat/protocols/irc/test/test_sendBufferedCommand.js new file mode 100644 index 0000000000..5558979db3 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_sendBufferedCommand.js @@ -0,0 +1,199 @@ +/* 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/. */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { ircAccount } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); +var { clearTimeout, setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +function FakeAccount() { + this._commandBuffers = new Map(); + this.callbacks = []; +} +FakeAccount.prototype = { + __proto__: ircAccount.prototype, + maxMessageLength: 60, + callbacks: [], + sendMessage(aCommand, aParams) { + this.callbacks.shift()(aCommand, aParams); + }, +}; + +var account = new FakeAccount(); + +function run_test() { + test_parameterCollect(); + test_maxLength(); + run_next_test(); +} + +function test_parameterCollect() { + // Individual tests, data consisting of [channel, key] pairs. + let tests = [ + { + data: [["one"], ["one"]], // also tests deduplication + result: "JOIN one", + }, + { + data: [["one", ""]], // explicit empty password string + result: "JOIN one", + }, + { + data: [["one"], ["two"], ["three"]], + result: "JOIN one,two,three", + }, + { + data: [["one"], ["two", "password"], ["three"]], + result: "JOIN two,one,three password", + }, + { + data: [ + ["one"], + ["two", "password"], + ["three"], + ["four", "anotherpassword"], + ], + result: "JOIN two,four,one,three password,anotherpassword", + }, + ]; + + for (let test of tests) { + let timeout; + // Destructure test to local variables so each function + // generated here gets the correct value in its scope. + let { data, result } = test; + account.callbacks.push((aCommand, aParams) => { + let msg = account.buildMessage(aCommand, aParams); + equal(msg, result, "Test buffering of parameters"); + clearTimeout(timeout); + account._lastCommandSendTime = 0; + run_next_test(); + }); + add_test(() => { + // This timeout lets the test fail more quickly if + // some of the callbacks we added don't get called. + // Not strictly speaking necessary. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + timeout = setTimeout(() => { + ok(false, "test_parameterCollect failed after timeout."); + run_next_test(); + }, 2000); + for (let [channel, key] of data) { + account.sendBufferedCommand("JOIN", channel, key); + } + }); + } + + // Test this still works when adding commands on different ticks of + // the event loop. + account._lastCommandSendTime = 0; + for (let test of tests) { + let timeout; + let { data, result } = test; + account.callbacks.push((aCommand, aParams) => { + let msg = account.buildMessage(aCommand, aParams); + equal(msg, result, "Test buffering with setTimeout"); + clearTimeout(timeout); + run_next_test(); + }); + add_test(() => { + // This timeout lets the test fail more quickly if + // some of the callbacks we added don't get called. + // Not strictly speaking necessary. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + timeout = setTimeout(() => { + ok(false, "test_parameterCollect failed after timeout."); + run_next_test(); + }, 2000); + let delay = 0; + for (let params of data) { + let [channel, key] = params; + delay += 200; + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => { + account.sendBufferedCommand("JOIN", channel, key); + }, delay); + } + }); + } +} + +function test_maxLength() { + let tests = [ + { + data: [ + ["applecustard"], + ["pearpie"], + ["strawberryfield"], + ["blueberrypancake"], + ["mangojuice"], + ["raspberryberet"], + ["pineapplesoup"], + ["limejelly"], + ["lemonsorbet"], + ], + results: [ + "JOIN applecustard,pearpie,strawberryfield,blueberrypancake", + "JOIN mangojuice,raspberryberet,pineapplesoup,limejelly", + "JOIN lemonsorbet", + ], + }, + { + data: [ + ["applecustard"], + ["pearpie"], + ["strawberryfield", "password1"], + ["blueberrypancake"], + ["mangojuice"], + ["raspberryberet"], + ["pineapplesoup"], + ["limejelly", "password2"], + ["lemonsorbet"], + ], + results: [ + "JOIN strawberryfield,applecustard,pearpie password1", + "JOIN blueberrypancake,mangojuice,raspberryberet", + "JOIN limejelly,pineapplesoup,lemonsorbet password2", + ], + }, + ]; + + account._lastCommandSendTime = 0; + for (let test of tests) { + let timeout; + // Destructure test to local variables so each function + // generated here gets the correct value in its scope. + let { data, results } = test; + for (let r of results) { + let result = r; + account.callbacks.push((aCommand, aParams) => { + let msg = account.buildMessage(aCommand, aParams); + equal(msg, result, "Test maximum message length constraint"); + // After all results are checked, run the next test. + if (result == results[results.length - 1]) { + clearTimeout(timeout); + run_next_test(); + } + }); + } + add_test(() => { + // This timeout lets the test fail more quickly if + // some of the callbacks we added don't get called. + // Not strictly speaking necessary. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + timeout = setTimeout(() => { + ok(false, "test_maxLength failed after timeout."); + run_next_test(); + }, 2000); + for (let [channel, key] of data) { + account.sendBufferedCommand("JOIN", channel, key); + } + }); + } +} diff --git a/comm/chat/protocols/irc/test/test_setMode.js b/comm/chat/protocols/irc/test/test_setMode.js new file mode 100644 index 0000000000..9a329beaa5 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_setMode.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { IMServices } = ChromeUtils.importESModule( + "resource:///modules/IMServices.sys.mjs" +); +var { ircAccount, ircChannel } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +IMServices.conversations.initConversations(); + +function FakeAccount() { + this.normalizeNick = ircAccount.prototype.normalizeNick.bind(this); +} +FakeAccount.prototype = { + __proto__: ircAccount.prototype, + setWhois: (n, f) => true, + ERROR: do_throw, +}; + +function run_test() { + add_test(test_topicSettable); + add_test(test_topicSettableJoinAsOp); + + run_next_test(); +} + +// Test joining a channel, then being set as op. +function test_topicSettable() { + let channel = new ircChannel(new FakeAccount(), "#test", "nick"); + // We're not in the room yet, so the topic is NOT editable. + equal(channel.topicSettable, false); + + // Join the room. + channel.getParticipant("nick"); + // The topic should be editable. + equal(channel.topicSettable, true); + + // Receive the channel mode. + channel.setMode("+t", [], "ChanServ"); + // Mode +t means that you need status to set the mode. + equal(channel.topicSettable, false); + + // Receive a user mode. + channel.setMode("+o", ["nick"], "ChanServ"); + // Nick is now an op and can set the topic! + equal(channel.topicSettable, true); + + run_next_test(); +} + +// Test when you join as an op (as opposed to being set to op after joining). +function test_topicSettableJoinAsOp() { + let channel = new ircChannel(new FakeAccount(), "#test", "nick"); + // We're not in the room yet, so the topic is NOT editable. + equal(channel.topicSettable, false); + + // Join the room as an op. + channel.getParticipant("@nick"); + // The topic should be editable. + equal(channel.topicSettable, true); + + // Receive the channel mode. + channel.setMode("+t", [], "ChanServ"); + // The topic should still be editable. + equal(channel.topicSettable, true); + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/test_splitLongMessages.js b/comm/chat/protocols/irc/test/test_splitLongMessages.js new file mode 100644 index 0000000000..b507d4ec99 --- /dev/null +++ b/comm/chat/protocols/irc/test/test_splitLongMessages.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { GenericIRCConversation, ircAccount } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +var messages = { + // Exactly 51 characters. + "This is a test.": ["This is a test."], + // Too long. + "This is a message that is too long.": [ + "This is a", + "message that is", + "too long.", + ], + // Too short. + "Short msg.": ["Short msg."], + "Thismessagecan'tbecut.": ["Thismessagecan'", "tbecut."], +}; + +function run_test() { + for (let message in messages) { + let msg = { message }; + let generatedMsgs = GenericIRCConversation.prepareForSending.call( + { + __proto__: GenericIRCConversation, + name: "target", + _account: { + __proto__: ircAccount.prototype, + _nickname: "sender", + prefix: "!user@host", + maxMessageLength: 51, // For convenience. + }, + }, + msg + ); + + // The expected messages as defined above. + let expectedMsgs = messages[message]; + // Ensure the arrays are equal. + deepEqual(generatedMsgs, expectedMsgs); + } +} diff --git a/comm/chat/protocols/irc/test/test_tryNewNick.js b/comm/chat/protocols/irc/test/test_tryNewNick.js new file mode 100644 index 0000000000..dbd2692d4c --- /dev/null +++ b/comm/chat/protocols/irc/test/test_tryNewNick.js @@ -0,0 +1,148 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { ircProtocol } = ChromeUtils.importESModule( + "resource:///modules/irc.sys.mjs" +); +var { ircAccount } = ChromeUtils.importESModule( + "resource:///modules/ircAccount.sys.mjs" +); + +var fakeProto = { + id: "fake-proto", + options: { alternateNicks: "" }, + _getOptionDefault(aOption) { + return this.options[aOption]; + }, + usernameSplits: ircProtocol.prototype.usernameSplits, + splitUsername: ircProtocol.prototype.splitUsername, +}; + +function test_tryNewNick() { + const testData = { + clokep: "clokep1", + clokep1: "clokep2", + clokep10: "clokep11", + clokep0: "clokep1", + clokep01: "clokep02", + clokep09: "clokep10", + + // Now put a number in the "first part". + clo1kep: "clo1kep1", + clo1kep1: "clo1kep2", + clo1kep10: "clo1kep11", + clo1kep0: "clo1kep1", + clo1kep01: "clo1kep02", + clo1kep09: "clo1kep10", + }; + + let account = new ircAccount(fakeProto, { + name: "clokep@instantbird.org", + }); + account.LOG = function (aStr) {}; + account.normalize = aStr => aStr; + + for (let currentNick in testData) { + account._sentNickname = currentNick; + account.sendMessage = (aCommand, aNewNick) => + equal(aNewNick, testData[currentNick]); + + account.tryNewNick(currentNick); + } + + run_next_test(); +} + +// This tests a bunch of cases near the max length by maintaining the state +// through a series of test nicks. +function test_maxLength() { + let testData = [ + // First try adding a digit, as normal. + ["abcdefghi", "abcdefghi1"], + // The "received" nick back will now be the same though, so it was too long. + ["abcdefghi", "abcdefgh1"], + // And just ensure we're iterating properly. + ["abcdefgh1", "abcdefgh2"], + ["abcdefgh2", "abcdefgh3"], + ["abcdefgh3", "abcdefgh4"], + ["abcdefgh4", "abcdefgh5"], + ["abcdefgh5", "abcdefgh6"], + ["abcdefgh6", "abcdefgh7"], + ["abcdefgh7", "abcdefgh8"], + ["abcdefgh8", "abcdefgh9"], + ["abcdefgh9", "abcdefgh10"], + ["abcdefgh1", "abcdefg10"], + ["abcdefg10", "abcdefg11"], + ["abcdefg99", "abcdefg100"], + ["abcdefg10", "abcdef100"], + ["a99999999", "a100000000"], + ["a10000000", "a00000000"], + ]; + + let account = new ircAccount(fakeProto, { + name: "clokep@instantbird.org", + }); + account.LOG = function (aStr) {}; + account._sentNickname = "abcdefghi"; + account.normalize = aStr => aStr; + + for (let currentNick of testData) { + account.sendMessage = (aCommand, aNewNick) => + equal(aNewNick, currentNick[1]); + + account.tryNewNick(currentNick[0]); + } + + run_next_test(); +} + +function test_altNicks() { + const altNicks = ["clokep_", "clokep|"]; + const testData = { + // Test account nick. + clokep: [altNicks, "clokep_"], + // Test first element in list. + clokep_: [altNicks, "clokep|"], + // Test last element in list. + "clokep|": [altNicks, "clokep|1"], + // Test element not in list with number at end. + clokep1: [altNicks, "clokep2"], + + // Test messy alternatives. + "clokep[": [" clokep ,\n clokep111,,,\tclokep[, clokep_", "clokep_"], + }; + + let account = new ircAccount(fakeProto, { + name: "clokep@instantbird.org", + }); + account.LOG = function (aStr) {}; + account.normalize = aStr => aStr; + + for (let currentNick in testData) { + // Only one pref is touched in here, override the default to return + // what this test needs. + account.getString = function (aStr) { + let data = testData[currentNick][0]; + if (Array.isArray(data)) { + return data.join(","); + } + return data; + }; + account._sentNickname = currentNick; + + account.sendMessage = (aCommand, aNewNick) => + equal(aNewNick, testData[currentNick][1]); + + account.tryNewNick(currentNick); + } + + run_next_test(); +} + +function run_test() { + add_test(test_tryNewNick); + add_test(test_maxLength); + add_test(test_altNicks); + + run_next_test(); +} diff --git a/comm/chat/protocols/irc/test/xpcshell.ini b/comm/chat/protocols/irc/test/xpcshell.ini new file mode 100644 index 0000000000..1f2e8bf907 --- /dev/null +++ b/comm/chat/protocols/irc/test/xpcshell.ini @@ -0,0 +1,18 @@ +[DEFAULT] +head = +tail = + +[test_ctcpFormatting.js] +[test_ctcpColoring.js] +[test_ctcpDequote.js] +[test_ctcpQuote.js] +[test_ircCAP.js] +[test_ircChannel.js] +[test_ircCommands.js] +[test_ircMessage.js] +[test_ircNonStandard.js] +[test_ircServerTime.js] +[test_sendBufferedCommand.js] +[test_setMode.js] +[test_splitLongMessages.js] +[test_tryNewNick.js] |