diff options
Diffstat (limited to 'comm/chat/protocols/xmpp/xmpp-base.sys.mjs')
-rw-r--r-- | comm/chat/protocols/xmpp/xmpp-base.sys.mjs | 3421 |
1 files changed, 3421 insertions, 0 deletions
diff --git a/comm/chat/protocols/xmpp/xmpp-base.sys.mjs b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs new file mode 100644 index 0000000000..f7e0ccd98e --- /dev/null +++ b/comm/chat/protocols/xmpp/xmpp-base.sys.mjs @@ -0,0 +1,3421 @@ +/* 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 { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { IMServices } from "resource:///modules/IMServices.sys.mjs"; +import { Status } from "resource:///modules/imStatusUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + executeSoon, + nsSimpleEnumerator, + EmptyEnumerator, + ClassInfo, + l10nHelper, +} from "resource:///modules/imXPCOMUtils.sys.mjs"; +import { + GenericAccountPrototype, + GenericAccountBuddyPrototype, + GenericConvIMPrototype, + GenericConvChatPrototype, + GenericConversationPrototype, + TooltipInfo, +} from "resource:///modules/jsProtoHelper.sys.mjs"; +import { NormalizedMap } from "resource:///modules/NormalizedMap.sys.mjs"; +import { + Stanza, + SupportedFeatures, +} from "resource:///modules/xmpp-xml.sys.mjs"; +import { XMPPSession } from "resource:///modules/xmpp-session.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "imgTools", + "@mozilla.org/image/tools;1", + "imgITools" +); + +XPCOMUtils.defineLazyGetter(lazy, "_", () => + l10nHelper("chrome://chat/locale/xmpp.properties") +); + +XPCOMUtils.defineLazyGetter(lazy, "TXTToHTML", function () { + let cs = Cc["@mozilla.org/txttohtmlconv;1"].getService(Ci.mozITXTToHTMLConv); + return aTxt => cs.scanTXT(aTxt, cs.kEntities); +}); + +// Parses the status from a presence stanza into an object of statusType, +// statusText and idleSince. +function parseStatus(aStanza) { + let statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; + let show = aStanza.getElement(["show"]); + if (show) { + show = show.innerText; + if (show == "away") { + statusType = Ci.imIStatusInfo.STATUS_AWAY; + } else if (show == "chat") { + statusType = Ci.imIStatusInfo.STATUS_AVAILABLE; // FIXME + } else if (show == "dnd") { + statusType = Ci.imIStatusInfo.STATUS_UNAVAILABLE; + } else if (show == "xa") { + statusType = Ci.imIStatusInfo.STATUS_IDLE; + } + } + + let idleSince = 0; + let date = _getDelay(aStanza); + if (date) { + idleSince = date.getTime(); + } + + let query = aStanza.getElement(["query"]); + if (query && query.uri == Stanza.NS.last) { + let now = Math.floor(Date.now() / 1000); + idleSince = now - parseInt(query.attributes.seconds, 10); + statusType = Ci.imIStatusInfo.STATUS_IDLE; + } + + let status = aStanza.getElement(["status"]); + status = status ? status.innerText : ""; + + return { statusType, statusText: status, idleSince }; +} + +// Returns a Date object for the delay value (stamp) in aStanza if it exists, +// otherwise returns undefined. +function _getDelay(aStanza) { + // XEP-0203: Delayed Delivery. + let date; + let delay = aStanza.getElement(["delay"]); + if (delay && delay.uri == Stanza.NS.delay) { + if (delay.attributes.stamp) { + date = new Date(delay.attributes.stamp); + } + } + if (date && isNaN(date.getTime())) { + return undefined; + } + + return date; +} + +// Writes aMsg in aConv as an outgoing message with optional date as the +// message may be sent from another client. +function _displaySentMsg(aConv, aMsg, aDate) { + let who; + if (aConv._account._connection) { + who = aConv._account._connection._jid.jid; + } + if (!who) { + who = aConv._account.name; + } + + let flags = { outgoing: true }; + flags._alias = aConv.account.alias || aConv.account.statusInfo.displayName; + + if (aDate) { + flags.time = aDate / 1000; + flags.delayed = true; + } + aConv.writeMessage(who, aMsg, flags); +} + +// The timespan after which we consider roomInfo to be stale. +var kListRefreshInterval = 12 * 60 * 60 * 1000; // 12 hours. + +/* This is an ordered list, used to determine chat buddy flags: + * index = member -> voiced + * moderator -> moderator + * admin -> admin + * owner -> founder + */ +var kRoles = [ + "outcast", + "visitor", + "participant", + "member", + "moderator", + "admin", + "owner", +]; + +function MUCParticipant(aNick, aJid, aPresenceStanza) { + this._jid = aJid; + this.name = aNick; + this.onPresenceStanza(aPresenceStanza); +} +MUCParticipant.prototype = { + __proto__: ClassInfo("prplIConvChatBuddy", "XMPP ConvChatBuddy object"), + + buddy: false, + + // The occupant jid of the participant which is of the form room@domain/nick. + _jid: null, + + // The real jid of the participant which is of the form local@domain/resource. + accountJid: null, + + statusType: null, + statusText: null, + get alias() { + return this.name; + }, + + role: 2, // "participant" by default + + // Called when a presence stanza is received for this participant. + onPresenceStanza(aStanza) { + let statusInfo = parseStatus(aStanza); + this.statusType = statusInfo.statusType; + this.statusText = statusInfo.statusText; + + let x = aStanza.children.filter( + child => child.localName == "x" && child.uri == Stanza.NS.muc_user + ); + if (x.length == 0) { + return; + } + + // XEP-0045 (7.2.3): We only expect a single <x/> element of this namespace, + // so we ignore any others. + x = x[0]; + + let item = x.getElement(["item"]); + if (!item) { + return; + } + + this.role = Math.max( + kRoles.indexOf(item.attributes.role), + kRoles.indexOf(item.attributes.affiliation) + ); + + let accountJid = item.attributes.jid; + if (accountJid) { + this.accountJid = accountJid; + } + }, + + get voiced() { + /* FIXME: The "voiced" role corresponds to users that can send messages to + * the room. If the chat is unmoderated, this should include everyone, not + * just members. */ + return this.role == kRoles.indexOf("member"); + }, + get moderator() { + return this.role == kRoles.indexOf("moderator"); + }, + get admin() { + return this.role == kRoles.indexOf("admin"); + }, + get founder() { + return this.role == kRoles.indexOf("owner"); + }, + typing: false, +}; + +// MUC (Multi-User Chat) +export var XMPPMUCConversationPrototype = { + __proto__: GenericConvChatPrototype, + // By default users are not in a MUC. + _left: true, + + // Tracks all received messages to avoid possible duplication if the server + // sends us the last few messages again when we rejoin a room. + _messageIds: new Set(), + + _init(aAccount, aJID, aNick) { + this._messageIds = new Set(); + GenericConvChatPrototype._init.call(this, aAccount, aJID, aNick); + }, + + _targetResource: "", + + // True while we are rejoining a room previously parted by the user. + _rejoined: false, + + get topic() { + return this._topic; + }, + set topic(aTopic) { + // XEP-0045 (8.1): Modifying the room subject. + let subject = Stanza.node("subject", null, null, aTopic.trim()); + let s = Stanza.message( + this.name, + null, + null, + { type: "groupchat" }, + subject + ); + let notAuthorized = lazy._( + "conversation.error.changeTopicFailedNotAuthorized" + ); + this._account.sendStanza( + s, + this._account.handleErrors( + { + forbidden: notAuthorized, + notAcceptable: notAuthorized, + itemNotFound: notAuthorized, + }, + this + ) + ); + }, + get topicSettable() { + return true; + }, + + /* Called when the user enters a chat message */ + dispatchMessage(aMsg, aAction = false) { + if (aAction) { + // XEP-0245: The /me Command. + // We need to prepend "/me " as the first four characters of the message + // body. + aMsg = "/me " + aMsg; + } + // XEP-0045 (7.4): Sending a message to all occupants in a room. + let s = Stanza.message(this.name, aMsg, null, { type: "groupchat" }); + let notInRoom = lazy._( + "conversation.error.sendFailedAsNotInRoom", + this.name, + aMsg + ); + this._account.sendStanza( + s, + this._account.handleErrors( + { + itemNotFound: notInRoom, + notAcceptable: notInRoom, + }, + this + ) + ); + }, + + /* Called by the account when a presence stanza is received for this muc */ + onPresenceStanza(aStanza) { + let from = aStanza.attributes.from; + let nick = this._account._parseJID(from).resource; + let jid = this._account.normalize(from); + let x = aStanza + .getElements(["x"]) + .find( + e => e.uri == Stanza.NS.muc_user || e.uri == Stanza.NS.vcard_update + ); + + // Check if the join failed. + if (this.left && aStanza.attributes.type == "error") { + let error = this._account.parseError(aStanza); + let message; + switch (error.condition) { + case "not-authorized": + case "registration-required": + // XEP-0045 (7.2.7): Members-Only Rooms. + message = lazy._("conversation.error.joinFailedNotAuthorized"); + break; + case "not-allowed": + message = lazy._("conversation.error.creationFailedNotAllowed"); + break; + case "remote-server-not-found": + message = lazy._( + "conversation.error.joinFailedRemoteServerNotFound", + this.name + ); + break; + case "forbidden": + // XEP-0045 (7.2.8): Banned users. + message = lazy._("conversation.error.joinForbidden", this.name); + break; + default: + message = lazy._("conversation.error.joinFailed", this.name); + this.ERROR("Failed to join MUC: " + aStanza.convertToString()); + break; + } + this.writeMessage(this.name, message, { system: true, error: true }); + this.joining = false; + return; + } + + if (!x) { + this.WARN( + "Received a MUC presence stanza without an x element or " + + "with a namespace we don't handle." + ); + return; + } + // Handle a MUC resource avatar + if ( + x.uri == Stanza.NS.vcard_update && + aStanza.attributes.from == this.normalizedName + ) { + let photo = aStanza.getElement(["x", "photo"]); + if (photo && photo.uri == Stanza.NS.vcard_update) { + let hash = photo.innerText; + if (hash && hash != this._photoHash) { + this._account._addVCardRequest(this.normalizedName); + } else if (!hash && this._photoHash) { + delete this._photoHash; + this.convIconFilename = ""; + } + } + return; + } + let codes = x.getElements(["status"]).map(elt => elt.attributes.code); + let item = x.getElement(["item"]); + + // Changes the nickname of a participant for this muc. + let changeNick = () => { + if (!item || !item.attributes.nick) { + this.WARN( + "Received a MUC presence code 303 or 210 stanza without an " + + "item element or a nick attribute." + ); + return; + } + let newNick = item.attributes.nick; + this.updateNick(nick, newNick, nick == this.nick); + }; + + if (aStanza.attributes.type == "unavailable") { + if (!this._participants.has(nick)) { + this.WARN( + "received unavailable presence for an unknown MUC participant: " + + from + ); + return; + } + if (codes.includes("303")) { + // XEP-0045 (7.6): Changing Nickname. + // Service Updates Nick for user. + changeNick(); + return; + } + if (item && item.attributes.role == "none") { + // XEP-0045: an occupant has left the room. + this.removeParticipant(nick); + + // Who caused the participant to leave the room. + let actor = item.getElement(["actor"]); + let actorNick = actor ? actor.attributes.nick : ""; + let isActor = actorNick ? ".actor" : ""; + + // Why the participant left. + let reasonNode = item.getElement(["reason"]); + let reason = reasonNode ? reasonNode.innerText : ""; + let isReason = reason ? ".reason" : ""; + + let isYou = nick == this.nick ? ".you" : ""; + let affectedNick = isYou ? "" : nick; + if (isYou) { + this.left = true; + } + + let message; + if (codes.includes("301")) { + // XEP-0045 (9.1): Banning a User. + message = "conversation.message.banned"; + } else if (codes.includes("307")) { + // XEP-0045 (8.2): Kicking an Occupant. + message = "conversation.message.kicked"; + } else if (codes.includes("322") || codes.includes("321")) { + // XEP-0045: Inform user that he or she is being removed from the + // room because the room has been changed to members-only and the + // user is not a member. + message = "conversation.message.removedNonMember"; + } else if (codes.includes("332")) { + // XEP-0045: Inform user that he or she is being removed from the + // room because the MUC service is being shut down. + message = "conversation.message.mucShutdown"; + + // The reason here just duplicates what's in the system message. + reason = isReason = ""; + } else { + // XEP-0045 (7.14): Received when the user parts a room. + message = "conversation.message.parted"; + + // The reason is in a status element in this case. + reasonNode = aStanza.getElement(["status"]); + reason = reasonNode ? reasonNode.innerText : ""; + isReason = reason ? ".reason" : ""; + } + + if (message) { + let messageID = message + isYou + isActor + isReason; + let params = [actorNick, affectedNick, reason].filter(s => s); + this.writeMessage(this.name, lazy._(messageID, ...params), { + system: true, + }); + } + } else { + this.WARN("Unhandled type==unavailable MUC presence stanza."); + } + return; + } + + if (codes.includes("201")) { + // XEP-0045 (10.1): Creating room. + // Service Acknowledges Room Creation + // and Room is awaiting configuration. + // XEP-0045 (10.1.2): Instant room. + let query = Stanza.node( + "query", + Stanza.NS.muc_owner, + null, + Stanza.node("x", Stanza.NS.xdata, { type: "submit" }) + ); + let s = Stanza.iq("set", null, jid, query); + this._account.sendStanza(s, aStanzaReceived => { + if (aStanzaReceived.attributes.type != "result") { + return false; + } + + // XEP-0045: Service Informs New Room Owner of Success + // for instant and reserved rooms. + this.left = false; + this.joining = false; + return true; + }); + } else if (codes.includes("210")) { + // XEP-0045 (7.6): Changing Nickname. + // Service modifies this user's nickname in accordance with local service + // policies. + changeNick(); + return; + } else if (codes.includes("110")) { + // XEP-0045: Room exists and joined successfully. + this.left = false; + this.joining = false; + // TODO (Bug 1172350): Implement Service Discovery Extensions (XEP-0128) to obtain + // configuration of this room. + } else if (codes.includes("104") && nick == this.name) { + // https://xmpp.org/extensions/inbox/muc-avatars.html (XEP-XXXX) + this._account._addVCardRequest(this.normalizedName); + } + + if (!this._participants.get(nick)) { + let participant = new MUCParticipant(nick, from, aStanza); + this._participants.set(nick, participant); + this.notifyObservers( + new nsSimpleEnumerator([participant]), + "chat-buddy-add" + ); + if (this.nick != nick && !this.joining) { + this.writeMessage( + this.name, + lazy._("conversation.message.join", nick), + { + system: true, + } + ); + } else if (this.nick == nick && this._rejoined) { + this.writeMessage(this.name, lazy._("conversation.message.rejoined"), { + system: true, + }); + this._rejoined = false; + } + } else { + this._participants.get(nick).onPresenceStanza(aStanza); + this.notifyObservers(this._participants.get(nick), "chat-buddy-update"); + } + }, + + /* Called by the account when a message is received for this muc */ + incomingMessage(aMsg, aStanza, aDate) { + let from = this._account._parseJID(aStanza.attributes.from).resource; + let id = aStanza.attributes.id; + let flags = {}; + if (!from) { + flags.system = true; + from = this.name; + } else if (aStanza.attributes.type == "error") { + aMsg = lazy._("conversation.error.notDelivered", aMsg); + flags.system = true; + flags.error = true; + } else if (from == this._nick) { + flags.outgoing = true; + } else { + flags.incoming = true; + } + if (aDate) { + flags.time = aDate / 1000; + flags.delayed = true; + } + if (id) { + // Checks if a message exists in conversation to avoid duplication. + if (this._messageIds.has(id)) { + return; + } + this._messageIds.add(id); + } + this.writeMessage(from, aMsg, flags); + }, + + getNormalizedChatBuddyName(aNick) { + return this._account.normalizeFullJid(this.name + "/" + aNick); + }, + + // Leaves MUC conversation. + part(aMsg = null) { + let s = Stanza.presence( + { to: this.name + "/" + this._nick, type: "unavailable" }, + aMsg ? Stanza.node("status", null, null, aMsg.trim()) : null + ); + this._account.sendStanza(s); + delete this.chatRoomFields; + }, + + // Invites a user to MUC conversation. + invite(aJID, aMsg = null) { + // XEP-0045 (7.8): Inviting Another User to a Room. + // XEP-0045 (7.8.2): Mediated Invitation. + let invite = Stanza.node( + "invite", + null, + { to: aJID }, + aMsg ? Stanza.node("reason", null, null, aMsg) : null + ); + let x = Stanza.node("x", Stanza.NS.muc_user, null, invite); + let s = Stanza.node("message", null, { to: this.name }, x); + this._account.sendStanza( + s, + this._account.handleErrors( + { + forbidden: lazy._("conversation.error.inviteFailedForbidden"), + // ejabberd uses error not-allowed to indicate that this account does not + // have the required privileges to invite users instead of forbidden error, + // and this is not mentioned in the spec (XEP-0045). + notAllowed: lazy._("conversation.error.inviteFailedForbidden"), + itemNotFound: lazy._("conversation.error.failedJIDNotFound", aJID), + }, + this + ) + ); + }, + + // Bans a participant from MUC conversation. + ban(aNickName, aMsg = null) { + // XEP-0045 (9.1): Banning a User. + let participant = this._participants.get(aNickName); + if (!participant) { + this.writeMessage( + this.name, + lazy._("conversation.error.nickNotInRoom", aNickName), + { system: true } + ); + return; + } + if (!participant.accountJid) { + this.writeMessage( + this.name, + lazy._("conversation.error.banCommandAnonymousRoom"), + { system: true } + ); + return; + } + + let attributes = { affiliation: "outcast", jid: participant.accountJid }; + let item = Stanza.node( + "item", + null, + attributes, + aMsg ? Stanza.node("reason", null, null, aMsg) : null + ); + let s = Stanza.iq( + "set", + null, + this.name, + Stanza.node("query", Stanza.NS.muc_admin, null, item) + ); + this._account.sendStanza(s, this._banKickHandler, this); + }, + + // Kicks a participant from MUC conversation. + kick(aNickName, aMsg = null) { + // XEP-0045 (8.2): Kicking an Occupant. + let attributes = { role: "none", nick: aNickName }; + let item = Stanza.node( + "item", + null, + attributes, + aMsg ? Stanza.node("reason", null, null, aMsg) : null + ); + let s = Stanza.iq( + "set", + null, + this.name, + Stanza.node("query", Stanza.NS.muc_admin, null, item) + ); + this._account.sendStanza(s, this._banKickHandler, this); + }, + + // Callback for ban and kick commands. + _banKickHandler(aStanza) { + return this._account._handleResult( + { + notAllowed: lazy._("conversation.error.banKickCommandNotAllowed"), + conflict: lazy._("conversation.error.banKickCommandConflict"), + }, + this + )(aStanza); + }, + + // Changes nick in MUC conversation to a new one. + setNick(aNewNick) { + // XEP-0045 (7.6): Changing Nickname. + let s = Stanza.presence({ to: this.name + "/" + aNewNick }, null); + this._account.sendStanza( + s, + this._account.handleErrors( + { + // XEP-0045 (7.6): Changing Nickname (example 53). + // TODO: We should discover if the user has a reserved nickname (maybe + // before joining a room), cf. XEP-0045 (7.12). + notAcceptable: lazy._( + "conversation.error.changeNickFailedNotAcceptable", + aNewNick + ), + // XEP-0045 (7.2.9): Nickname Conflict. + conflict: lazy._( + "conversation.error.changeNickFailedConflict", + aNewNick + ), + }, + this + ) + ); + }, + + // Called by the account when a message stanza is received for this muc and + // needs to be handled. + onMessageStanza(aStanza) { + let x = aStanza.getElement(["x"]); + let decline = x.getElement(["decline"]); + if (decline) { + // XEP-0045 (7.8): Inviting Another User to a Room. + // XEP-0045 (7.8.2): Mediated Invitation. + let invitee = decline.attributes.jid; + let reasonNode = decline.getElement(["reason"]); + let reason = reasonNode ? reasonNode.innerText : ""; + let msg; + if (reason) { + msg = lazy._( + "conversation.message.invitationDeclined.reason", + invitee, + reason + ); + } else { + msg = lazy._("conversation.message.invitationDeclined", invitee); + } + + this.writeMessage(this.name, msg, { system: true }); + } else { + this.WARN("Unhandled message stanza."); + } + }, + + /* Called when the user closed the conversation */ + close() { + if (!this.left) { + this.part(); + } + GenericConvChatPrototype.close.call(this); + }, + unInit() { + this._account.removeConversation(this.name); + GenericConvChatPrototype.unInit.call(this); + }, + + _photoHash: null, + _saveIcon(aPhotoNode) { + this._account._saveResourceIcon(aPhotoNode, this).then( + url => { + this.convIconFilename = url; + }, + error => { + this._account.WARN( + "Error while loading conversation icon for " + + this.normalizedName + + ": " + + error.message + ); + } + ); + }, +}; + +function XMPPMUCConversation(aAccount, aJID, aNick) { + this._init(aAccount, aJID, aNick); +} +XMPPMUCConversation.prototype = XMPPMUCConversationPrototype; + +/* Helper class for buddy conversations */ +export var XMPPConversationPrototype = { + __proto__: GenericConvIMPrototype, + + _typingTimer: null, + supportChatStateNotifications: true, + _typingState: "active", + + // Indicates that current conversation is with a MUC participant and the + // recipient jid (stored in the userName) is of the form room@domain/nick. + _isMucParticipant: false, + + get buddy() { + return this._account._buddies.get(this.name); + }, + get title() { + return this.contactDisplayName; + }, + get contactDisplayName() { + return this.buddy ? this.buddy.contactDisplayName : this.name; + }, + get userName() { + return this.buddy ? this.buddy.userName : this.name; + }, + + // Returns jid (room@domain/nick) if it is with a MUC participant, and the + // name of conversation otherwise. + get normalizedName() { + if (this._isMucParticipant) { + return this._account.normalizeFullJid(this.name); + } + return this._account.normalize(this.name); + }, + + // Used to avoid showing full jids in typing notifications. + get shortName() { + if (this.buddy) { + return this.buddy.contactDisplayName; + } + + let jid = this._account._parseJID(this.name); + if (!jid) { + return this.name; + } + + // Returns nick of the recipient if conversation is with a participant of + // a MUC we are in as jid of the recipient is of the form room@domain/nick. + if (this._isMucParticipant) { + return jid.resource; + } + + return jid.node; + }, + + get shouldSendTypingNotifications() { + return ( + this.supportChatStateNotifications && + Services.prefs.getBoolPref("purple.conversations.im.send_typing") + ); + }, + + /* Called when the user is typing a message + * aString - the currently typed message + * Returns the number of characters that can still be typed */ + sendTyping(aString) { + if (!this.shouldSendTypingNotifications) { + return Ci.prplIConversation.NO_TYPING_LIMIT; + } + + this._cancelTypingTimer(); + if (aString.length) { + this._typingTimer = setTimeout(this.finishedComposing.bind(this), 10000); + } + + this._setTypingState(aString.length ? "composing" : "active"); + + return Ci.prplIConversation.NO_TYPING_LIMIT; + }, + + finishedComposing() { + if (!this.shouldSendTypingNotifications) { + return; + } + + this._setTypingState("paused"); + }, + + _setTypingState(aNewState) { + if (this._typingState == aNewState) { + return; + } + + let s = Stanza.message(this.to, null, aNewState); + + // We don't care about errors in response to typing notifications + // (e.g. because the user has left the room when talking to a MUC + // participant). + this._account.sendStanza(s, () => true); + + this._typingState = aNewState; + }, + _cancelTypingTimer() { + if (this._typingTimer) { + clearTimeout(this._typingTimer); + delete this._typingTimer; + } + }, + + // Holds the resource of user that you are currently talking to, but if the + // user is a participant of a MUC we are in, holds the nick of user you are + // talking to. + _targetResource: "", + + get to() { + if (!this._targetResource || this._isMucParticipant) { + return this.userName; + } + return this.userName + "/" + this._targetResource; + }, + + /* Called when the user enters a chat message */ + dispatchMessage(aMsg, aAction = false) { + if (aAction) { + // XEP-0245: The /me Command. + // We need to prepend "/me " as the first four characters of the message + // body. + aMsg = "/me" + aMsg; + } + this._cancelTypingTimer(); + let cs = this.shouldSendTypingNotifications ? "active" : null; + let s = Stanza.message(this.to, aMsg, cs); + this._account.sendStanza(s); + _displaySentMsg(this, aMsg); + delete this._typingState; + }, + + // Invites the contact to a MUC room. + invite(aRoomJid, aPassword = null) { + // XEP-0045 (7.8): Inviting Another User to a Room. + // XEP-0045 (7.8.1) and XEP-0249: Direct Invitation. + let x = Stanza.node("x", Stanza.NS.conference, { + jid: aRoomJid, + password: aPassword, + }); + this._account.sendStanza(Stanza.node("message", null, { to: this.to }, x)); + }, + + // Query the user for its Software Version. + // XEP-0092: Software Version. + getVersion() { + // TODO: Use Service Discovery to determine if the user's client supports + // jabber:iq:version protocol. + + let s = Stanza.iq( + "get", + null, + this.to, + Stanza.node("query", Stanza.NS.version) + ); + this._account.sendStanza(s, aStanza => { + // TODO: handle other errors that can result from querying + // user for its software version. + if ( + this._account.handleErrors( + { + default: lazy._("conversation.error.version.unknown"), + }, + this + )(aStanza) + ) { + return; + } + + let query = aStanza.getElement(["query"]); + if (!query || query.uri != Stanza.NS.version) { + this.WARN( + "Received a response to version query which does not " + + "contain query element or 'jabber:iq:version' namespace." + ); + return; + } + + let name = query.getElement(["name"]); + let version = query.getElement(["version"]); + if (!name || !version) { + // XEP-0092: name and version elements are REQUIRED. + this.WARN( + "Received a response to version query which does not " + + "contain name or version." + ); + return; + } + + let messageID = "conversation.message.version"; + let params = [this.shortName, name.innerText, version.innerText]; + + // XEP-0092: os is OPTIONAL. + let os = query.getElement(["os"]); + if (os) { + params.push(os.innerText); + messageID += "WithOS"; + } + + this.writeMessage(this.name, lazy._(messageID, ...params), { + system: true, + }); + }); + }, + + /* Perform entity escaping before displaying the message. We assume incoming + messages have already been escaped, and will otherwise be filtered. */ + prepareForDisplaying(aMsg) { + if (aMsg.outgoing && !aMsg.system) { + aMsg.displayMessage = lazy.TXTToHTML(aMsg.displayMessage); + } + GenericConversationPrototype.prepareForDisplaying.apply(this, arguments); + }, + + /* Called by the account when a message is received from the buddy */ + incomingMessage(aMsg, aStanza, aDate) { + let from = aStanza.attributes.from; + this._targetResource = this._account._parseJID(from).resource; + let flags = {}; + let error = this._account.parseError(aStanza); + if (error) { + let norm = this._account.normalize(from); + let muc = this._account._mucs.get(norm); + + if (!aMsg) { + // Failed outgoing message. + switch (error.condition) { + case "remote-server-not-found": + aMsg = lazy._("conversation.error.remoteServerNotFound"); + break; + case "service-unavailable": + aMsg = lazy._( + "conversation.error.sendServiceUnavailable", + this.shortName + ); + break; + default: + aMsg = lazy._("conversation.error.unknownSendError"); + break; + } + } else if ( + this._isMucParticipant && + muc && + !muc.left && + error.condition == "item-not-found" + ) { + // XEP-0045 (7.5): MUC private messages. + // If we try to send to participant not in a room we are in. + aMsg = lazy._( + "conversation.error.sendFailedAsRecipientNotInRoom", + this._targetResource, + aMsg + ); + } else if ( + this._isMucParticipant && + (error.condition == "item-not-found" || + error.condition == "not-acceptable") + ) { + // If we left a room and try to send to a participant in it or the + // room is removed. + aMsg = lazy._( + "conversation.error.sendFailedAsNotInRoom", + this._account.normalize(from), + aMsg + ); + } else { + aMsg = lazy._("conversation.error.notDelivered", aMsg); + } + flags.system = true; + flags.error = true; + } else { + flags = { incoming: true, _alias: this.contactDisplayName }; + // XEP-0245: The /me Command. + if (aMsg.startsWith("/me ")) { + flags.action = true; + aMsg = aMsg.slice(4); + } + } + if (aDate) { + flags.time = aDate / 1000; + flags.delayed = true; + } + this.writeMessage(from, aMsg, flags); + }, + + /* Called when the user closed the conversation */ + close() { + // TODO send the stanza indicating we have left the conversation? + GenericConvIMPrototype.close.call(this); + }, + unInit() { + this._account.removeConversation(this.normalizedName); + GenericConvIMPrototype.unInit.call(this); + }, +}; + +// Creates XMPP conversation. +function XMPPConversation(aAccount, aNormalizedName, aMucParticipant) { + this._init(aAccount, aNormalizedName); + if (aMucParticipant) { + this._isMucParticipant = true; + } +} +XMPPConversation.prototype = XMPPConversationPrototype; + +/* Helper class for buddies */ +export var XMPPAccountBuddyPrototype = { + __proto__: GenericAccountBuddyPrototype, + + subscription: "none", + // Returns a list of TooltipInfo objects to be displayed when the user + // hovers over the buddy. + getTooltipInfo() { + if (!this._account.connected) { + return null; + } + + let tooltipInfo = []; + if (this._resources) { + for (let r in this._resources) { + let status = this._resources[r]; + let statusString = Status.toLabel(status.statusType); + if ( + status.statusType == Ci.imIStatusInfo.STATUS_IDLE && + status.idleSince + ) { + let now = Math.floor(Date.now() / 1000); + let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits( + now - status.idleSince + ); + if (!valuesAndUnits[2]) { + valuesAndUnits.splice(2, 2); + } + statusString += " (" + valuesAndUnits.join(" ") + ")"; + } + if (status.statusText) { + statusString += " - " + status.statusText; + } + let label = r + ? lazy._("tooltip.status", r) + : lazy._("tooltip.statusNoResource"); + tooltipInfo.push(new TooltipInfo(label, statusString)); + } + } + + // The subscription value is interesting to display only in unusual cases. + if (this.subscription != "both") { + tooltipInfo.push( + new TooltipInfo(lazy._("tooltip.subscription"), this.subscription) + ); + } + + return tooltipInfo; + }, + + // _rosterAlias is the value stored in the roster on the XMPP + // server. For most servers we will be read/write. + _rosterAlias: "", + set rosterAlias(aNewAlias) { + let old = this.displayName; + this._rosterAlias = aNewAlias; + if (old != this.displayName) { + this._notifyObservers("display-name-changed", old); + } + }, + _vCardReceived: false, + // _vCardFormattedName is the display name the contact has set for + // himself in his vCard. It's read-only from our point of view. + _vCardFormattedName: "", + set vCardFormattedName(aNewFormattedName) { + let old = this.displayName; + this._vCardFormattedName = aNewFormattedName; + if (old != this.displayName) { + this._notifyObservers("display-name-changed", old); + } + }, + + // _serverAlias is set by jsProtoHelper to the value we cached in sqlite. + // Use it only if we have neither of the other two values; usually because + // we haven't connected to the server yet. + get serverAlias() { + return this._rosterAlias || this._vCardFormattedName || this._serverAlias; + }, + set serverAlias(aNewAlias) { + if (!this._rosterItem) { + this.ERROR( + "attempting to update the server alias of an account buddy " + + "for which we haven't received a roster item." + ); + return; + } + + let item = this._rosterItem; + if (aNewAlias) { + item.attributes.name = aNewAlias; + } else if ("name" in item.attributes) { + delete item.attributes.name; + } + + let s = Stanza.iq( + "set", + null, + null, + Stanza.node("query", Stanza.NS.roster, null, item) + ); + this._account.sendStanza(s); + + // If we are going to change the alias on the server, discard the cached + // value that we got from our local sqlite storage at startup. + delete this._serverAlias; + }, + + /* Display name of the buddy */ + get contactDisplayName() { + return this.buddy.contact.displayName || this.displayName; + }, + + get tag() { + return this._tag; + }, + set tag(aNewTag) { + let oldTag = this._tag; + if (oldTag.name == aNewTag.name) { + this.ERROR("attempting to set the tag to the same value"); + return; + } + + this._tag = aNewTag; + IMServices.contacts.accountBuddyMoved(this, oldTag, aNewTag); + + if (!this._rosterItem) { + this.ERROR( + "attempting to change the tag of an account buddy without roster item" + ); + return; + } + + let item = this._rosterItem; + let oldXML = item.getXML(); + // Remove the old tag if it was listed in the roster item. + item.children = item.children.filter( + c => c.qName != "group" || c.innerText != oldTag.name + ); + // Ensure the new tag is listed. + let newTagName = aNewTag.name; + if (!item.getChildren("group").some(g => g.innerText == newTagName)) { + item.addChild(Stanza.node("group", null, null, newTagName)); + } + // Avoid sending anything to the server if the roster item hasn't changed. + // It's possible that the roster item hasn't changed if the roster + // item had several groups and the user moved locally the contact + // to another group where it already was on the server. + if (item.getXML() == oldXML) { + return; + } + + let s = Stanza.iq( + "set", + null, + null, + Stanza.node("query", Stanza.NS.roster, null, item) + ); + this._account.sendStanza(s); + }, + + remove() { + if (!this._account.connected) { + return; + } + + let s = Stanza.iq( + "set", + null, + null, + Stanza.node( + "query", + Stanza.NS.roster, + null, + Stanza.node("item", null, { + jid: this.normalizedName, + subscription: "remove", + }) + ) + ); + this._account.sendStanza(s); + }, + + _photoHash: null, + _saveIcon(aPhotoNode) { + this._account._saveResourceIcon(aPhotoNode, this).then( + url => { + this.buddyIconFilename = url; + }, + error => { + this._account.WARN( + "Error loading buddy icon for " + + this.normalizedName + + ": " + + error.message + ); + } + ); + }, + + _preferredResource: undefined, + _resources: null, + onAccountDisconnected() { + delete this._preferredResource; + delete this._resources; + }, + // Called by the account when a presence stanza is received for this buddy. + onPresenceStanza(aStanza) { + let preferred = this._preferredResource; + + // Facebook chat's XMPP server doesn't send resources, let's + // replace undefined resources with empty resources. + let resource = + this._account._parseJID(aStanza.attributes.from).resource || ""; + + let type = aStanza.attributes.type; + + // Reset typing status if the buddy is in a conversation and becomes unavailable. + let conv = this._account._conv.get(this.normalizedName); + if (type == "unavailable" && conv) { + conv.updateTyping(Ci.prplIConvIM.NOT_TYPING, this.contactDisplayName); + } + + if (type == "unavailable" || type == "error") { + if (!this._resources || !(resource in this._resources)) { + // Ignore for already offline resources. + return; + } + delete this._resources[resource]; + if (preferred == resource) { + preferred = undefined; + } + } else { + let statusInfo = parseStatus(aStanza); + let priority = aStanza.getElement(["priority"]); + priority = priority ? parseInt(priority.innerText, 10) : 0; + + if (!this._resources) { + this._resources = {}; + } + this._resources[resource] = { + statusType: statusInfo.statusType, + statusText: statusInfo.statusText, + idleSince: statusInfo.idleSince, + priority, + stanza: aStanza, + }; + } + + let photo = aStanza.getElement(["x", "photo"]); + if (photo && photo.uri == Stanza.NS.vcard_update) { + let hash = photo.innerText; + if (hash && hash != this._photoHash) { + this._account._addVCardRequest(this.normalizedName); + } else if (!hash && this._photoHash) { + delete this._photoHash; + this.buddyIconFilename = ""; + } + } + + for (let r in this._resources) { + if ( + preferred === undefined || + this._resources[r].statusType > this._resources[preferred].statusType + ) { + // FIXME also compare priorities... + preferred = r; + } + } + if ( + preferred != undefined && + preferred == this._preferredResource && + resource != preferred + ) { + // The presence information change is only for an unused resource, + // only potential buddy tooltips need to be refreshed. + this._notifyObservers("status-detail-changed"); + return; + } + + // Presence info has changed enough that if we are having a + // conversation with one resource of this buddy, we should send + // the next message to all resources. + // FIXME: the test here isn't exactly right... + if ( + this._preferredResource != preferred && + this._account._conv.has(this.normalizedName) + ) { + delete this._account._conv.get(this.normalizedName)._targetResource; + } + + this._preferredResource = preferred; + if (preferred === undefined) { + let statusType = Ci.imIStatusInfo.STATUS_UNKNOWN; + if (type == "unavailable") { + statusType = Ci.imIStatusInfo.STATUS_OFFLINE; + } + this.setStatus(statusType, ""); + } else { + preferred = this._resources[preferred]; + this.setStatus(preferred.statusType, preferred.statusText); + } + }, + + /* Can send messages to buddies who appear offline */ + get canSendMessage() { + return this.account.connected; + }, + + /* Called when the user wants to chat with the buddy */ + createConversation() { + return this._account.createConversation(this.normalizedName); + }, +}; + +function XMPPAccountBuddy(aAccount, aBuddy, aTag, aUserName) { + this._init(aAccount, aBuddy, aTag, aUserName); +} +XMPPAccountBuddy.prototype = XMPPAccountBuddyPrototype; + +var XMPPRoomInfoPrototype = { + __proto__: ClassInfo("prplIRoomInfo", "XMPP RoomInfo Object"), + get topic() { + return ""; + }, + get participantCount() { + return Ci.prplIRoomInfo.NO_PARTICIPANT_COUNT; + }, + get chatRoomFieldValues() { + let roomJid = this._account._roomList.get(this.name); + return this._account.getChatRoomDefaultFieldValues(roomJid); + }, +}; +function XMPPRoomInfo(aName, aAccount) { + this.name = aName; + this._account = aAccount; +} +XMPPRoomInfo.prototype = XMPPRoomInfoPrototype; + +/* Helper class for account */ +export var XMPPAccountPrototype = { + __proto__: GenericAccountPrototype, + + _jid: null, // parsed Jabber ID: node, domain, resource + _connection: null, // XMPPSession socket + authMechanisms: null, // hook to let prpls tweak the list of auth mechanisms + + // Contains the domain of MUC service which is obtained using service + // discovery. + _mucService: null, + + // Maps room names to room jid. + _roomList: new Map(), + + // Callbacks used when roomInfo is available. + _roomInfoCallbacks: new Set(), + + // Determines if roomInfo that we have is expired or not. + _lastListTime: 0, + get isRoomInfoStale() { + return Date.now() - this._lastListTime > kListRefreshInterval; + }, + + // If true, we are waiting for replies. + _pendingList: false, + + // An array of jids for which we still need to request vCards. + _pendingVCardRequests: [], + + // XEP-0280: Message Carbons. + // If true, message carbons are currently enabled. + _isCarbonsEnabled: false, + + /* Generate unique id for a stanza. Using id and unique sid is defined in + * RFC 6120 (Section 8.2.3, 4.7.3). + */ + generateId: () => Services.uuid.generateUUID().toString().slice(1, -1), + + _init(aProtoInstance, aImAccount) { + GenericAccountPrototype._init.call(this, aProtoInstance, aImAccount); + + // Ongoing conversations. + // The keys of this._conv are assumed to be normalized like account@domain + // for normal conversations and like room@domain/nick for MUC participant + // convs. + this._conv = new NormalizedMap(this.normalizeFullJid.bind(this)); + + this._buddies = new NormalizedMap(this.normalize.bind(this)); + this._mucs = new NormalizedMap(this.normalize.bind(this)); + + this._pendingVCardRequests = []; + }, + + get canJoinChat() { + return true; + }, + chatRoomFields: { + room: { + get label() { + return lazy._("chatRoomField.room"); + }, + required: true, + }, + server: { + get label() { + return lazy._("chatRoomField.server"); + }, + required: true, + }, + nick: { + get label() { + return lazy._("chatRoomField.nick"); + }, + required: true, + }, + password: { + get label() { + return lazy._("chatRoomField.password"); + }, + isPassword: true, + }, + }, + parseDefaultChatName(aDefaultChatName) { + if (!aDefaultChatName) { + return { nick: this._jid.node }; + } + + let params = aDefaultChatName.trim().split(/\s+/); + let jid = this._parseJID(params[0]); + + // We swap node and domain as domain is required for parseJID, but node and + // resource are optional. In MUC join command, Node is required as it + // represents a room, but domain and resource are optional as we get muc + // domain from service discovery. + if (!jid.node && jid.domain) { + [jid.node, jid.domain] = [jid.domain, jid.node]; + } + + let chatFields = { + room: jid.node, + server: jid.domain || this._mucService, + nick: jid.resource || this._jid.node, + }; + if (params.length > 1) { + chatFields.password = params[1]; + } + return chatFields; + }, + getChatRoomDefaultFieldValues(aDefaultChatName) { + let rv = GenericAccountPrototype.getChatRoomDefaultFieldValues.call( + this, + aDefaultChatName + ); + if (!rv.values.nick) { + rv.values.nick = this._jid.node; + } + if (!rv.values.server && this._mucService) { + rv.values.server = this._mucService; + } + + return rv; + }, + + // XEP-0045: Requests joining room if it exists or + // creating room if it does not exist. + joinChat(aComponents) { + let jid = + aComponents.getValue("room") + "@" + aComponents.getValue("server"); + let nick = aComponents.getValue("nick"); + + let muc = this._mucs.get(jid); + if (muc) { + if (!muc.left) { + // We are already in this conversation. + return muc; + } else if (!muc.chatRoomFields) { + // We are rejoining a room that was parted by the user. + muc._rejoined = true; + } + } else { + muc = new this._MUCConversationConstructor(this, jid, nick); + this._mucs.set(jid, muc); + } + + // Store the prplIChatRoomFieldValues to enable later reconnections. + muc.chatRoomFields = aComponents; + muc.joining = true; + muc.removeAllParticipants(); + + let password = aComponents.getValue("password"); + let x = Stanza.node( + "x", + Stanza.NS.muc, + null, + password ? Stanza.node("password", null, null, password) : null + ); + let logString; + if (password) { + logString = + "<presence .../> (Stanza containing password to join MUC " + + jid + + "/" + + nick + + " not logged)"; + } + this.sendStanza( + Stanza.presence({ to: jid + "/" + nick }, x), + undefined, + undefined, + logString + ); + return muc; + }, + + _idleSince: 0, + observe(aSubject, aTopic, aData) { + if (aTopic == "idle-time-changed") { + let idleTime = parseInt(aData, 10); + if (idleTime) { + this._idleSince = Math.floor(Date.now() / 1000) - idleTime; + } else { + delete this._idleSince; + } + this._shouldSendPresenceForIdlenessChange = true; + executeSoon( + function () { + if ("_shouldSendPresenceForIdlenessChange" in this) { + this._sendPresence(); + } + }.bind(this) + ); + } else if (aTopic == "status-changed") { + this._sendPresence(); + } else if (aTopic == "user-icon-changed") { + delete this._cachedUserIcon; + this._forceUserIconUpdate = true; + this._sendVCard(); + } else if (aTopic == "user-display-name-changed") { + this._forceUserDisplayNameUpdate = true; + } + this._sendVCard(); + }, + + /* GenericAccountPrototype events */ + /* Connect to the server */ + connect() { + this._jid = this._parseJID(this.name); + + // For the resource, if the user has edited the option, always use that. + if (this.prefs.prefHasUserValue("resource")) { + let resource = this.getString("resource"); + + // this._jid needs to be updated. This value is however never used + // because while connected it's the jid of the session that's + // interesting. + this._jid = this._setJID(this._jid.domain, this._jid.node, resource); + } else if (this._jid.resource) { + // If there is a resource in the account name (inherited from libpurple), + // migrate it to the pref so it appears correctly in the advanced account + // options next time. + this.prefs.setStringPref("resource", this._jid.resource); + } + + this._connection = new XMPPSession( + this.getString("server") || this._jid.domain, + this.getInt("port") || 5222, + this.getString("connection_security"), + this._jid, + this.imAccount.password, + this + ); + }, + + remove() { + this._conv.forEach(conv => conv.close()); + this._mucs.forEach(muc => muc.close()); + this._buddies.forEach((buddy, jid) => this._forgetRosterItem(jid)); + }, + + unInit() { + if (this._connection) { + this._disconnect(undefined, undefined, true); + } + delete this._jid; + delete this._conv; + delete this._buddies; + delete this._mucs; + }, + + /* Disconnect from the server */ + disconnect() { + this._disconnect(); + }, + + addBuddy(aTag, aName) { + if (!this._connection) { + throw new Error("The account isn't connected"); + } + + let jid = this.normalize(aName); + if (!jid || !jid.includes("@")) { + throw new Error("Invalid username"); + } + + if (this._buddies.has(jid)) { + let subscription = this._buddies.get(jid).subscription; + if (subscription && (subscription == "both" || subscription == "to")) { + this.DEBUG("not re-adding an existing buddy"); + return; + } + } else { + let s = Stanza.iq( + "set", + null, + null, + Stanza.node( + "query", + Stanza.NS.roster, + null, + Stanza.node( + "item", + null, + { jid }, + Stanza.node("group", null, null, aTag.name) + ) + ) + ); + this.sendStanza( + s, + this._handleResult({ + default: aError => { + this.WARN( + "Unable to add a roster item due to " + aError + " error." + ); + }, + }) + ); + } + this.sendStanza(Stanza.presence({ to: jid, type: "subscribe" })); + }, + + /* Loads a buddy from the local storage. + * Called for each buddy locally stored before connecting + * to the server. */ + loadBuddy(aBuddy, aTag) { + let buddy = new this._accountBuddyConstructor(this, aBuddy, aTag); + this._buddies.set(buddy.normalizedName, buddy); + return buddy; + }, + + /* Replies to a buddy request in order to accept it or deny it. */ + replyToBuddyRequest(aReply, aRequest) { + if (!this._connection) { + return; + } + let s = Stanza.presence({ to: aRequest.userName, type: aReply }); + this.sendStanza(s); + this.removeBuddyRequest(aRequest); + }, + + requestBuddyInfo(aJid) { + if (!this.connected) { + Services.obs.notifyObservers(EmptyEnumerator, "user-info-received", aJid); + return; + } + + let userName; + let tooltipInfo = []; + let jid = this._parseJID(aJid); + let muc = this._mucs.get(jid.node + "@" + jid.domain); + let participant; + if (muc) { + participant = muc._participants.get(jid.resource); + if (participant) { + if (participant.accountJid) { + userName = participant.accountJid; + } + if (!muc.left) { + let statusType = participant.statusType; + let statusText = participant.statusText; + tooltipInfo.push( + new TooltipInfo(statusType, statusText, Ci.prplITooltipInfo.status) + ); + + if (participant.buddyIconFilename) { + tooltipInfo.push( + new TooltipInfo( + null, + participant.buddyIconFilename, + Ci.prplITooltipInfo.icon + ) + ); + } + } + } + } + Services.obs.notifyObservers( + new nsSimpleEnumerator(tooltipInfo), + "user-info-received", + aJid + ); + + let iq = Stanza.iq( + "get", + null, + aJid, + Stanza.node("vCard", Stanza.NS.vcard) + ); + this.sendStanza(iq, aStanza => { + let vCardInfo = {}; + let vCardNode = aStanza.getElement(["vCard"]); + + // In the case of an error response, we just notify the observers with + // what info we already have. + if (aStanza.attributes.type == "result" && vCardNode) { + vCardInfo = this.parseVCard(vCardNode); + } + + // The real jid of participant which is of the form local@domain/resource. + // We consider the jid is provided by server is more correct than jid is + // set by the user. + if (userName) { + vCardInfo.userName = userName; + } + + // vCard fields we want to display in the tooltip. + const kTooltipFields = [ + "userName", + "fullName", + "nickname", + "title", + "organization", + "email", + "birthday", + "locality", + "country", + "telephone", + ]; + + let tooltipInfo = []; + for (let field of kTooltipFields) { + if (vCardInfo.hasOwnProperty(field)) { + tooltipInfo.push( + new TooltipInfo(lazy._("tooltip." + field), vCardInfo[field]) + ); + } + } + if (vCardInfo.photo) { + let dataURI = this._getPhotoURI(vCardInfo.photo); + + // Store the photo URI for this participant. + if (participant) { + participant.buddyIconFilename = dataURI; + } + + tooltipInfo.push( + new TooltipInfo(null, dataURI, Ci.prplITooltipInfo.icon) + ); + } + Services.obs.notifyObservers( + new nsSimpleEnumerator(tooltipInfo), + "user-info-received", + aJid + ); + }); + }, + + // Parses the photo node of a received vCard if exists and returns string of + // data URI, otherwise returns null. + _getPhotoURI(aPhotoNode) { + if (!aPhotoNode) { + return null; + } + + let type = aPhotoNode.getElement(["TYPE"]); + let value = aPhotoNode.getElement(["BINVAL"]); + if (!type || !value) { + return null; + } + + return "data:" + type.innerText + ";base64," + value.innerText; + }, + + // Parses the vCard into the properties of the returned object. + parseVCard(aVCardNode) { + // XEP-0054: vcard-temp. + let aResult = {}; + for (let node of aVCardNode.children.filter( + child => child.type == "node" + )) { + let localName = node.localName; + let innerText = node.innerText; + if (innerText) { + if (localName == "FN") { + aResult.fullName = innerText; + } else if (localName == "NICKNAME") { + aResult.nickname = innerText; + } else if (localName == "TITLE") { + aResult.title = innerText; + } else if (localName == "BDAY") { + aResult.birthday = innerText; + } else if (localName == "JABBERID") { + aResult.userName = innerText; + } + } + if (localName == "ORG") { + let organization = node.getElement(["ORGNAME"]); + if (organization && organization.innerText) { + aResult.organization = organization.innerText; + } + } else if (localName == "EMAIL") { + let userID = node.getElement(["USERID"]); + if (userID && userID.innerText) { + aResult.email = userID.innerText; + } + } else if (localName == "ADR") { + let locality = node.getElement(["LOCALITY"]); + if (locality && locality.innerText) { + aResult.locality = locality.innerText; + } + + let country = node.getElement(["CTRY"]); + if (country && country.innerText) { + aResult.country = country.innerText; + } + } else if (localName == "PHOTO") { + aResult.photo = node; + } else if (localName == "TEL") { + let number = node.getElement(["NUMBER"]); + if (number && number.innerText) { + aResult.telephone = number.innerText; + } + } + // TODO: Parse the other fields of vCard and display it in system messages + // in response to /whois. + } + return aResult; + }, + + // Returns undefined if not an error stanza, and an object + // describing the error otherwise: + parseError(aStanza) { + if (aStanza.attributes.type != "error") { + return undefined; + } + + let retval = { stanza: aStanza }; + let error = aStanza.getElement(["error"]); + + // RFC 6120 Section 8.3.2: Type must be one of + // auth -- retry after providing credentials + // cancel -- do not retry (the error cannot be remedied) + // continue -- proceed (the condition was only a warning) + // modify -- retry after changing the data sent + // wait -- retry after waiting (the error is temporary). + retval.type = error.attributes.type; + + // RFC 6120 Section 8.3.3. + const kDefinedConditions = [ + "bad-request", + "conflict", + "feature-not-implemented", + "forbidden", + "gone", + "internal-server-error", + "item-not-found", + "jid-malformed", + "not-acceptable", + "not-allowed", + "not-authorized", + "policy-violation", + "recipient-unavailable", + "redirect", + "registration-required", + "remote-server-not-found", + "remote-server-timeout", + "resource-constraint", + "service-unavailable", + "subscription-required", + "undefined-condition", + "unexpected-request", + ]; + let condition = kDefinedConditions.find(c => error.getElement([c])); + if (!condition) { + // RFC 6120 Section 8.3.2. + this.WARN( + "Nonstandard or missing defined-condition element in error stanza." + ); + condition = "undefined-condition"; + } + retval.condition = condition; + + let errortext = error.getElement(["text"]); + if (errortext) { + retval.text = errortext.innerText; + } + + return retval; + }, + + // Returns an error-handling callback for use with sendStanza generated + // from aHandlers, an object defining the error handlers. + // If the stanza passed to the callback is an error stanza, it checks if + // aHandlers contains a property with the name of the defined condition + // of the error. + // * If the property is a function, it is called with the parsed error + // as its argument, bound to aThis (if provided). + // It should return true if the error was handled. + // * If the property is a string, it is displayed as a system message + // in the conversation given by aThis. + handleErrors(aHandlers, aThis) { + return aStanza => { + if (!aHandlers) { + return false; + } + + let error = this.parseError(aStanza); + if (!error) { + return false; + } + + let toCamelCase = aStr => { + // Turn defined condition string into a valid camelcase + // JS property name. + let capitalize = s => s[0].toUpperCase() + s.slice(1); + let uncapitalize = s => s[0].toLowerCase() + s.slice(1); + return uncapitalize(aStr.split("-").map(capitalize).join("")); + }; + let condition = toCamelCase(error.condition); + // Check if we have a handler property for this kind of error or a + // default handler. + if (!(condition in aHandlers) && !("default" in aHandlers)) { + return false; + } + + // Try to get the handler for condition, if we cannot get it, try to get + // the default handler. + let handler = aHandlers[condition]; + if (!handler) { + handler = aHandlers.default; + } + + if (typeof handler == "string") { + // The string is an error message to be displayed in the conversation. + if (!aThis || !aThis.writeMessage) { + this.ERROR( + "HandleErrors was passed an error message string, but " + + "no conversation to display it in:\n" + + handler + ); + return true; + } + aThis.writeMessage(aThis.name, handler, { system: true, error: true }); + return true; + } else if (typeof handler == "function") { + // If we're given a function, call this error handler. + return handler.call(aThis, error); + } + + // If this happens, there's a bug somewhere. + this.ERROR( + "HandleErrors was passed a handler for '" + + condition + + "'' which is neither a function nor a string." + ); + return false; + }; + }, + + // Returns a callback suitable for use in sendStanza, to handle type==result + // responses. aHandlers and aThis are passed on to handleErrors for error + // handling. + _handleResult(aHandlers, aThis) { + return aStanza => { + if (aStanza.attributes.type == "result") { + return true; + } + return this.handleErrors(aHandlers, aThis)(aStanza); + }; + }, + + /* XMPPSession events */ + + /* Called when the XMPP session is started */ + onConnection() { + // Request the roster. The account will be marked as connected when this is + // complete. + this.reportConnecting(lazy._("connection.downloadingRoster")); + let s = Stanza.iq( + "get", + null, + null, + Stanza.node("query", Stanza.NS.roster) + ); + this.sendStanza(s, this.onRoster, this); + + // XEP-0030 and XEP-0045 (6): Service Discovery. + // Queries Server for Associated Services. + let iq = Stanza.iq( + "get", + null, + this._jid.domain, + Stanza.node("query", Stanza.NS.disco_items) + ); + this.sendStanza(iq, this.onServiceDiscovery, this); + + // XEP-0030: Service Discovery Information Features. + iq = Stanza.iq( + "get", + null, + this._jid.domain, + Stanza.node("query", Stanza.NS.disco_info) + ); + this.sendStanza(iq, this.onServiceDiscoveryInfo, this); + }, + + /* Called whenever a stanza is received */ + onXmppStanza(aStanza) {}, + + /* Called when a iq stanza is received */ + onIQStanza(aStanza) { + let type = aStanza.attributes.type; + if (type == "set") { + for (let query of aStanza.getChildren("query")) { + if (query.uri != Stanza.NS.roster) { + continue; + } + + // RFC 6121 2.1.6 (Roster push): + // A receiving client MUST ignore the stanza unless it has no 'from' + // attribute (i.e., implicitly from the bare JID of the user's + // account) or it has a 'from' attribute whose value matches the + // user's bare JID <user@domainpart>. + let from = aStanza.attributes.from; + if (from && from != this._jid.node + "@" + this._jid.domain) { + this.WARN("Ignoring potentially spoofed roster push."); + return; + } + + for (let item of query.getChildren("item")) { + this._onRosterItem(item, true); + } + return; + } + } else if (type == "get") { + let id = aStanza.attributes.id; + let from = aStanza.attributes.from; + + // XEP-0199: XMPP server-to-client ping (XEP-0199) + let ping = aStanza.getElement(["ping"]); + if (ping && ping.uri == Stanza.NS.ping) { + this.sendStanza(Stanza.iq("result", id, from)); + return; + } + + let query = aStanza.getElement(["query"]); + if (query && query.uri == Stanza.NS.version) { + // XEP-0092: Software Version. + let children = []; + children.push(Stanza.node("name", null, null, Services.appinfo.name)); + children.push( + Stanza.node("version", null, null, Services.appinfo.version) + ); + let versionQuery = Stanza.node( + "query", + Stanza.NS.version, + null, + children + ); + this.sendStanza(Stanza.iq("result", id, from, versionQuery)); + return; + } + if (query && query.uri == Stanza.NS.disco_info) { + // XEP-0030: Service Discovery. + let children = []; + if (aStanza.attributes.node == Stanza.NS.muc_rooms) { + // XEP-0045 (6.7): Room query. + // TODO: Currently, we return an empty <query/> element, but we + // should return non-private rooms. + } else { + children = SupportedFeatures.map(feature => + Stanza.node("feature", null, { var: feature }) + ); + children.unshift( + Stanza.node("identity", null, { + category: "client", + type: "pc", + name: Services.appinfo.name, + }) + ); + } + let discoveryQuery = Stanza.node( + "query", + Stanza.NS.disco_info, + null, + children + ); + this.sendStanza(Stanza.iq("result", id, from, discoveryQuery)); + return; + } + } + this.WARN(`Unhandled IQ ${type} stanza.`); + if (type == "get" || type == "set") { + // RFC 6120 (section 8.2.3): An entity that receives an IQ request of + // type "get" or "set" MUST reply with an IQ response of type "result" + // or "error". + let id = aStanza.attributes.id; + let from = aStanza.attributes.from; + let condition = Stanza.node("service-unavailable", Stanza.NS.stanzas, { + type: "cancel", + }); + let error = Stanza.node("error", null, { type: "cancel" }, condition); + this.sendStanza(Stanza.iq("error", id, from, error)); + } + }, + + /* Called when a presence stanza is received */ + onPresenceStanza(aStanza) { + let from = aStanza.attributes.from; + this.DEBUG("Received presence stanza for " + from); + + let jid = this.normalize(from); + let type = aStanza.attributes.type; + if (type == "subscribe") { + this.addBuddyRequest( + jid, + this.replyToBuddyRequest.bind(this, "subscribed"), + this.replyToBuddyRequest.bind(this, "unsubscribed") + ); + } else if ( + type == "unsubscribe" || + type == "unsubscribed" || + type == "subscribed" + ) { + // Nothing useful to do for these presence stanzas, as we will also + // receive a roster push containing more or less the same information + } else if (this._buddies.has(jid)) { + this._buddies.get(jid).onPresenceStanza(aStanza); + } else if (this._mucs.has(jid)) { + this._mucs.get(jid).onPresenceStanza(aStanza); + } else if (jid != this.normalize(this._connection._jid.jid)) { + this.WARN("received presence stanza for unknown buddy " + from); + } else if ( + jid == this._jid.node + "@" + this._jid.domain && + this._connection._resource != this._parseJID(from).resource + ) { + // Ignore presence stanzas for another resource. + } else { + this.WARN("Unhandled presence stanza."); + } + }, + + // XEP-0030: Discovering services and their features that are supported by + // the server. + onServiceDiscovery(aStanza) { + let query = aStanza.getElement(["query"]); + if ( + aStanza.attributes.type != "result" || + !query || + query.uri != Stanza.NS.disco_items + ) { + this.LOG("Could not get services for this server: " + this._jid.domain); + return true; + } + + // Discovering the Features that are Supported by each service. + query.getElements(["item"]).forEach(item => { + let jid = item.attributes.jid; + if (!jid) { + return; + } + let iq = Stanza.iq( + "get", + null, + jid, + Stanza.node("query", Stanza.NS.disco_info) + ); + this.sendStanza(iq, receivedStanza => { + let query = receivedStanza.getElement(["query"]); + let from = receivedStanza.attributes.from; + if ( + aStanza.attributes.type != "result" || + !query || + query.uri != Stanza.NS.disco_info + ) { + this.LOG("Could not get features for this service: " + from); + return true; + } + let features = query + .getElements(["feature"]) + .map(elt => elt.attributes.var); + let identity = query.getElement(["identity"]); + if ( + identity && + identity.attributes.category == "conference" && + identity.attributes.type == "text" && + features.includes(Stanza.NS.muc) + ) { + // XEP-0045 (6.2): this feature is for a MUC Service. + // XEP-0045 (15.2): Service Discovery Category/Type. + this._mucService = from; + } + // TODO: Handle other services that are supported by XMPP through + // their features. + + return true; + }); + }); + return true; + }, + + // XEP-0030: Discovering Service Information and its features that are + // supported by the server. + onServiceDiscoveryInfo(aStanza) { + let query = aStanza.getElement(["query"]); + if ( + aStanza.attributes.type != "result" || + !query || + query.uri != Stanza.NS.disco_info + ) { + this.LOG("Could not get features for this server: " + this._jid.domain); + return true; + } + + let features = query + .getElements(["feature"]) + .map(elt => elt.attributes.var); + if (features.includes(Stanza.NS.carbons)) { + // XEP-0280: Message Carbons. + // Enabling Carbons on server, as it's disabled by default on server. + if (Services.prefs.getBoolPref("chat.xmpp.messageCarbons")) { + let iqStanza = Stanza.iq( + "set", + null, + null, + Stanza.node("enable", Stanza.NS.carbons) + ); + this.sendStanza(iqStanza, aStanza => { + let error = this.parseError(aStanza); + if (error) { + this.WARN( + "Unable to enable message carbons due to " + + error.condition + + " error." + ); + return true; + } + + let type = aStanza.attributes.type; + if (type != "result") { + this.WARN( + "Received unexpected stanza with " + + type + + " type " + + "while enabling message carbons." + ); + return true; + } + + this.LOG("Message carbons enabled."); + this._isCarbonsEnabled = true; + return true; + }); + } + } + // TODO: Handle other features that are supported by the server. + return true; + }, + + requestRoomInfo(aCallback) { + if (this._roomInfoCallbacks.has(aCallback)) { + return; + } + + if (this.isRoomInfoStale && !this._pendingList) { + this._roomList = new Map(); + this._lastListTime = Date.now(); + this._roomInfoCallback = aCallback; + this._pendingList = true; + + // XEP-0045 (6.3): Discovering Rooms. + let iq = Stanza.iq( + "get", + null, + this._mucService, + Stanza.node("query", Stanza.NS.disco_items) + ); + this.sendStanza(iq, this.onRoomDiscovery, this); + } else { + let rooms = [...this._roomList.keys()]; + aCallback.onRoomInfoAvailable(rooms, !this._pendingList); + } + + if (this._pendingList) { + this._roomInfoCallbacks.add(aCallback); + } + }, + + onRoomDiscovery(aStanza) { + let query = aStanza.getElement(["query"]); + if (!query || query.uri != Stanza.NS.disco_items) { + this.LOG("Could not get rooms for this server: " + this._jid.domain); + return; + } + + // XEP-0059: Result Set Management. + let set = query.getElement(["set"]); + let last = set ? set.getElement(["last"]) : null; + if (last) { + let iq = Stanza.iq( + "get", + null, + this._mucService, + Stanza.node("query", Stanza.NS.disco_items) + ); + this.sendStanza(iq, this.onRoomDiscovery, this); + } else { + this._pendingList = false; + } + + let rooms = []; + query.getElements(["item"]).forEach(item => { + let jid = this._parseJID(item.attributes.jid); + if (!jid) { + return; + } + + let name = item.attributes.name; + if (!name) { + name = jid.node ? jid.node : jid.jid; + } + + this._roomList.set(name, jid.jid); + rooms.push(name); + }); + + this._roomInfoCallback.onRoomInfoAvailable(rooms, !this._pendingList); + }, + + getRoomInfo(aName) { + return new XMPPRoomInfo(aName, this); + }, + + // Returns null if not an invitation stanza, and an object + // describing the invitation otherwise. + parseInvitation(aStanza) { + let x = aStanza.getElement(["x"]); + if (!x) { + return null; + } + let retVal = { + shouldDecline: false, + }; + + // XEP-0045. Direct Invitation (7.8.1) + // Described in XEP-0249. + // jid (chatroom) is required. + // Password, reason, continue and thread are optional. + if (x.uri == Stanza.NS.conference) { + if (!x.attributes.jid) { + this.WARN("Received an invitation with missing MUC jid."); + return null; + } + retVal.mucJid = this.normalize(x.attributes.jid); + retVal.from = this.normalize(aStanza.attributes.from); + retVal.password = x.attributes.password; + retVal.reason = x.attributes.reason; + retVal.continue = x.attributes.continue; + retVal.thread = x.attributes.thread; + return retVal; + } + + // XEP-0045. Mediated Invitation (7.8.2) + // Sent by the chatroom on behalf of someone in the chatroom. + // jid (chatroom) and from (inviter) are required. + // password and reason are optional. + if (x.uri == Stanza.NS.muc_user) { + let invite = x.getElement(["invite"]); + if (!invite || !invite.attributes.from) { + this.WARN("Received an invitation with missing MUC invite or from."); + return null; + } + retVal.mucJid = this.normalize(aStanza.attributes.from); + retVal.from = this.normalize(invite.attributes.from); + retVal.shouldDecline = true; + let continueElement = invite.getElement(["continue"]); + retVal.continue = !!continueElement; + if (continueElement) { + retVal.thread = continueElement.attributes.thread; + } + if (x.getElement(["password"])) { + retVal.password = x.getElement(["password"]).innerText; + } + if (invite.getElement(["reason"])) { + retVal.reason = invite.getElement(["reason"]).innerText; + } + return retVal; + } + + return null; + }, + + /* Called when a message stanza is received */ + onMessageStanza(aStanza) { + // XEP-0280: Message Carbons. + // Sending and Receiving Messages. + // Indicates that the forwarded message was sent or received. + let isSent = false; + let carbonStanza = + aStanza.getElement(["sent"]) || aStanza.getElement(["received"]); + if (carbonStanza) { + if (carbonStanza.uri != Stanza.NS.carbons) { + this.WARN( + "Received a forwarded message which does not '" + + Stanza.NS.carbons + + "' namespace." + ); + return; + } + + isSent = carbonStanza.localName == "sent"; + carbonStanza = carbonStanza.getElement(["forwarded", "message"]); + if (this._isCarbonsEnabled) { + aStanza = carbonStanza; + } else { + this.WARN( + "Received an unexpected forwarded message while message " + + "carbons are not enabled." + ); + return; + } + } + + // For forwarded sent messages, we need to use "to" attribute to + // get the right conversation as from in this case is this account. + let convJid = isSent ? aStanza.attributes.to : aStanza.attributes.from; + + let normConvJid = this.normalize(convJid); + let isMuc = this._mucs.has(normConvJid); + + let type = aStanza.attributes.type; + let x = aStanza.getElement(["x"]); + let body; + let b = aStanza.getElement(["body"]); + if (b) { + // If there's a <body> child we have more than just typing notifications. + // Prefer HTML (in <html><body>) and use plain text (<body>) as fallback. + let htmlBody = aStanza.getElement(["html", "body"]); + if (htmlBody) { + body = htmlBody.innerXML; + } else { + // Even if the message is in plain text, the prplIMessage + // should contain a string that's correctly escaped for + // insertion in an HTML document. + body = lazy.TXTToHTML(b.innerText); + } + } + + let subject = aStanza.getElement(["subject"]); + // Ignore subject when !isMuc. We're being permissive about subject changes + // in the comment below, so we need to be careful about where that makes + // sense. Psi+'s OTR plugin includes a subject and body in its message + // stanzas. + if (subject && isMuc) { + // XEP-0045 (7.2.16): Check for a subject element in the stanza and update + // the topic if it exists. + // We are breaking the spec because only a message that contains a + // <subject/> but no <body/> element shall be considered a subject change + // for MUC, but we ignore that to be compatible with ejabberd versions + // before 15.06. + let muc = this._mucs.get(normConvJid); + let nick = this._parseJID(convJid).resource; + // TODO There can be multiple subject elements with different xml:lang + // attributes. + muc.setTopic(subject.innerText, nick); + return; + } + + let invitation = this.parseInvitation(aStanza); + if (invitation) { + let messageID; + if (invitation.reason) { + messageID = "conversation.muc.invitationWithReason2"; + } else { + messageID = "conversation.muc.invitationWithoutReason"; + } + if (invitation.password) { + messageID += ".password"; + } + let params = [ + invitation.from, + invitation.mucJid, + invitation.password, + invitation.reason, + ].filter(s => s); + let message = lazy._(messageID, ...params); + + this.addChatRequest( + invitation.mucJid, + () => { + let chatRoomFields = this.getChatRoomDefaultFieldValues( + invitation.mucJid + ); + if (invitation.password) { + chatRoomFields.setValue("password", invitation.password); + } + let muc = this.joinChat(chatRoomFields); + muc.writeMessage(muc.name, message, { system: true }); + }, + (request, tryToDeny) => { + // Only mediated invitations (XEP-0045) can explicitly decline. + if (invitation.shouldDecline && tryToDeny) { + let decline = Stanza.node( + "decline", + null, + { from: invitation.from }, + null + ); + let x = Stanza.node("x", Stanza.NS.muc_user, null, decline); + let s = Stanza.node("message", null, { to: invitation.mucJid }, x); + this.sendStanza(s); + } + // Always show invite reason or password, even if the invite wasn't + // automatically declined based on the setting. + if (!request || invitation.reason || invitation.password) { + let conv = this.createConversation(invitation.from); + if (conv) { + conv.writeMessage(invitation.from, message, { system: true }); + } + } + } + ); + } + + if (body) { + let date = _getDelay(aStanza); + if ( + type == "groupchat" || + (type == "error" && isMuc && !this._conv.has(convJid)) + ) { + if (!isMuc) { + this.WARN( + "Received a groupchat message for unknown MUC " + normConvJid + ); + return; + } + let muc = this._mucs.get(normConvJid); + muc.incomingMessage(body, aStanza, date); + return; + } + + let conv = this.createConversation(convJid); + if (!conv) { + return; + } + + if (isSent) { + _displaySentMsg(conv, body, date); + return; + } + conv.incomingMessage(body, aStanza, date); + } else if (type == "error") { + let conv = this.createConversation(convJid); + if (conv) { + conv.incomingMessage(null, aStanza); + } + } else if (x && x.uri == Stanza.NS.muc_user) { + let muc = this._mucs.get(normConvJid); + if (!muc) { + this.WARN( + "Received a groupchat message for unknown MUC " + normConvJid + ); + return; + } + muc.onMessageStanza(aStanza); + return; + } + + // If this is a sent message carbon, the user is typing on another client. + if (isSent) { + return; + } + + // Don't create a conversation to only display the typing notifications. + if (!this._conv.has(normConvJid) && !this._conv.has(convJid)) { + return; + } + + // Ignore errors while delivering typing notifications. + if (type == "error") { + return; + } + + let typingState = Ci.prplIConvIM.NOT_TYPING; + let state; + let s = aStanza.getChildrenByNS(Stanza.NS.chatstates); + if (s.length > 0) { + state = s[0].localName; + } + if (state) { + this.DEBUG(state); + if (state == "composing") { + typingState = Ci.prplIConvIM.TYPING; + } else if (state == "paused") { + typingState = Ci.prplIConvIM.TYPED; + } + } + let convName = normConvJid; + + // If the bare JID is a MUC that we have joined, use the full JID as this + // is a private message to a MUC participant. + if (isMuc) { + convName = convJid; + } + + let conv = this._conv.get(convName); + if (!conv) { + return; + } + conv.updateTyping(typingState, conv.shortName); + conv.supportChatStateNotifications = !!state; + }, + + /** Called when there is an error in the XMPP session */ + onError(aError, aException) { + if (aError === null || aError === undefined) { + aError = Ci.prplIAccount.ERROR_OTHER_ERROR; + } + this._disconnect(aError, aException.toString()); + }, + + onVCard(aStanza) { + let jid = this._pendingVCardRequests.shift(); + this._requestNextVCard(); + if (!this._buddies.has(jid) && !this._mucs.has(jid)) { + this.WARN("Received a vCard for unknown buddy " + jid); + return; + } + + let vCard = aStanza.getElement(["vCard"]); + let error = this.parseError(aStanza); + if ( + (error && + (error.condition == "item-not-found" || + error.condition == "service-unavailable")) || + !vCard || + !vCard.children.length + ) { + this.LOG("No vCard exists (or the user does not exist) for " + jid); + return; + } else if (error) { + this.WARN("Received unexpected vCard error " + error.condition); + return; + } + + let stanzaJid = this.normalize(aStanza.attributes.from); + if (jid && jid != stanzaJid) { + this.ERROR( + "Received vCard for a different jid (" + + stanzaJid + + ") " + + "than the requested " + + jid + ); + } + + let foundFormattedName = false; + let vCardInfo = this.parseVCard(vCard); + if (this._mucs.has(jid)) { + const conv = this._mucs.get(jid); + if (vCardInfo.photo) { + conv._saveIcon(vCardInfo.photo); + } + return; + } + let buddy = this._buddies.get(jid); + if (vCardInfo.fullName) { + buddy.vCardFormattedName = vCardInfo.fullName; + foundFormattedName = true; + } + if (vCardInfo.photo) { + buddy._saveIcon(vCardInfo.photo); + } + if (!foundFormattedName && buddy._vCardFormattedName) { + buddy.vCardFormattedName = ""; + } + buddy._vCardReceived = true; + }, + + /** + * Save the icon for a resource to the local file system. + * + * @param photo - The vcard photo node representing the icon. + * @param {prplIChatBuddy|prplIConversation} resource - Resource the icon is for. + * @returns {Promise<string>} Resolves with the file:// URI to the local icon file. + */ + _saveResourceIcon(photo, resource) { + // Some servers seem to send a photo node without a type declared. + let type = photo.getElement(["TYPE"]); + if (!type) { + return Promise.reject(new Error("Missing image type")); + } + type = type.innerText; + const kExt = { + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + }; + if (!kExt.hasOwnProperty(type)) { + return Promise.reject(new Error("Unknown image type")); + } + + let content = "", + data = ""; + // Strip all characters not allowed in base64 before parsing. + let parseBase64 = aBase => atob(aBase.replace(/[^A-Za-z0-9\+\/\=]/g, "")); + for (let line of photo.getElement(["BINVAL"]).innerText.split("\n")) { + data += line; + // Mozilla's atob() doesn't handle padding with "=" or "==" + // unless it's at the end of the string, so we have to work around that. + if (line.endsWith("=")) { + content += parseBase64(data); + data = ""; + } + } + content += parseBase64(data); + + // Store a sha1 hash of the photo we have just received. + let ch = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + ch.init(ch.SHA1); + let dataArray = Object.keys(content).map(i => content.charCodeAt(i)); + ch.update(dataArray, dataArray.length); + let hash = ch.finish(false); + function toHexString(charCode) { + return charCode.toString(16).padStart(2, "0"); + } + resource._photoHash = Object.keys(hash) + .map(i => toHexString(hash.charCodeAt(i))) + .join(""); + + let istream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + istream.setData(content, content.length); + + let fileName = resource._photoHash + "." + kExt[type]; + let file = lazy.FileUtils.getFile("ProfD", [ + "icons", + this.protocol.normalizedName, + this.normalizedName, + fileName, + ]); + let ostream = lazy.FileUtils.openSafeFileOutputStream(file); + return new Promise(resolve => { + lazy.NetUtil.asyncCopy(istream, ostream, rc => { + if (Components.isSuccessCode(rc)) { + resolve(Services.io.newFileURI(file).spec); + } + }); + }); + }, + + _requestNextVCard() { + if (!this._pendingVCardRequests.length) { + return; + } + let s = Stanza.iq( + "get", + null, + this._pendingVCardRequests[0], + Stanza.node("vCard", Stanza.NS.vcard) + ); + this.sendStanza(s, this.onVCard, this); + }, + + _addVCardRequest(aJID) { + let requestPending = !!this._pendingVCardRequests.length; + this._pendingVCardRequests.push(aJID); + if (!requestPending) { + this._requestNextVCard(); + } + }, + + // XEP-0029 (Section 2) and RFC 6122 (Section 2): The node and domain are + // lowercase, while resources are case sensitive and can contain spaces. + normalizeFullJid(aJID) { + return this._parseJID(aJID.trim()).jid; + }, + + // Standard normalization for XMPP removes the resource part of jids. + normalize(aJID) { + return aJID + .trim() + .split("/", 1)[0] // up to first slash + .toLowerCase(); + }, + + // RFC 6122 (Section 2): [ localpart "@" ] domainpart [ "/" resourcepart ] is + // the form of jid. + // Localpart is parsed as node and optional. + // Domainpart is parsed as domain and required. + // resourcepart is parsed as resource and optional. + _parseJID(aJid) { + let match = /^(?:([^"&'/:<>@]+)@)?([^@/<>'\"]+)(?:\/(.*))?$/.exec( + aJid.trim() + ); + if (!match) { + return null; + } + + let result = { + node: match[1], + domain: match[2].toLowerCase(), + resource: match[3], + }; + return this._setJID(result.domain, result.node, result.resource); + }, + + // Constructs jid as an object from domain, node and resource parts. + // The object has properties (node, domain, resource and jid). + // aDomain is required, but aNode and aResource are optional. + _setJID(aDomain, aNode = null, aResource = null) { + if (!aDomain) { + throw new Error("aDomain must have a value"); + } + + let result = { + node: aNode, + domain: aDomain.toLowerCase(), + resource: aResource, + }; + let jid = result.domain; + if (result.node) { + result.node = result.node.toLowerCase(); + jid = result.node + "@" + jid; + } + if (result.resource) { + jid += "/" + result.resource; + } + result.jid = jid; + return result; + }, + + _onRosterItem(aItem, aNotifyOfUpdates) { + let jid = aItem.attributes.jid; + if (!jid) { + this.WARN("Received a roster item without jid: " + aItem.getXML()); + return ""; + } + jid = this.normalize(jid); + + let subscription = ""; + if ("subscription" in aItem.attributes) { + subscription = aItem.attributes.subscription; + } + if (subscription == "remove") { + this._forgetRosterItem(jid); + return ""; + } + + let buddy; + if (this._buddies.has(jid)) { + buddy = this._buddies.get(jid); + let groups = aItem.getChildren("group"); + if (groups.length) { + // If the server specified at least one group, ensure the group we use + // as the account buddy's tag is still a group on the server... + let tagName = buddy.tag.name; + if (!groups.some(g => g.innerText == tagName)) { + // ... otherwise we need to move our account buddy to a new group. + tagName = groups[0].innerText; + if (tagName) { + // Should always be true, but check just in case... + let oldTag = buddy.tag; + buddy._tag = IMServices.tags.createTag(tagName); + IMServices.contacts.accountBuddyMoved(buddy, oldTag, buddy._tag); + } + } + } + } else { + let tag; + for (let group of aItem.getChildren("group")) { + let name = group.innerText; + if (name) { + tag = IMServices.tags.createTag(name); + break; // TODO we should create an accountBuddy per group, + // but this._buddies would probably not like that... + } + } + buddy = new this._accountBuddyConstructor( + this, + null, + tag || IMServices.tags.defaultTag, + jid + ); + } + + // We request the vCard only if we haven't received it yet and are + // subscribed to presence for that contact. + if ( + (subscription == "both" || subscription == "to") && + !buddy._vCardReceived + ) { + this._addVCardRequest(jid); + } + + let alias = "name" in aItem.attributes ? aItem.attributes.name : ""; + if (alias) { + if (aNotifyOfUpdates && this._buddies.has(jid)) { + buddy.rosterAlias = alias; + } else { + buddy._rosterAlias = alias; + } + } else if (buddy._rosterAlias) { + buddy.rosterAlias = ""; + } + + if (subscription) { + buddy.subscription = subscription; + } + if (!this._buddies.has(jid)) { + this._buddies.set(jid, buddy); + IMServices.contacts.accountBuddyAdded(buddy); + } else if (aNotifyOfUpdates) { + buddy._notifyObservers("status-detail-changed"); + } + + // Keep the xml nodes of the item so that we don't have to + // recreate them when changing something (eg. the alias) in it. + buddy._rosterItem = aItem; + + return jid; + }, + _forgetRosterItem(aJID) { + IMServices.contacts.accountBuddyRemoved(this._buddies.get(aJID)); + this._buddies.delete(aJID); + }, + + /* When the roster is received */ + onRoster(aStanza) { + // For the first element that is a roster stanza. + for (let qe of aStanza.getChildren("query")) { + if (qe.uri != Stanza.NS.roster) { + continue; + } + + // Find all the roster items in the new message. + let newRoster = new Set(); + for (let item of qe.getChildren("item")) { + let jid = this._onRosterItem(item); + if (jid) { + newRoster.add(jid); + } + } + // If an item was in the old roster, but not in the new, forget it. + for (let jid of this._buddies.keys()) { + if (!newRoster.has(jid)) { + this._forgetRosterItem(jid); + } + } + break; + } + + this._sendPresence(); + this._buddies.forEach(b => { + if (b.subscription == "both" || b.subscription == "to") { + b.setStatus(Ci.imIStatusInfo.STATUS_OFFLINE, ""); + } + }); + this.reportConnected(); + this._sendVCard(); + }, + + /* Public methods */ + + sendStanza(aStanza, aCallback, aThis, aLogString) { + return this._connection.sendStanza(aStanza, aCallback, aThis, aLogString); + }, + + // Variations of the XMPP protocol can change these default constructors: + _conversationConstructor: XMPPConversation, + _MUCConversationConstructor: XMPPMUCConversation, + _accountBuddyConstructor: XMPPAccountBuddy, + + /* Create a new conversation */ + createConversation(aName) { + let convName = this.normalize(aName); + + // Checks if conversation is with a participant of a MUC we are in. We do + // not want to strip the resource as it is of the form room@domain/nick. + let isMucParticipant = this._mucs.has(convName); + if (isMucParticipant) { + convName = this.normalizeFullJid(aName); + } + + // Checking that the aName can be parsed and is not broken. + let jid = this._parseJID(convName); + if ( + !jid || + !jid.domain || + (isMucParticipant && (!jid.node || !jid.resource)) + ) { + this.ERROR("Could not create conversation as jid is broken: " + convName); + throw new Error("Invalid JID"); + } + + if (!this._conv.has(convName)) { + this._conv.set( + convName, + new this._conversationConstructor(this, convName, isMucParticipant) + ); + } + + return this._conv.get(convName); + }, + + /* Remove an existing conversation */ + removeConversation(aNormalizedName) { + if (this._conv.has(aNormalizedName)) { + this._conv.delete(aNormalizedName); + } else if (this._mucs.has(aNormalizedName)) { + this._mucs.delete(aNormalizedName); + } + }, + + /* Private methods */ + + /** + * Disconnect from the server + * + * @param {number} aError - The error reason, passed to reportDisconnecting. + * @param {string} aErrorMessage - The error message, passed to reportDisconnecting. + * @param {boolean} aQuiet - True to avoid sending status change notifications + * during the uninitialization of the account. + */ + _disconnect( + aError = Ci.prplIAccount.NO_ERROR, + aErrorMessage = "", + aQuiet = false + ) { + if (!this._connection) { + return; + } + + this.reportDisconnecting(aError, aErrorMessage); + + this._buddies.forEach(b => { + if (!aQuiet) { + b.setStatus(Ci.imIStatusInfo.STATUS_UNKNOWN, ""); + } + b.onAccountDisconnected(); + }); + + this._mucs.forEach(muc => { + muc.joining = false; // In case we never finished joining. + muc.left = true; + }); + + this._connection.disconnect(); + delete this._connection; + + // We won't receive "user-icon-changed" notifications while the + // account isn't connected, so clear the cache to avoid keeping an + // obsolete icon. + delete this._cachedUserIcon; + // Also clear the cached user vCard, as we will want to redownload it + // after reconnecting. + delete this._userVCard; + + // Clear vCard requests. + this._pendingVCardRequests = []; + + this.reportDisconnected(); + }, + + /* Set the user status on the server */ + _sendPresence() { + delete this._shouldSendPresenceForIdlenessChange; + + if (!this._connection) { + return; + } + + let si = this.imAccount.statusInfo; + let statusType = si.statusType; + let show = ""; + if (statusType == Ci.imIStatusInfo.STATUS_UNAVAILABLE) { + show = "dnd"; + } else if ( + statusType == Ci.imIStatusInfo.STATUS_AWAY || + statusType == Ci.imIStatusInfo.STATUS_IDLE + ) { + show = "away"; + } + let children = []; + if (show) { + children.push(Stanza.node("show", null, null, show)); + } + let statusText = si.statusText; + if (statusText) { + children.push(Stanza.node("status", null, null, statusText)); + } + if (this._idleSince) { + let time = Math.floor(Date.now() / 1000) - this._idleSince; + children.push(Stanza.node("query", Stanza.NS.last, { seconds: time })); + } + if (this.prefs.prefHasUserValue("priority")) { + let priority = Math.max(-128, Math.min(127, this.getInt("priority"))); + if (priority) { + children.push(Stanza.node("priority", null, null, priority.toString())); + } + } + this.sendStanza( + Stanza.presence({ "xml:lang": "en" }, children), + aStanza => { + // As we are implicitly subscribed to our own presence (rfc6121#4), we + // will receive the presence stanza mirrored back to us. We don't need + // to do anything with this response. + return true; + } + ); + }, + + _downloadingUserVCard: false, + _downloadUserVCard() { + // If a download is already in progress, don't start another one. + if (this._downloadingUserVCard) { + return; + } + this._downloadingUserVCard = true; + let s = Stanza.iq("get", null, null, Stanza.node("vCard", Stanza.NS.vcard)); + this.sendStanza(s, this.onUserVCard, this); + }, + + onUserVCard(aStanza) { + delete this._downloadingUserVCard; + let userVCard = aStanza.getElement(["vCard"]) || null; + if (userVCard) { + // Strip any server-specific namespace off the incoming vcard + // before storing it. + this._userVCard = Stanza.node( + "vCard", + Stanza.NS.vcard, + null, + userVCard.children + ); + } + + // If a user icon exists in the vCard we received from the server, + // we need to ensure the line breaks in its binval are exactly the + // same as those we would include if we sent the icon, and that + // there isn't any other whitespace. + if (this._userVCard) { + let binval = this._userVCard.getElement(["PHOTO", "BINVAL"]); + if (binval && binval.children.length) { + binval = binval.children[0]; + binval.text = binval.text + .replace(/[^A-Za-z0-9\+\/\=]/g, "") + .replace(/.{74}/g, "$&\n"); + } + } else { + // Downloading the vCard failed. + if ( + this.handleErrors({ + itemNotFound: () => false, // OK, no vCard exists yet. + default: () => true, + })(aStanza) + ) { + this.WARN( + "Unexpected error retrieving the user's vcard, " + + "so we won't attempt to set it either." + ); + return; + } + // Set this so that we don't get into an infinite loop trying to download + // the vcard again. The check in sendVCard is for hasOwnProperty. + this._userVCard = null; + } + this._sendVCard(); + }, + + _cachingUserIcon: false, + _cacheUserIcon() { + if (this._cachingUserIcon) { + return; + } + + let userIcon = this.imAccount.statusInfo.getUserIcon(); + if (!userIcon) { + this._cachedUserIcon = null; + this._sendVCard(); + return; + } + + this._cachingUserIcon = true; + let channel = lazy.NetUtil.newChannel({ + uri: userIcon, + loadingPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + securityFlags: + Ci.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_INHERITS_SEC_CONTEXT, + contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE, + }); + lazy.NetUtil.asyncFetch(channel, (inputStream, resultCode) => { + if (!Components.isSuccessCode(resultCode)) { + return; + } + try { + let type = channel.contentType; + let buffer = lazy.NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + let readImage = lazy.imgTools.decodeImageFromBuffer( + buffer, + buffer.length, + type + ); + let scaledImage; + if (readImage.width <= 96 && readImage.height <= 96) { + scaledImage = lazy.imgTools.encodeImage(readImage, type); + } else { + if (type != "image/jpeg") { + type = "image/png"; + } + scaledImage = lazy.imgTools.encodeScaledImage( + readImage, + type, + 64, + 64 + ); + } + + let bstream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + bstream.setInputStream(scaledImage); + + let data = bstream.readBytes(bstream.available()); + this._cachedUserIcon = { + type, + binval: btoa(data).replace(/.{74}/g, "$&\n"), + }; + } catch (e) { + console.error(e); + this._cachedUserIcon = null; + } + delete this._cachingUserIcon; + this._sendVCard(); + }); + }, + _sendVCard() { + if (!this._connection) { + return; + } + + // We have to download the user's existing vCard before updating it. + // This lets us preserve the fields that we don't change or don't know. + // Some servers may reject a new vCard if we don't do this first. + if (!this.hasOwnProperty("_userVCard")) { + // The download of the vCard is asynchronous and will call _sendVCard back + // when the user's vCard has been received. + this._downloadUserVCard(); + return; + } + + // Read the local user icon asynchronously from the disk. + // _cacheUserIcon will call _sendVCard back once the icon is ready. + if (!this.hasOwnProperty("_cachedUserIcon")) { + this._cacheUserIcon(); + return; + } + + // If the user currently doesn't have any vCard on the server or + // the download failed, an empty new one. + if (!this._userVCard) { + this._userVCard = Stanza.node("vCard", Stanza.NS.vcard); + } + + // Keep a serialized copy of the existing user vCard so that we + // can avoid resending identical data to the server. + let existingVCard = this._userVCard.getXML(); + + let fn = this._userVCard.getElement(["FN"]); + let displayName = this.imAccount.statusInfo.displayName; + if (displayName) { + // If a display name is set locally, update or add an FN field to the vCard. + if (!fn) { + this._userVCard.addChild( + Stanza.node("FN", Stanza.NS.vcard, null, displayName) + ); + } else if (fn.children.length) { + fn.children[0].text = displayName; + } else { + fn.addText(displayName); + } + } else if ("_forceUserDisplayNameUpdate" in this) { + // We remove a display name stored on the server without replacing + // it with a new value only if this _sendVCard call is the result of + // a user action. This is to avoid removing data from the server each + // time the user connects from a new profile. + this._userVCard.children = this._userVCard.children.filter( + n => n.qName != "FN" + ); + } + delete this._forceUserDisplayNameUpdate; + + if (this._cachedUserIcon) { + // If we have a local user icon, update or add it in the PHOTO field. + let photoChildren = [ + Stanza.node("TYPE", Stanza.NS.vcard, null, this._cachedUserIcon.type), + Stanza.node( + "BINVAL", + Stanza.NS.vcard, + null, + this._cachedUserIcon.binval + ), + ]; + let photo = this._userVCard.getElement(["PHOTO"]); + if (photo) { + photo.children = photoChildren; + } else { + this._userVCard.addChild( + Stanza.node("PHOTO", Stanza.NS.vcard, null, photoChildren) + ); + } + } else if ("_forceUserIconUpdate" in this) { + // Like for the display name, we remove a photo without + // replacing it only if the call is caused by a user action. + this._userVCard.children = this._userVCard.children.filter( + n => n.qName != "PHOTO" + ); + } + delete this._forceUserIconUpdate; + + // Send the vCard only if it has really changed. + // We handle the result response from the server (it does not require + // any further action). + if (this._userVCard.getXML() != existingVCard) { + this.sendStanza( + Stanza.iq("set", null, null, this._userVCard), + this._handleResult() + ); + } else { + this.LOG( + "Not sending the vCard because the server stored vCard is identical." + ); + } + }, +}; |