summaryrefslogtreecommitdiffstats
path: root/comm/chat/protocols/irc
diff options
context:
space:
mode:
Diffstat (limited to 'comm/chat/protocols/irc')
-rw-r--r--comm/chat/protocols/irc/components.conf15
-rw-r--r--comm/chat/protocols/irc/icons/prpl-irc-32.pngbin0 -> 695 bytes
-rw-r--r--comm/chat/protocols/irc/icons/prpl-irc-48.pngbin0 -> 1003 bytes
-rw-r--r--comm/chat/protocols/irc/icons/prpl-irc.pngbin0 -> 454 bytes
-rw-r--r--comm/chat/protocols/irc/irc.sys.mjs122
-rw-r--r--comm/chat/protocols/irc/ircAccount.sys.mjs2296
-rw-r--r--comm/chat/protocols/irc/ircBase.sys.mjs1768
-rw-r--r--comm/chat/protocols/irc/ircCAP.sys.mjs170
-rw-r--r--comm/chat/protocols/irc/ircCTCP.sys.mjs291
-rw-r--r--comm/chat/protocols/irc/ircCommands.sys.mjs599
-rw-r--r--comm/chat/protocols/irc/ircDCC.sys.mjs66
-rw-r--r--comm/chat/protocols/irc/ircEchoMessage.sys.mjs41
-rw-r--r--comm/chat/protocols/irc/ircHandlerPriorities.sys.mjs16
-rw-r--r--comm/chat/protocols/irc/ircHandlers.sys.mjs306
-rw-r--r--comm/chat/protocols/irc/ircISUPPORT.sys.mjs246
-rw-r--r--comm/chat/protocols/irc/ircMultiPrefix.sys.mjs60
-rw-r--r--comm/chat/protocols/irc/ircNonStandard.sys.mjs262
-rw-r--r--comm/chat/protocols/irc/ircSASL.sys.mjs179
-rw-r--r--comm/chat/protocols/irc/ircServerTime.sys.mjs80
-rw-r--r--comm/chat/protocols/irc/ircServices.sys.mjs317
-rw-r--r--comm/chat/protocols/irc/ircUtils.sys.mjs303
-rw-r--r--comm/chat/protocols/irc/ircWatchMonitor.sys.mjs467
-rw-r--r--comm/chat/protocols/irc/jar.mn9
-rw-r--r--comm/chat/protocols/irc/moz.build33
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpColoring.js72
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpDequote.js55
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpFormatting.js59
-rw-r--r--comm/chat/protocols/irc/test/test_ctcpQuote.js64
-rw-r--r--comm/chat/protocols/irc/test/test_ircCAP.js236
-rw-r--r--comm/chat/protocols/irc/test/test_ircChannel.js187
-rw-r--r--comm/chat/protocols/irc/test/test_ircCommands.js218
-rw-r--r--comm/chat/protocols/irc/test/test_ircMessage.js336
-rw-r--r--comm/chat/protocols/irc/test/test_ircNonStandard.js209
-rw-r--r--comm/chat/protocols/irc/test/test_ircProtocol.js20
-rw-r--r--comm/chat/protocols/irc/test/test_ircServerTime.js130
-rw-r--r--comm/chat/protocols/irc/test/test_sendBufferedCommand.js199
-rw-r--r--comm/chat/protocols/irc/test/test_setMode.js70
-rw-r--r--comm/chat/protocols/irc/test/test_splitLongMessages.js44
-rw-r--r--comm/chat/protocols/irc/test/test_tryNewNick.js148
-rw-r--r--comm/chat/protocols/irc/test/xpcshell.ini18
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
new file mode 100644
index 0000000000..003103914c
--- /dev/null
+++ b/comm/chat/protocols/irc/icons/prpl-irc-32.png
Binary files differ
diff --git a/comm/chat/protocols/irc/icons/prpl-irc-48.png b/comm/chat/protocols/irc/icons/prpl-irc-48.png
new file mode 100644
index 0000000000..606425fabb
--- /dev/null
+++ b/comm/chat/protocols/irc/icons/prpl-irc-48.png
Binary files differ
diff --git a/comm/chat/protocols/irc/icons/prpl-irc.png b/comm/chat/protocols/irc/icons/prpl-irc.png
new file mode 100644
index 0000000000..19d578deda
--- /dev/null
+++ b/comm/chat/protocols/irc/icons/prpl-irc.png
Binary files differ
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]