diff --git a/karma.conf.js b/karma.conf.js index 8e0939fd3..3babc9bc2 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -49,6 +49,7 @@ module.exports = function(config) { { pattern: "spec/corrections.js", type: 'module' }, { pattern: "spec/styling.js", type: 'module' }, { pattern: "spec/receipts.js", type: 'module' }, + { pattern: "spec/markers.js", type: 'module' }, { pattern: "spec/muc_messages.js", type: 'module' }, { pattern: "spec/me-messages.js", type: 'module' }, { pattern: "spec/mentions.js", type: 'module' }, diff --git a/spec/chatbox.js b/spec/chatbox.js index 57ab92718..5754cdeb3 100644 --- a/spec/chatbox.js +++ b/spec/chatbox.js @@ -1007,19 +1007,17 @@ describe("Chatboxes", function () { const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); - const view = await mock.openChatBoxFor(_converse, sender_jid) - spyOn(view.model, 'sendMarker').and.callThrough(); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); view.model.save('scrolled', true); await _converse.handleMessageStanza(msg); await u.waitUntil(() => view.model.messages.length); expect(view.model.get('num_unread')).toBe(1); const msgid = view.model.messages.last().get('id'); expect(view.model.get('first_unread_id')).toBe(msgid); - await u.waitUntil(() => view.model.sendMarker.calls.count() === 1); - expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.length); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); done(); })); @@ -1031,15 +1029,14 @@ describe("Chatboxes", function () { await mock.waitForRoster(_converse, 'current', 1); const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read'); - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); const chatbox = _converse.chatboxes.get(sender_jid); - spyOn(chatbox, 'sendMarker').and.callThrough(); await _converse.handleMessageStanza(msg); expect(chatbox.get('num_unread')).toBe(0); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 2); - expect(sent_stanzas[1].nodeTree.querySelector('displayed')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2); + expect(sent_stanzas[1].querySelector('displayed')).toBeDefined(); done(); })); @@ -1053,12 +1050,10 @@ describe("Chatboxes", function () { const msgFactory = function () { return mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); }; - - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); const chatbox = _converse.chatboxes.get(sender_jid); - spyOn(chatbox, 'sendMarker').and.callThrough(); _converse.windowState = 'hidden'; const msg = msgFactory(); _converse.handleMessageStanza(msg); @@ -1066,8 +1061,8 @@ describe("Chatboxes", function () { expect(chatbox.get('num_unread')).toBe(1); const msgid = chatbox.messages.last().get('id'); expect(chatbox.get('first_unread_id')).toBe(msgid); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1); - expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); done(); })); @@ -1079,11 +1074,10 @@ describe("Chatboxes", function () { await mock.waitForRoster(_converse, 'current', 1); const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); const chatbox = _converse.chatboxes.get(sender_jid); - spyOn(chatbox, 'sendMarker').and.callThrough(); chatbox.save('scrolled', true); _converse.windowState = 'hidden'; const msg = msgFactory(); @@ -1092,8 +1086,8 @@ describe("Chatboxes", function () { expect(chatbox.get('num_unread')).toBe(1); const msgid = chatbox.messages.last().get('id'); expect(chatbox.get('first_unread_id')).toBe(msgid); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1); - expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); done(); })); @@ -1105,11 +1099,10 @@ describe("Chatboxes", function () { await mock.waitForRoster(_converse, 'current', 1); const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); const chatbox = _converse.chatboxes.get(sender_jid); - spyOn(chatbox, 'sendMarker').and.callThrough(); _converse.windowState = 'hidden'; const msg = msgFactory(); _converse.handleMessageStanza(msg); @@ -1117,12 +1110,12 @@ describe("Chatboxes", function () { expect(chatbox.get('num_unread')).toBe(1); const msgid = chatbox.messages.last().get('id'); expect(chatbox.get('first_unread_id')).toBe(msgid); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1); - expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); _converse.saveWindowState({'type': 'focus'}); expect(chatbox.get('num_unread')).toBe(0); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 2); - expect(sent_stanzas[1].nodeTree.querySelector('displayed')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2); + expect(sent_stanzas[1].querySelector('displayed')).toBeDefined(); done(); })); @@ -1134,11 +1127,10 @@ describe("Chatboxes", function () { await mock.waitForRoster(_converse, 'current', 1); const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); const chatbox = _converse.chatboxes.get(sender_jid); - spyOn(chatbox, 'sendMarker').and.callThrough(); chatbox.save('scrolled', true); _converse.windowState = 'hidden'; const msg = msgFactory(); @@ -1147,13 +1139,12 @@ describe("Chatboxes", function () { expect(chatbox.get('num_unread')).toBe(1); const msgid = chatbox.messages.last().get('id'); expect(chatbox.get('first_unread_id')).toBe(msgid); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1); - expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined(); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); _converse.saveWindowState({'type': 'focus'}); await u.waitUntil(() => chatbox.get('num_unread') === 1); expect(chatbox.get('first_unread_id')).toBe(msgid); - await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1); - expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined(); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); done(); })); }); diff --git a/spec/markers.js b/spec/markers.js new file mode 100644 index 000000000..daa2c807a --- /dev/null +++ b/spec/markers.js @@ -0,0 +1,186 @@ +/*global mock, converse */ + +const Strophe = converse.env.Strophe; +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("A XEP-0333 Chat Marker", function () { + + it("is sent when a markable message is received from a roster contact", + mock.initConverse( + ['rosterGroupsFetched'], {}, + async function (done, _converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const msgid = u.getUniqueId(); + const stanza = u.toStanza(` + + My lord, dispatch; read o'er these articles. + + `); + + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => sent_stanzas.length === 2); + expect(Strophe.serialize(sent_stanzas[0])).toBe( + ``+ + ``+ + ``); + done(); + })); + + it("is not sent when a markable message is received from someone not on the roster", + mock.initConverse( + ['rosterGroupsFetched'], {'allow_non_roster_messaging': true}, + async function (done, _converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const contact_jid = 'someone@montague.lit'; + const msgid = u.getUniqueId(); + const stanza = u.toStanza(` + + My lord, dispatch; read o'er these articles. + + `); + + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); + await _converse.handleMessageStanza(stanza); + const sent_messages = sent_stanzas + .map(s => s?.nodeTree ?? s) + .filter(e => e.nodeName === 'message'); + + await u.waitUntil(() => sent_messages.length === 2); + expect(Strophe.serialize(sent_messages[0])).toBe( + ``+ + ``+ + ``+ + ``+ + `` + ); + done(); + })); + + it("is ignored if it's a carbon copy of one that I sent from a different client", + mock.initConverse( + ['rosterGroupsFetched'], {}, + async function (done, _converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.api.chatviews.get(contact_jid); + + let stanza = u.toStanza(` + + 😊 + + + + `); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(1); + + stanza = u.toStanza( + ` + + + + + + + + + + `); + spyOn(_converse.api, "trigger").and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.api.trigger.calls.count(), 500); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(1); + done(); + })); + + + it("may be returned for a MUC message", + mock.initConverse( + ['rosterGroupsFetched'], {}, + async function (done, _converse) { + + await mock.waitForRoster(_converse, 'current'); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.api.chatviews.get(muc_jid); + const textarea = view.el.querySelector('textarea.chat-textarea'); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + view.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim()) + .toBe("But soft, what light through yonder airlock breaks?"); + + const msg_obj = view.model.messages.at(0); + let stanza = u.toStanza(` + + + `); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); + expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + + + `); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + + + `); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + + 'tis I! + + `); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); + expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); + done(); + })); +}); diff --git a/spec/messages.js b/spec/messages.js index a781c1e3a..ccae65bbc 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -1548,122 +1548,3 @@ describe("A Chat Message", function () { })); }); }); - -describe("A XEP-0333 Chat Marker", function () { - - it("is sent when a markable message is received from a roster contact", - mock.initConverse( - ['rosterGroupsFetched'], {}, - async function (done, _converse) { - - await mock.waitForRoster(_converse, 'current', 1); - const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - await mock.openChatBoxFor(_converse, contact_jid); - const view = _converse.api.chatviews.get(contact_jid); - const msgid = u.getUniqueId(); - const stanza = u.toStanza(` - - My lord, dispatch; read o'er these articles. - - `); - - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); - spyOn(view.model, 'sendMarker').and.callThrough(); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => view.model.sendMarker.calls.count() === 2); - expect(Strophe.serialize(sent_stanzas[0])).toBe( - ``+ - ``+ - ``); - done(); - })); - - it("is not sent when a markable message is received from someone not on the roster", - mock.initConverse( - ['rosterGroupsFetched'], {'allow_non_roster_messaging': true}, - async function (done, _converse) { - - await mock.waitForRoster(_converse, 'current', 0); - const contact_jid = 'someone@montague.lit'; - const msgid = u.getUniqueId(); - const stanza = u.toStanza(` - - My lord, dispatch; read o'er these articles. - - `); - - const sent_stanzas = []; - spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); - await _converse.handleMessageStanza(stanza); - const sent_messages = sent_stanzas - .map(s => _.isElement(s) ? s : s.nodeTree) - .filter(e => e.nodeName === 'message'); - - await u.waitUntil(() => sent_messages.length === 2); - expect(Strophe.serialize(sent_messages[0])).toBe( - ``+ - ``+ - ``+ - ``+ - `` - ); - done(); - })); - - it("is ignored if it's a carbon copy of one that I sent from a different client", - mock.initConverse( - ['rosterGroupsFetched'], {}, - async function (done, _converse) { - - await mock.waitForRoster(_converse, 'current', 1); - await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); - - const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; - await mock.openChatBoxFor(_converse, contact_jid); - const view = _converse.api.chatviews.get(contact_jid); - - let stanza = u.toStanza(` - - 😊 - - - - `); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await new Promise(resolve => view.model.messages.once('rendered', resolve)); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.model.messages.length).toBe(1); - - stanza = u.toStanza( - ` - - - - - - - - - - `); - spyOn(_converse.api, "trigger").and.callThrough(); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => _converse.api.trigger.calls.count(), 500); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.model.messages.length).toBe(1); - done(); - })); -}); diff --git a/spec/muc_messages.js b/spec/muc_messages.js index 00251fbc3..f547bb38b 100644 --- a/spec/muc_messages.js +++ b/spec/muc_messages.js @@ -1,6 +1,6 @@ /*global mock, converse */ -const { Promise, Strophe, $msg, $pres, sizzle, stanza_utils } = converse.env; +const { Promise, Strophe, $msg, $pres, sizzle } = converse.env; const u = converse.env.utils; const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; @@ -621,85 +621,28 @@ describe("A Groupchat Message", function () { expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); const msg_obj = view.model.messages.at(0); - const stanza = u.toStanza(` + let stanza = u.toStanza(` + + ${msg_obj.get('message')} + + + `); + await view.model.handleMessageStanza(stanza); + await u.waitUntil(() => view.model.messages.last().get('received')); + + stanza = u.toStanza(` `); - spyOn(stanza_utils, "parseMUCMessage").and.callThrough(); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.parseMUCMessage.calls.count() === 1); expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); - done(); - })); - - it("can cause a chat marker to be returned", - mock.initConverse( - ['rosterGroupsFetched'], {}, - async function (done, _converse) { - - await mock.waitForRoster(_converse, 'current'); - const muc_jid = 'lounge@montague.lit'; - await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); - const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.el.querySelector('textarea.chat-textarea'); - textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ - target: textarea, - preventDefault: function preventDefault () {}, - keyCode: 13 // Enter - }); - await new Promise(resolve => view.model.messages.once('rendered', resolve)); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim()) - .toBe("But soft, what light through yonder airlock breaks?"); - - const msg_obj = view.model.messages.at(0); - let stanza = u.toStanza(` - - - `); - const stanza_utils = converse.env.stanza_utils; - spyOn(stanza_utils, "getChatMarker").and.callThrough(); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 1); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); - - stanza = u.toStanza(` - - - `); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 2); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); - - stanza = u.toStanza(` - - - `); - _converse.connection._dataRecv(mock.createRequest(stanza)); - - await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 3); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); - expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); - - stanza = u.toStanza(` - - 'tis I! - - `); - _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 4); - await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); - expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); done(); })); }); diff --git a/src/headless/core.js b/src/headless/core.js index c2b9537fc..8e900a6ec 100644 --- a/src/headless/core.js +++ b/src/headless/core.js @@ -12,7 +12,6 @@ import pluggable from 'pluggable.js/src/pluggable'; import syncDriver from 'localforage-webextensionstorage-driver/sync'; import localDriver from 'localforage-webextensionstorage-driver/local'; import sizzle from 'sizzle'; -import stanza_utils from "@converse/headless/utils/stanza"; import u from '@converse/headless/utils/core'; import { Collection } from "@converse/skeletor/src/collection"; import { Connection, MockConnection } from '@converse/headless/shared/connection.js'; @@ -1654,7 +1653,6 @@ Object.assign(converse, { log, sizzle, sprintf, - stanza_utils, u, } }); diff --git a/src/headless/plugins/adhoc.js b/src/headless/plugins/adhoc.js index 9e167e5a6..39e2d0ddf 100644 --- a/src/headless/plugins/adhoc.js +++ b/src/headless/plugins/adhoc.js @@ -1,7 +1,7 @@ import { converse } from "../core.js"; import log from "@converse/headless/log"; import sizzle from 'sizzle'; -import st from "../utils/stanza"; +import { getAttributes } from '@converse/headless/shared/parsers'; const { Strophe } = converse.env; let _converse, api; @@ -11,7 +11,7 @@ Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands'); function parseForCommands (stanza) { const items = sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"][node="${Strophe.NS.ADHOC}"] item`, stanza); - return items.map(st.getAttributes) + return items.map(getAttributes) } diff --git a/src/headless/plugins/chat/index.js b/src/headless/plugins/chat/index.js index 3d5adf0d2..6e21def46 100644 --- a/src/headless/plugins/chat/index.js +++ b/src/headless/plugins/chat/index.js @@ -8,9 +8,10 @@ import MessageMixin from './message.js'; import ModelWithContact from './model-with-contact.js'; import chat_api from './api.js'; import log from '../../log.js'; -import st from '../../utils/stanza'; import { Collection } from "@converse/skeletor/src/collection"; import { _converse, api, converse } from '../../core.js'; +import { isServerMessage, } from '@converse/headless/shared/parsers'; +import { parseMessage } from './parsers.js'; const { Strophe, sizzle, utils } = converse.env; const u = converse.env.utils; @@ -74,12 +75,12 @@ converse.plugins.add('converse-chat', { * @param { MessageAttributes } attrs - The message attributes */ _converse.handleMessageStanza = async function (stanza) { - if (st.isServerMessage(stanza)) { + if (isServerMessage(stanza)) { // Prosody sends headline messages with type `chat`, so we need to filter them out here. const from = stanza.getAttribute('from'); return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`); } - const attrs = await st.parseMessage(stanza, _converse); + const attrs = await parseMessage(stanza, _converse); if (u.isErrorObject(attrs)) { attrs.stanza && log.error(attrs.stanza); return log.error(attrs.message); diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index d6d5de769..862ceac8f 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -1,10 +1,11 @@ import ModelWithContact from './model-with-contact.js'; import filesize from "filesize"; -import log from "../../log.js"; -import st from "../../utils/stanza"; +import log from '@converse/headless/log'; import { Model } from '@converse/skeletor/src/model.js'; import { _converse, api, converse } from "../../core.js"; import { find, isMatch, isObject, pick } from "lodash-es"; +import { parseMessage } from './parsers.js'; +import { sendMarker } from '@converse/headless/shared/actions'; const { Strophe, $msg } = converse.env; @@ -130,7 +131,7 @@ const ChatBox = ModelWithContact.extend({ async handleErrorMessageStanza (stanza) { const { __ } = _converse; - const attrs = await st.parseMessage(stanza, _converse); + const attrs = await parseMessage(stanza, _converse); if (!await this.shouldShowErrorMessage(attrs)) { return; } @@ -392,7 +393,7 @@ const ChatBox = ModelWithContact.extend({ * @private * @method _converse.ChatBox#findDanglingRetraction * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link parseMessage} * @returns { _converse.Message } */ findDanglingRetraction (attrs) { @@ -419,7 +420,7 @@ const ChatBox = ModelWithContact.extend({ * @private * @method _converse.ChatBox#handleRetraction * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link parseMessage} * @returns { Boolean } Returns `true` or `false` depending on * whether a message was retracted or not. */ @@ -459,7 +460,7 @@ const ChatBox = ModelWithContact.extend({ * @private * @method _converse.ChatBox#handleCorrection * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link parseMessage} * @returns { _converse.Message|undefined } Returns the corrected * message or `undefined` if not applicable. */ @@ -497,7 +498,7 @@ const ChatBox = ModelWithContact.extend({ * @private * @method _converse.ChatBox#getDuplicateMessage * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link parseMessage} * @returns {Promise<_converse.Message>} */ getDuplicateMessage (attrs) { @@ -604,27 +605,10 @@ const ChatBox = ModelWithContact.extend({ if (!msg) return; if (msg?.get('is_markable') || force) { const from_jid = Strophe.getBareJidFromJid(msg.get('from')); - this.sendMarker(from_jid, msg.get('msgid'), type, msg.get('type')); + sendMarker(from_jid, msg.get('msgid'), type, msg.get('type')); } }, - /** - * Send out a XEP-0333 chat marker - * @param { String } to_jid - * @param { String } id - The id of the message being marked - * @param { String } type - The marker type - * @param { String } msg_type - */ - sendMarker (to_jid, id, type, msg_type) { - const stanza = $msg({ - 'from': _converse.connection.jid, - 'id': u.getUniqueId(), - 'to': to_jid, - 'type': msg_type ? msg_type : 'chat' - }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id}); - api.send(stanza); - }, - handleChatMarker (attrs) { const to_bare_jid = Strophe.getBareJidFromJid(attrs.to); if (to_bare_jid !== _converse.bare_jid) { @@ -632,7 +616,7 @@ const ChatBox = ModelWithContact.extend({ } if (attrs.is_markable) { if (this.contact && !attrs.is_archived && !attrs.is_carbon) { - this.sendMarker(attrs.from, attrs.msgid, 'received'); + sendMarker(attrs.from, attrs.msgid, 'received'); } return false; } else if (attrs.marker_id) { diff --git a/src/headless/plugins/chat/parsers.js b/src/headless/plugins/chat/parsers.js new file mode 100644 index 000000000..c4238c858 --- /dev/null +++ b/src/headless/plugins/chat/parsers.js @@ -0,0 +1,219 @@ +import dayjs from 'dayjs'; +import log from '@converse/headless/log'; +import u from '@converse/headless/utils/core'; +import { api, converse } from '@converse/headless/core'; +import { rejectMessage } from '@converse/headless/shared/actions'; + +import { + StanzaParseError, + getChatMarker, + getChatState, + getCorrectionAttributes, + getEncryptionAttributes, + getErrorAttributes, + getOutOfBandAttributes, + getReceiptId, + getReferences, + getRetractionAttributes, + getSpoilerAttributes, + getStanzaIDs, + isArchived, + isCarbon, + isHeadline, + isServerMessage, + isValidReceiptRequest, + rejectUnencapsulatedForward, +} from '@converse/headless/shared/parsers'; + +const { Strophe, sizzle } = converse.env; + + +/** + * Parses a passed in message stanza and returns an object of attributes. + * @method st#parseMessage + * @param { XMLElement } stanza - The message stanza + * @param { _converse } _converse + * @returns { (MessageAttributes|Error) } + */ +export async function parseMessage (stanza, _converse) { + const err = rejectUnencapsulatedForward(stanza); + if (err) { + return err; + } + + let to_jid = stanza.getAttribute('to'); + const to_resource = Strophe.getResourceFromJid(to_jid); + if (api.settings.get('filter_by_resource') && to_resource && to_resource !== _converse.resource) { + return new StanzaParseError( + `Ignoring incoming message intended for a different resource: ${to_jid}`, + stanza + ); + } + + const original_stanza = stanza; + let from_jid = stanza.getAttribute('from') || _converse.bare_jid; + if (isCarbon(stanza)) { + if (from_jid === _converse.bare_jid) { + const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; + stanza = sizzle(selector, stanza).pop(); + to_jid = stanza.getAttribute('to'); + from_jid = stanza.getAttribute('from'); + } else { + // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security + rejectMessage(stanza, 'Rejecting carbon from invalid JID'); + return new StanzaParseError(`Rejecting carbon from invalid JID ${to_jid}`, stanza); + } + } + + const is_archived = isArchived(stanza); + if (is_archived) { + if (from_jid === _converse.bare_jid) { + const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; + stanza = sizzle(selector, stanza).pop(); + to_jid = stanza.getAttribute('to'); + from_jid = stanza.getAttribute('from'); + } else { + return new StanzaParseError( + `Invalid Stanza: alleged MAM message from ${stanza.getAttribute('from')}`, + stanza + ); + } + } + + const from_bare_jid = Strophe.getBareJidFromJid(from_jid); + const is_me = from_bare_jid === _converse.bare_jid; + if (is_me && to_jid === null) { + return new StanzaParseError( + `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`, + stanza + ); + } + + const is_headline = isHeadline(stanza); + const is_server_message = isServerMessage(stanza); + let contact, contact_jid; + if (!is_headline && !is_server_message) { + contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid; + contact = await api.contacts.get(contact_jid); + if (contact === undefined && !api.settings.get('allow_non_roster_messaging')) { + log.error(stanza); + return new StanzaParseError( + `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`, + stanza + ); + } + } + /** + * @typedef { Object } MessageAttributes + * The object which {@link parseMessage} returns + * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else + * @property { Array } references - A list of objects representing XEP-0372 references + * @property { Boolean } editable - Is this message editable via XEP-0308? + * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive? + * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon? + * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203? + * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted? + * @property { Boolean } is_error - Whether an error was received for this message + * @property { Boolean } is_headline - Is this a "headline" message? + * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? + * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? + * @property { Boolean } is_only_emojis - Does the message body contain only emojis? + * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? + * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored + * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) + * @property { Object } encrypted - XEP-0384 encryption payload attributes + * @property { String } body - The contents of the tag of the message stanza + * @property { String } chat_state - The XEP-0085 chat state notification contained in this message + * @property { String } contact_jid - The JID of the other person or entity + * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308 + * @property { String } error_condition - The defined error condition + * @property { String } error_text - The error text received from the server + * @property { String } error_type - The type of error received from the server + * @property { String } from - The sender JID + * @property { String } fullname - The full name of the sender + * @property { String } marker - The XEP-0333 Chat Marker value + * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker + * @property { String } msgid - The root `id` attribute of the stanza + * @property { String } nick - The roster nickname of the sender + * @property { String } oob_desc - The description of the XEP-0066 out of band data + * @property { String } oob_url - The URL of the XEP-0066 out of band data + * @property { String } origin_id - The XEP-0359 Origin ID + * @property { String } receipt_id - The `id` attribute of a XEP-0184 element + * @property { String } received - An ISO8601 string recording the time that the message was received + * @property { String } replace_id - The `id` attribute of a XEP-0308 element + * @property { String } retracted - An ISO8601 string recording the time that the message was retracted + * @property { String } retracted_id - The `id` attribute of a XEP-424 element + * @property { String } spoiler_hint The XEP-0382 spoiler hint + * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple. + * @property { String } subject - The element value + * @property { String } thread - The element value + * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 element, or of receipt. + * @property { String } to - The recipient JID + * @property { String } type - The type of message + */ + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const marker = getChatMarker(stanza); + const now = new Date().toISOString(); + let attrs = Object.assign( + { + contact_jid, + is_archived, + is_headline, + is_server_message, + 'body': stanza.querySelector('body')?.textContent?.trim(), + 'chat_state': getChatState(stanza), + 'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')), + 'is_carbon': isCarbon(original_stanza), + 'is_delayed': !!delay, + 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, + 'is_marker': !!marker, + 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, + 'marker_id': marker && marker.getAttribute('id'), + 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), + 'nick': contact?.attributes?.nickname, + 'receipt_id': getReceiptId(stanza), + 'received': new Date().toISOString(), + 'references': getReferences(stanza), + 'sender': is_me ? 'me' : 'them', + 'subject': stanza.querySelector('subject')?.textContent, + 'thread': stanza.querySelector('thread')?.textContent, + 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now, + 'to': stanza.getAttribute('to'), + 'type': stanza.getAttribute('type') + }, + getErrorAttributes(stanza), + getOutOfBandAttributes(stanza), + getSpoilerAttributes(stanza), + getCorrectionAttributes(stanza, original_stanza), + getStanzaIDs(stanza, original_stanza), + getRetractionAttributes(stanza, original_stanza), + getEncryptionAttributes(stanza, _converse) + ); + + if (attrs.is_archived) { + const from = original_stanza.getAttribute('from'); + if (from && from !== _converse.bare_jid) { + return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza); + } + } + await api.emojis.initialize(); + attrs = Object.assign( + { + 'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead + 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, + 'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs) + }, + attrs + ); + + // We prefer to use one of the XEP-0359 unique and stable stanza IDs + // as the Model id, to avoid duplicates. + attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from}`] || u.getUniqueId(); + + /** + * *Hook* which allows plugins to add additional parsing + * @event _converse#parseMessage + */ + return api.hook('parseMessage', stanza, attrs); +} diff --git a/src/headless/plugins/headlines.js b/src/headless/plugins/headlines.js index 935e54f77..c07f81da6 100644 --- a/src/headless/plugins/headlines.js +++ b/src/headless/plugins/headlines.js @@ -4,7 +4,8 @@ * @description XEP-0045 Multi-User Chat Views */ import { _converse, api, converse } from "@converse/headless/core"; -import st from "../utils/stanza"; +import { isHeadline, isServerMessage } from '@converse/headless/shared/parsers'; +import { parseMessage } from '@converse/headless/plugins/chat/parsers'; converse.plugins.add('converse-headlines', { @@ -79,7 +80,7 @@ converse.plugins.add('converse-headlines', { async function onHeadlineMessage (stanza) { // Handler method for all incoming messages of type "headline". - if (st.isHeadline(stanza) || st.isServerMessage(stanza)) { + if (isHeadline(stanza) || isServerMessage(stanza)) { const from_jid = stanza.getAttribute('from'); if (from_jid.includes('@') && !_converse.roster.get(from_jid) && @@ -96,7 +97,7 @@ converse.plugins.add('converse-headlines', { 'type': _converse.HEADLINES_TYPE, 'from': from_jid }); - const attrs = await st.parseMessage(stanza, _converse); + const attrs = await parseMessage(stanza, _converse); await chatbox.createMessage(attrs); api.trigger('message', {chatbox, stanza, attrs}); } diff --git a/src/headless/plugins/mam.js b/src/headless/plugins/mam.js index a732ca08e..96cbfed35 100644 --- a/src/headless/plugins/mam.js +++ b/src/headless/plugins/mam.js @@ -5,11 +5,12 @@ * @license Mozilla Public License (MPLv2) */ import "./disco"; -import { _converse, api, converse } from "@converse/headless/core"; -import log from "../log.js"; +import log from '@converse/headless/log'; import sizzle from "sizzle"; -import st from "../utils/stanza"; +import { parseMessage } from '@converse/headless/plugins/chat/parsers'; +import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers'; import { RSM } from '@converse/headless/shared/rsm'; +import { _converse, api, converse } from "@converse/headless/core"; const { Strophe, $iq, dayjs } = converse.env; const { NS } = Strophe; @@ -49,7 +50,7 @@ const MAMEnabledChat = { await api.emojis.initialize(); const is_muc = this.get('type') === _converse.CHATROOMS_TYPE; result.messages = result.messages.map( - s => (is_muc ? st.parseMUCMessage(s, this, _converse) : st.parseMessage(s, _converse)) + s => (is_muc ? parseMUCMessage(s, this, _converse) : parseMessage(s, _converse)) ); /** diff --git a/src/headless/plugins/muc/index.js b/src/headless/plugins/muc/index.js index e81c16e16..2e9c93cad 100644 --- a/src/headless/plugins/muc/index.js +++ b/src/headless/plugins/muc/index.js @@ -13,7 +13,7 @@ import ChatRoomOccupant from './occupant.js'; import ChatRoomOccupants from './occupants.js'; import log from '../../log'; import muc_api from './api.js'; -import muc_utils from '../../utils/muc'; +import muc_utils from './utils.js'; import u from '../../utils/form'; import { Collection } from '@converse/skeletor/src/collection'; import { Model } from '@converse/skeletor/src/model.js'; diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index f24e3cdb9..ece90e190 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -1,13 +1,15 @@ import log from '../../log'; -import { Model } from '@converse/skeletor/src/model.js'; -import muc_utils from '../../utils/muc'; +import muc_utils from './utils.js'; import p from '../../utils/parse-helpers'; import sizzle from 'sizzle'; -import st from '../../utils/stanza'; import u from '../../utils/form'; +import { Model } from '@converse/skeletor/src/model.js'; import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe'; import { _converse, api, converse } from '../../core.js'; import { debounce, intersection, invoke, isElement, pick, zipObject } from 'lodash-es'; +import { isArchived } from '@converse/headless/shared/parsers'; +import { parseMemberListIQ, parseMUCMessage, parseMUCPresence } from './parsers.js'; +import { sendMarker } from '@converse/headless/shared/actions'; const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322']; @@ -194,7 +196,7 @@ const ChatRoomMixin = { return; } const from_jid = Strophe.getBareJidFromJid(msg.get('from')); - this.sendMarker(from_jid, id, type, msg.get('type')); + sendMarker(from_jid, id, type, msg.get('type')); } }, @@ -365,7 +367,7 @@ const ChatRoomMixin = { async handleErrorMessageStanza (stanza) { const { __ } = _converse; - const attrs = await st.parseMUCMessage(stanza, this, _converse); + const attrs = await parseMUCMessage(stanza, this, _converse); if (!(await this.shouldShowErrorMessage(attrs))) { return; } @@ -414,7 +416,7 @@ const ChatRoomMixin = { * @param { XMLElement } stanza */ async handleMessageStanza (stanza) { - if (st.isArchived(stanza)) { + if (isArchived(stanza)) { // MAM messages are handled in converse-mam. // We shouldn't get MAM messages here because // they shouldn't have a `type` attribute. @@ -431,7 +433,7 @@ const ChatRoomMixin = { * @property { MUCMessageAttributes } attrs * @property { ChatRoom } chatbox */ - const attrs = await st.parseMUCMessage(stanza, this, _converse); + const attrs = await parseMUCMessage(stanza, this, _converse); const data = { stanza, attrs, 'chatbox': this }; /** * Triggered when a groupchat message stanza has been received and parsed. @@ -1305,8 +1307,7 @@ const ChatRoomMixin = { log.warn(result); return err; } - return muc_utils - .parseMemberListIQ(result) + return parseMemberListIQ(result) .filter(p => p) .sort((a, b) => (a.nick < b.nick ? -1 : a.nick > b.nick ? 1 : 0)); }, @@ -1438,7 +1439,7 @@ const ChatRoomMixin = { * @param { XMLElement } pres - The presence stanza */ updateOccupantsOnPresence (pres) { - const data = st.parseMUCPresence(pres); + const data = parseMUCPresence(pres); if (data.type === 'error' || (!data.jid && !data.nick)) { return true; } @@ -1538,7 +1539,7 @@ const ChatRoomMixin = { * @private * @method _converse.ChatRoom#handleSubjectChange * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMUCMessage} + * message, as returned by {@link parseMUCMessage} */ async handleSubjectChange (attrs) { const __ = _converse.__; @@ -1692,7 +1693,7 @@ const ChatRoomMixin = { * @private * @method _converse.ChatRoom#findDanglingModeration * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMUCMessage} + * message, as returned by {@link parseMUCMessage} * @returns { _converse.ChatRoomMessage } */ findDanglingModeration (attrs) { @@ -1723,7 +1724,7 @@ const ChatRoomMixin = { * @private * @method _converse.ChatRoom#handleModeration * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMUCMessage} + * message, as returned by {@link parseMUCMessage} * @returns { Boolean } Returns `true` or `false` depending on * whether a message was moderated or not. */ diff --git a/src/headless/plugins/muc/parsers.js b/src/headless/plugins/muc/parsers.js new file mode 100644 index 000000000..9b841d886 --- /dev/null +++ b/src/headless/plugins/muc/parsers.js @@ -0,0 +1,307 @@ +import dayjs from 'dayjs'; +import { + StanzaParseError, + getChatMarker, + getChatState, + getCorrectionAttributes, + getEncryptionAttributes, + getErrorAttributes, + getOutOfBandAttributes, + getReceiptId, + getReferences, + getRetractionAttributes, + getSpoilerAttributes, + getStanzaIDs, + isArchived, + isCarbon, + isHeadline, + isValidReceiptRequest, + rejectUnencapsulatedForward, +} from '@converse/headless/shared/parsers'; +import { api, converse } from '@converse/headless/core'; + +const { Strophe, sizzle, u } = converse.env; +const { NS } = Strophe; + +/** + * @private + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @returns { Object } + */ +function getModerationAttributes (stanza) { + const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); + if (fastening) { + const applies_to_id = fastening.getAttribute('id'); + const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop(); + if (moderated) { + const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop(); + if (retracted) { + return { + 'editable': false, + 'moderated': 'retracted', + 'moderated_by': moderated.getAttribute('by'), + 'moderated_id': applies_to_id, + 'moderation_reason': moderated.querySelector('reason')?.textContent + }; + } + } + } else { + const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop(); + if (tombstone) { + const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop(); + if (retracted) { + return { + 'editable': false, + 'is_tombstone': true, + 'moderated_by': tombstone.getAttribute('by'), + 'retracted': tombstone.getAttribute('stamp'), + 'moderation_reason': tombstone.querySelector('reason')?.textContent + }; + } + } + } + return {}; +} + +/** + * Parses a passed in message stanza and returns an object of attributes. + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @param { _converse.ChatRoom } chatbox + * @param { _converse } _converse + * @returns { Promise } + */ +export async function parseMUCMessage (stanza, chatbox, _converse) { + const err = rejectUnencapsulatedForward(stanza); + if (err) { + return err; + } + + const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`; + const original_stanza = stanza; + stanza = sizzle(selector, stanza).pop() || stanza; + + if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) { + return new StanzaParseError( + `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`, + stanza + ); + } + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const from = stanza.getAttribute('from'); + const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from)); + const marker = getChatMarker(stanza); + const now = new Date().toISOString(); + /** + * @typedef { Object } MUCMessageAttributes + * The object which {@link parseMUCMessage} returns + * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else + * @property { Array } references - A list of objects representing XEP-0372 references + * @property { Boolean } editable - Is this message editable via XEP-0308? + * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive? + * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon? + * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203? + * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted? + * @property { Boolean } is_error - Whether an error was received for this message + * @property { Boolean } is_headline - Is this a "headline" message? + * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? + * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? + * @property { Boolean } is_only_emojis - Does the message body contain only emojis? + * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? + * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored + * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) + * @property { Object } encrypted - XEP-0384 encryption payload attributes + * @property { String } body - The contents of the tag of the message stanza + * @property { String } chat_state - The XEP-0085 chat state notification contained in this message + * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308 + * @property { String } error_condition - The defined error condition + * @property { String } error_text - The error text received from the server + * @property { String } error_type - The type of error received from the server + * @property { String } from - The sender JID (${muc_jid}/${nick}) + * @property { String } from_muc - The JID of the MUC from which this message was sent + * @property { String } from_real_jid - The real JID of the sender, if available + * @property { String } fullname - The full name of the sender + * @property { String } marker - The XEP-0333 Chat Marker value + * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker + * @property { String } moderated - The type of XEP-0425 moderation (if any) that was applied + * @property { String } moderated_by - The JID of the user that moderated this message + * @property { String } moderated_id - The XEP-0359 Stanza ID of the message that this one moderates + * @property { String } moderation_reason - The reason provided why this message moderates another + * @property { String } msgid - The root `id` attribute of the stanza + * @property { String } nick - The MUC nickname of the sender + * @property { String } oob_desc - The description of the XEP-0066 out of band data + * @property { String } oob_url - The URL of the XEP-0066 out of band data + * @property { String } origin_id - The XEP-0359 Origin ID + * @property { String } receipt_id - The `id` attribute of a XEP-0184 element + * @property { String } received - An ISO8601 string recording the time that the message was received + * @property { String } replace_id - The `id` attribute of a XEP-0308 element + * @property { String } retracted - An ISO8601 string recording the time that the message was retracted + * @property { String } retracted_id - The `id` attribute of a XEP-424 element + * @property { String } spoiler_hint The XEP-0382 spoiler hint + * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple. + * @property { String } subject - The element value + * @property { String } thread - The element value + * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 element, or of receipt. + * @property { String } to - The recipient JID + * @property { String } type - The type of message + */ + let attrs = Object.assign( + { + from, + nick, + 'body': stanza.querySelector('body')?.textContent?.trim(), + 'chat_state': getChatState(stanza), + 'from_muc': Strophe.getBareJidFromJid(from), + 'from_real_jid': chatbox.occupants.findOccupant({ nick })?.get('jid'), + 'is_archived': isArchived(original_stanza), + 'is_carbon': isCarbon(original_stanza), + 'is_delayed': !!delay, + 'is_headline': isHeadline(stanza), + 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, + 'is_marker': !!marker, + 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, + 'marker_id': marker && marker.getAttribute('id'), + 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), + 'receipt_id': getReceiptId(stanza), + 'received': new Date().toISOString(), + 'references': getReferences(stanza), + 'subject': stanza.querySelector('subject')?.textContent, + 'thread': stanza.querySelector('thread')?.textContent, + 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now, + 'to': stanza.getAttribute('to'), + 'type': stanza.getAttribute('type') + }, + getErrorAttributes(stanza), + getOutOfBandAttributes(stanza), + getSpoilerAttributes(stanza), + getCorrectionAttributes(stanza, original_stanza), + getStanzaIDs(stanza, original_stanza), + getRetractionAttributes(stanza, original_stanza), + getModerationAttributes(stanza), + getEncryptionAttributes(stanza, _converse) + ); + + await api.emojis.initialize(); + attrs = Object.assign( + { + 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, + 'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs), + 'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead + 'sender': attrs.nick === chatbox.get('nick') ? 'me' : 'them' + }, + attrs + ); + + if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) { + return new StanzaParseError( + `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`, + stanza + ); + } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) { + return new StanzaParseError( + `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`, + stanza + ); + } else if (attrs.is_carbon) { + return new StanzaParseError('Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied', stanza); + } + // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates. + attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from_muc || attrs.from}`] || u.getUniqueId(); + /** + * *Hook* which allows plugins to add additional parsing + * @event _converse#parseMUCMessage + */ + return api.hook('parseMUCMessage', stanza, attrs); +} + +/** + * Given an IQ stanza with a member list, create an array of objects containing + * known member data (e.g. jid, nick, role, affiliation). + * @private + * @method muc_utils#parseMemberListIQ + * @returns { MemberListItem[] } + */ +export function parseMemberListIQ (iq) { + return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(item => { + /** + * @typedef {Object} MemberListItem + * Either the JID or the nickname (or both) will be available. + * @property {string} affiliation + * @property {string} [role] + * @property {string} [jid] + * @property {string} [nick] + */ + const data = { + 'affiliation': item.getAttribute('affiliation') + }; + const jid = item.getAttribute('jid'); + if (u.isValidJID(jid)) { + data['jid'] = jid; + } else { + // XXX: Prosody sends nick for the jid attribute value + // Perhaps for anonymous room? + data['nick'] = jid; + } + const nick = item.getAttribute('nick'); + if (nick) { + data['nick'] = nick; + } + const role = item.getAttribute('role'); + if (role) { + data['role'] = nick; + } + return data; + }); +} + +/** + * Parses a passed in MUC presence stanza and returns an object of attributes. + * @method parseMUCPresence + * @param { XMLElement } stanza - The presence stanza + * @returns { Object } + */ +export function parseMUCPresence (stanza) { + const from = stanza.getAttribute('from'); + const type = stanza.getAttribute('type'); + const data = { + 'from': from, + 'nick': Strophe.getResourceFromJid(from), + 'type': type, + 'states': [], + 'hats': [], + 'show': type !== 'unavailable' ? 'online' : 'offline' + }; + Array.from(stanza.children).forEach(child => { + if (child.matches('status')) { + data.status = child.textContent || null; + } else if (child.matches('show')) { + data.show = child.textContent || 'online'; + } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.MUC_USER) { + Array.from(child.children).forEach(item => { + if (item.nodeName === 'item') { + data.affiliation = item.getAttribute('affiliation'); + data.role = item.getAttribute('role'); + data.jid = item.getAttribute('jid'); + data.nick = item.getAttribute('nick') || data.nick; + } else if (item.nodeName == 'status' && item.getAttribute('code')) { + data.states.push(item.getAttribute('code')); + } + }); + } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) { + data.image_hash = child.querySelector('photo')?.textContent; + } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) { + data['hats'] = Array.from(child.children).map( + c => + c.matches('hat') && { + 'title': c.getAttribute('title'), + 'uri': c.getAttribute('uri') + } + ); + } + }); + return data; +} diff --git a/src/headless/utils/muc.js b/src/headless/plugins/muc/utils.js similarity index 60% rename from src/headless/utils/muc.js rename to src/headless/plugins/muc/utils.js index 4d5a04a3d..d42649077 100644 --- a/src/headless/utils/muc.js +++ b/src/headless/plugins/muc/utils.js @@ -4,10 +4,6 @@ * @description This is the MUC utilities module. */ import { difference, indexOf } from "lodash-es"; -import { converse } from "@converse/headless/core"; -import u from "./core"; - -const { Strophe, sizzle } = converse.env; /** * The MUC utils object. Contains utility functions related to multi-user chat. @@ -58,49 +54,7 @@ const muc_utils = { delta = delta.concat(difference(old_jids, new_jids).map(jid => ({'jid': jid, 'affiliation': 'none'}))); } return delta; - }, - - /** - * Given an IQ stanza with a member list, create an array of objects containing - * known member data (e.g. jid, nick, role, affiliation). - * @private - * @method muc_utils#parseMemberListIQ - * @returns { MemberListItem[] } - */ - parseMemberListIQ (iq) { - return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map( - (item) => { - /** - * @typedef {Object} MemberListItem - * Either the JID or the nickname (or both) will be available. - * @property {string} affiliation - * @property {string} [role] - * @property {string} [jid] - * @property {string} [nick] - */ - const data = { - 'affiliation': item.getAttribute('affiliation'), - } - const jid = item.getAttribute('jid'); - if (u.isValidJID(jid)) { - data['jid'] = jid; - } else { - // XXX: Prosody sends nick for the jid attribute value - // Perhaps for anonymous room? - data['nick'] = jid; - } - const nick = item.getAttribute('nick'); - if (nick) { - data['nick'] = nick; - } - const role = item.getAttribute('role'); - if (role) { - data['role'] = nick; - } - return data; - } - ); - }, + } } export default muc_utils; diff --git a/src/headless/shared/actions.js b/src/headless/shared/actions.js new file mode 100644 index 000000000..f10e2cbe4 --- /dev/null +++ b/src/headless/shared/actions.js @@ -0,0 +1,41 @@ +import log from '../log'; +import { Strophe, $msg } from 'strophe.js/src/strophe'; +import { _converse, api, converse } from '@converse/headless/core'; + +const u = converse.env.utils; + +export function rejectMessage (stanza, text) { + // Reject an incoming message by replying with an error message of type "cancel". + api.send( + $msg({ + 'to': stanza.getAttribute('from'), + 'type': 'error', + 'id': stanza.getAttribute('id') + }) + .c('error', { 'type': 'cancel' }) + .c('not-allowed', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }) + .up() + .c('text', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }) + .t(text) + ); + log.warn(`Rejecting message stanza with the following reason: ${text}`); + log.warn(stanza); +} + + +/** + * Send out a XEP-0333 chat marker + * @param { String } to_jid + * @param { String } id - The id of the message being marked + * @param { String } type - The marker type + * @param { String } msg_type + */ +export function sendMarker (to_jid, id, type, msg_type) { + const stanza = $msg({ + 'from': _converse.connection.jid, + 'id': u.getUniqueId(), + 'to': to_jid, + 'type': msg_type ? msg_type : 'chat' + }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id}); + api.send(stanza); +} diff --git a/src/headless/shared/parsers.js b/src/headless/shared/parsers.js new file mode 100644 index 000000000..3235cb835 --- /dev/null +++ b/src/headless/shared/parsers.js @@ -0,0 +1,287 @@ +import dayjs from 'dayjs'; +import sizzle from 'sizzle'; +import { Strophe } from 'strophe.js/src/strophe'; +import { _converse, api } from '@converse/headless/core'; +import { rejectMessage } from '@converse/headless/shared/actions'; + +const { NS } = Strophe; + +export class StanzaParseError extends Error { + constructor (message, stanza) { + super(message, stanza); + this.name = 'StanzaParseError'; + this.stanza = stanza; + } +} + +/** + * Extract the XEP-0359 stanza IDs from the passed in stanza + * and return a map containing them. + * @private + * @param { XMLElement } stanza - The message stanza + * @returns { Object } + */ +export function getStanzaIDs (stanza, original_stanza) { + const attrs = {}; + // Store generic stanza ids + const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza); + const sid_attrs = sids.reduce((acc, s) => { + acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id'); + return acc; + }, {}); + Object.assign(attrs, sid_attrs); + + // Store the archive id + const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); + if (result) { + const by_jid = original_stanza.getAttribute('from') || _converse.bare_jid; + attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); + } + + // Store the origin id + const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); + if (origin_id) { + attrs['origin_id'] = origin_id.getAttribute('id'); + } + return attrs; +} + +export function getEncryptionAttributes (stanza, _converse) { + const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop(); + const attrs = { 'is_encrypted': !!encrypted }; + if (!encrypted || api.settings.get('clear_cache_on_logout')) { + return attrs; + } + const header = encrypted.querySelector('header'); + attrs['encrypted'] = { 'device_id': header.getAttribute('sid') }; + + const device_id = _converse.omemo_store?.get('device_id'); + const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop(); + if (key) { + Object.assign(attrs.encrypted, { + 'iv': header.querySelector('iv').textContent, + 'key': key.textContent, + 'payload': encrypted.querySelector('payload')?.textContent || null, + 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) + }); + } + return attrs; +} + +/** + * @private + * @param { XMLElement } stanza - The message stanza + * @param { XMLElement } original_stanza - The original stanza, that contains the + * message stanza, if it was contained, otherwise it's the message stanza itself. + * @returns { Object } + */ +export function getRetractionAttributes (stanza, original_stanza) { + const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); + if (fastening) { + const applies_to_id = fastening.getAttribute('id'); + const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop(); + if (retracted) { + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(); + return { + 'editable': false, + 'retracted': time, + 'retracted_id': applies_to_id + }; + } + } else { + const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); + if (tombstone) { + return { + 'editable': false, + 'is_tombstone': true, + 'retracted': tombstone.getAttribute('stamp') + }; + } + } + return {}; +} + +export function getCorrectionAttributes (stanza, original_stanza) { + const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop(); + if (el) { + const replace_id = el.getAttribute('id'); + const msgid = replace_id; + if (replace_id) { + const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); + const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(); + return { + msgid, + replace_id, + 'edited': time + }; + } + } + return {}; +} + +export function getSpoilerAttributes (stanza) { + const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop(); + return { + 'is_spoiler': !!spoiler, + 'spoiler_hint': spoiler?.textContent + }; +} + +export function getOutOfBandAttributes (stanza) { + const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop(); + if (xform) { + return { + 'oob_url': xform.querySelector('url')?.textContent, + 'oob_desc': xform.querySelector('desc')?.textContent + }; + } + return {}; +} + +/** + * Returns the human readable error message contained in a `groupchat` message stanza of type `error`. + * @private + * @param { XMLElement } stanza - The message stanza + */ +export function getErrorAttributes (stanza) { + if (stanza.getAttribute('type') === 'error') { + const error = stanza.querySelector('error'); + const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop(); + return { + 'is_error': true, + 'error_text': text?.textContent, + 'error_type': error.getAttribute('type'), + 'error_condition': error.firstElementChild.nodeName + }; + } + return {}; +} + +export function getReferences (stanza) { + const text = stanza.querySelector('body')?.textContent; + return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => { + const begin = ref.getAttribute('begin'); + const end = ref.getAttribute('end'); + return { + 'begin': begin, + 'end': end, + 'type': ref.getAttribute('type'), + 'value': text.slice(begin, end), + 'uri': ref.getAttribute('uri') + }; + }); +} + +export function getReceiptId (stanza) { + const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); + return receipt?.getAttribute('id'); +} + +/** + * Determines whether the passed in stanza is a XEP-0280 Carbon + * @private + * @param { XMLElement } stanza - The message stanza + * @returns { Boolean } + */ +export function isCarbon (stanza) { + const xmlns = Strophe.NS.CARBONS; + return ( + sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 || + sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0 + ); +} + +/** + * Returns the XEP-0085 chat state contained in a message stanza + * @private + * @param { XMLElement } stanza - The message stanza + */ +export function getChatState (stanza) { + return sizzle( + ` + composing[xmlns="${NS.CHATSTATES}"], + paused[xmlns="${NS.CHATSTATES}"], + inactive[xmlns="${NS.CHATSTATES}"], + active[xmlns="${NS.CHATSTATES}"], + gone[xmlns="${NS.CHATSTATES}"]`, + stanza + ).pop()?.nodeName; +} + +export function isValidReceiptRequest (stanza, attrs) { + return ( + attrs.sender !== 'me' && + !attrs.is_carbon && + !attrs.is_archived && + sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length + ); +} + +export function rejectUnencapsulatedForward (stanza) { + const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length; + if (bare_forward) { + rejectMessage(stanza, 'Forwarded messages not part of an encapsulating protocol are not supported'); + const from_jid = stanza.getAttribute('from'); + return new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza); + } +} + +/** + * Determines whether the passed in stanza is a XEP-0333 Chat Marker + * @private + * @method getChatMarker + * @param { XMLElement } stanza - The message stanza + * @returns { Boolean } + */ +export function getChatMarker (stanza) { + // If we receive more than one marker (which shouldn't happen), we take + // the highest level of acknowledgement. + return sizzle(` + acknowledged[xmlns="${Strophe.NS.MARKERS}"], + displayed[xmlns="${Strophe.NS.MARKERS}"], + received[xmlns="${Strophe.NS.MARKERS}"]`, + stanza + ).pop(); +} + +export function isHeadline (stanza) { + return stanza.getAttribute('type') === 'headline'; +} + +export function isServerMessage (stanza) { + const from_jid = stanza.getAttribute('from'); + if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) { + // Some servers (e.g. Prosody) don't set the stanza + // type to "headline" when sending server messages. + // For now we check if an @ signal is included, and if not, + // we assume it's a headline stanza. + return true; + } + return false; +} + +/** + * Determines whether the passed in stanza is a XEP-0313 MAM stanza + * @private + * @method isArchived + * @param { XMLElement } stanza - The message stanza + * @returns { Boolean } + */ +export function isArchived (original_stanza) { + return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); +} + + +/** + * Returns an object containing all attribute names and values for a particular element. + * @method getAttributes + * @param { XMLElement } stanza + * @returns { Object } + */ +export function getAttributes (stanza) { + return stanza.getAttributeNames().reduce((acc, name) => { + acc[name] = Strophe.xmlunescape(stanza.getAttribute(name)); + return acc; + }, {}); +} diff --git a/src/headless/utils/stanza.js b/src/headless/utils/stanza.js deleted file mode 100644 index 674260841..000000000 --- a/src/headless/utils/stanza.js +++ /dev/null @@ -1,738 +0,0 @@ -import { Strophe, $msg } from 'strophe.js/src/strophe'; -import dayjs from 'dayjs'; -import sizzle from 'sizzle'; -import u from '@converse/headless/utils/core'; -import log from "../log"; -import { _converse, api } from "@converse/headless/core"; - -const { NS } = Strophe; - - -function getSpoilerAttributes (stanza) { - const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop(); - return { - 'is_spoiler': !!spoiler, - 'spoiler_hint': spoiler?.textContent - } -} - -function getOutOfBandAttributes (stanza) { - const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop(); - if (xform) { - return { - 'oob_url': xform.querySelector('url')?.textContent, - 'oob_desc': xform.querySelector('desc')?.textContent - } - } - return {}; -} - -function getCorrectionAttributes (stanza, original_stanza) { - const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop(); - if (el) { - const replace_id = el.getAttribute('id'); - const msgid = replace_id; - if (replace_id) { - const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); - return { - msgid, - replace_id, - 'edited': time - } - } - } - return {}; -} - - -function getEncryptionAttributes (stanza, _converse) { - const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop(); - const attrs = { 'is_encrypted': !!encrypted }; - if (!encrypted || api.settings.get('clear_cache_on_logout')) { - return attrs; - } - const header = encrypted.querySelector('header'); - attrs['encrypted'] = {'device_id': header.getAttribute('sid')}; - - const device_id = _converse.omemo_store?.get('device_id'); - const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop(); - if (key) { - Object.assign(attrs.encrypted, { - 'iv': header.querySelector('iv').textContent, - 'key': key.textContent, - 'payload': encrypted.querySelector('payload')?.textContent || null, - 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) - }); - } - return attrs; -} - - -function isValidReceiptRequest (stanza, attrs) { - return ( - attrs.sender !== 'me' && - !attrs.is_carbon && - !attrs.is_archived && - sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length - ); -} - - -function getReceiptId (stanza) { - const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); - return receipt?.getAttribute('id'); -} - -/** - * Returns the XEP-0085 chat state contained in a message stanza - * @private - * @param { XMLElement } stanza - The message stanza - */ -function getChatState (stanza) { - return sizzle(` - composing[xmlns="${NS.CHATSTATES}"], - paused[xmlns="${NS.CHATSTATES}"], - inactive[xmlns="${NS.CHATSTATES}"], - active[xmlns="${NS.CHATSTATES}"], - gone[xmlns="${NS.CHATSTATES}"]`, stanza).pop()?.nodeName; -} - -/** - * Determines whether the passed in stanza is a XEP-0280 Carbon - * @private - * @param { XMLElement } stanza - The message stanza - * @returns { Boolean } - */ -function isCarbon (stanza) { - const xmlns = Strophe.NS.CARBONS; - return sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 || - sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0; -} - -/** - * Extract the XEP-0359 stanza IDs from the passed in stanza - * and return a map containing them. - * @private - * @param { XMLElement } stanza - The message stanza - * @returns { Object } - */ -function getStanzaIDs (stanza, original_stanza) { - const attrs = {}; - // Store generic stanza ids - const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza); - const sid_attrs = sids.reduce((acc, s) => { - acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id'); - return acc; - }, {}); - Object.assign(attrs, sid_attrs); - - // Store the archive id - const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); - if (result) { - const by_jid = original_stanza.getAttribute('from') || _converse.bare_jid; - attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); - } - - // Store the origin id - const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); - if (origin_id) { - attrs['origin_id'] = origin_id.getAttribute('id'); - } - return attrs; -} - -/** - * @private - * @param { XMLElement } stanza - The message stanza - * @param { XMLElement } original_stanza - The original stanza, that contains the - * message stanza, if it was contained, otherwise it's the message stanza itself. - * @returns { Object } - */ -function getModerationAttributes (stanza) { - const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); - if (fastening) { - const applies_to_id = fastening.getAttribute('id'); - const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop(); - if (moderated) { - const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop(); - if (retracted) { - return { - 'editable': false, - 'moderated': 'retracted', - 'moderated_by': moderated.getAttribute('by'), - 'moderated_id': applies_to_id, - 'moderation_reason': moderated.querySelector('reason')?.textContent - } - } - } - } else { - const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop(); - if (tombstone) { - const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop(); - if (retracted) { - return { - 'editable': false, - 'is_tombstone': true, - 'moderated_by': tombstone.getAttribute('by'), - 'retracted': tombstone.getAttribute('stamp'), - 'moderation_reason': tombstone.querySelector('reason')?.textContent - - } - } - } - } - return {}; -} - - -/** - * @private - * @param { XMLElement } stanza - The message stanza - * @param { XMLElement } original_stanza - The original stanza, that contains the - * message stanza, if it was contained, otherwise it's the message stanza itself. - * @returns { Object } - */ -function getRetractionAttributes (stanza, original_stanza) { - const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop(); - if (fastening) { - const applies_to_id = fastening.getAttribute('id'); - const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop(); - if (retracted) { - const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(); - return { - 'editable': false, - 'retracted': time, - 'retracted_id': applies_to_id - } - } - } else { - const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop(); - if (tombstone) { - return { - 'editable': false, - 'is_tombstone': true, - 'retracted': tombstone.getAttribute('stamp') - } - } - } - return {}; -} - -function getReferences (stanza) { - const text = stanza.querySelector('body')?.textContent; - return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => { - const begin = ref.getAttribute('begin'); - const end = ref.getAttribute('end'); - return { - 'begin': begin, - 'end': end, - 'type': ref.getAttribute('type'), - 'value': text.slice(begin, end), - 'uri': ref.getAttribute('uri') - }; - }); -} - -function rejectMessage (stanza, text) { - // Reject an incoming message by replying with an error message of type "cancel". - api.send( - $msg({ - 'to': stanza.getAttribute('from'), - 'type': 'error', - 'id': stanza.getAttribute('id') - }).c('error', {'type': 'cancel'}) - .c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up() - .c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text) - ); - log.warn(`Rejecting message stanza with the following reason: ${text}`); - log.warn(stanza); -} - - -/** - * Returns the human readable error message contained in a `groupchat` message stanza of type `error`. - * @private - * @param { XMLElement } stanza - The message stanza - */ -function getErrorAttributes (stanza) { - if (stanza.getAttribute('type') === 'error') { - const error = stanza.querySelector('error'); - const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop(); - return { - 'is_error': true, - 'error_text': text?.textContent, - 'error_type': error.getAttribute('type'), - 'error_condition': error.firstElementChild.nodeName - } - } - return {}; -} - - -class StanzaParseError extends Error { - constructor (message, stanza) { - super(message, stanza); - this.name = 'StanzaParseError'; - this.stanza = stanza; - } -} - - -function rejectUnencapsulatedForward (stanza) { - const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length; - if (bare_forward) { - rejectMessage( - stanza, - 'Forwarded messages not part of an encapsulating protocol are not supported' - ); - const from_jid = stanza.getAttribute('from'); - return new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza); - } -} - - -/** - * The stanza utils object. Contains utility functions related to stanza processing. - * @namespace st - */ -const st = { - - isHeadline (stanza) { - return stanza.getAttribute('type') === 'headline'; - }, - - isServerMessage (stanza) { - const from_jid = stanza.getAttribute('from'); - if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) { - // Some servers (e.g. Prosody) don't set the stanza - // type to "headline" when sending server messages. - // For now we check if an @ signal is included, and if not, - // we assume it's a headline stanza. - return true; - } - return false; - }, - - /** - * Determines whether the passed in stanza is a XEP-0333 Chat Marker - * @private - * @method st#getChatMarker - * @param { XMLElement } stanza - The message stanza - * @returns { Boolean } - */ - getChatMarker (stanza) { - // If we receive more than one marker (which shouldn't happen), we take - // the highest level of acknowledgement. - return sizzle(` - acknowledged[xmlns="${Strophe.NS.MARKERS}"], - displayed[xmlns="${Strophe.NS.MARKERS}"], - received[xmlns="${Strophe.NS.MARKERS}"]`, stanza).pop(); - }, - - /** - * Determines whether the passed in stanza is a XEP-0313 MAM stanza - * @private - * @method st#isArchived - * @param { XMLElement } stanza - The message stanza - * @returns { Boolean } - */ - isArchived (original_stanza) { - return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); - }, - - /** - * Returns an object containing all attribute names and values for a particular element. - * @method st#getAttributes - * @param { XMLElement } stanza - * @returns { Object } - */ - getAttributes (stanza) { - return stanza.getAttributeNames().reduce((acc, name) => { - acc[name] = Strophe.xmlunescape(stanza.getAttribute(name)) - return acc; - }, {}); - }, - - /** - * Parses a passed in message stanza and returns an object of attributes. - * @method st#parseMessage - * @param { XMLElement } stanza - The message stanza - * @param { _converse } _converse - * @returns { (MessageAttributes|Error) } - */ - async parseMessage (stanza, _converse) { - const err = rejectUnencapsulatedForward(stanza); - if (err) { - return err; - } - - let to_jid = stanza.getAttribute('to'); - const to_resource = Strophe.getResourceFromJid(to_jid); - if (api.settings.get('filter_by_resource') && (to_resource && to_resource !== _converse.resource)) { - return new StanzaParseError(`Ignoring incoming message intended for a different resource: ${to_jid}`, stanza); - } - - const original_stanza = stanza; - let from_jid = stanza.getAttribute('from') || _converse.bare_jid; - if (isCarbon(stanza)) { - if (from_jid === _converse.bare_jid) { - const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; - stanza = sizzle(selector, stanza).pop(); - to_jid = stanza.getAttribute('to'); - from_jid = stanza.getAttribute('from'); - } else { - // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security - rejectMessage(stanza, 'Rejecting carbon from invalid JID'); - return new StanzaParseError(`Rejecting carbon from invalid JID ${to_jid}`, stanza); - } - } - - const is_archived = st.isArchived(stanza); - if (is_archived) { - if (from_jid === _converse.bare_jid) { - const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; - stanza = sizzle(selector, stanza).pop(); - to_jid = stanza.getAttribute('to'); - from_jid = stanza.getAttribute('from'); - } else { - return new StanzaParseError(`Invalid Stanza: alleged MAM message from ${stanza.getAttribute('from')}`, stanza); - } - } - - const from_bare_jid = Strophe.getBareJidFromJid(from_jid); - const is_me = from_bare_jid === _converse.bare_jid; - if (is_me && to_jid === null) { - return new StanzaParseError( - `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`, - stanza - ); - } - - const is_headline = st.isHeadline(stanza); - const is_server_message = st.isServerMessage(stanza); - let contact, contact_jid; - if (!is_headline && !is_server_message) { - contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid; - contact = await api.contacts.get(contact_jid); - if (contact === undefined && !api.settings.get("allow_non_roster_messaging")) { - log.error(stanza); - return new StanzaParseError( - `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`, - stanza - ); - } - } - /** - * @typedef { Object } MessageAttributes - * The object which {@link st.parseMessage} returns - * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else - * @property { Array } references - A list of objects representing XEP-0372 references - * @property { Boolean } editable - Is this message editable via XEP-0308? - * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive? - * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon? - * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203? - * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted? - * @property { Boolean } is_error - Whether an error was received for this message - * @property { Boolean } is_headline - Is this a "headline" message? - * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? - * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? - * @property { Boolean } is_only_emojis - Does the message body contain only emojis? - * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? - * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? - * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored - * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) - * @property { Object } encrypted - XEP-0384 encryption payload attributes - * @property { String } body - The contents of the tag of the message stanza - * @property { String } chat_state - The XEP-0085 chat state notification contained in this message - * @property { String } contact_jid - The JID of the other person or entity - * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308 - * @property { String } error_condition - The defined error condition - * @property { String } error_text - The error text received from the server - * @property { String } error_type - The type of error received from the server - * @property { String } from - The sender JID - * @property { String } fullname - The full name of the sender - * @property { String } marker - The XEP-0333 Chat Marker value - * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker - * @property { String } msgid - The root `id` attribute of the stanza - * @property { String } nick - The roster nickname of the sender - * @property { String } oob_desc - The description of the XEP-0066 out of band data - * @property { String } oob_url - The URL of the XEP-0066 out of band data - * @property { String } origin_id - The XEP-0359 Origin ID - * @property { String } receipt_id - The `id` attribute of a XEP-0184 element - * @property { String } received - An ISO8601 string recording the time that the message was received - * @property { String } replace_id - The `id` attribute of a XEP-0308 element - * @property { String } retracted - An ISO8601 string recording the time that the message was retracted - * @property { String } retracted_id - The `id` attribute of a XEP-424 element - * @property { String } spoiler_hint The XEP-0382 spoiler hint - * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple. - * @property { String } subject - The element value - * @property { String } thread - The element value - * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 element, or of receipt. - * @property { String } to - The recipient JID - * @property { String } type - The type of message - */ - const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - const marker = st.getChatMarker(stanza); - const now = (new Date()).toISOString(); - let attrs = Object.assign({ - contact_jid, - is_archived, - is_headline, - is_server_message, - 'body': stanza.querySelector('body')?.textContent?.trim(), - 'chat_state': getChatState(stanza), - 'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')), - 'is_carbon': isCarbon(original_stanza), - 'is_delayed': !!delay, - 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, - 'is_marker': !!marker, - 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, - 'marker_id': marker && marker.getAttribute('id'), - 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), - 'nick': contact?.attributes?.nickname, - 'receipt_id': getReceiptId(stanza), - 'received': (new Date()).toISOString(), - 'references': getReferences(stanza), - 'sender': is_me ? 'me' : 'them', - 'subject': stanza.querySelector('subject')?.textContent, - 'thread': stanza.querySelector('thread')?.textContent, - 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now, - 'to': stanza.getAttribute('to'), - 'type': stanza.getAttribute('type') - }, - getErrorAttributes(stanza), - getOutOfBandAttributes(stanza), - getSpoilerAttributes(stanza), - getCorrectionAttributes(stanza, original_stanza), - getStanzaIDs(stanza, original_stanza), - getRetractionAttributes(stanza, original_stanza), - getEncryptionAttributes(stanza, _converse) - ); - - if (attrs.is_archived) { - const from = original_stanza.getAttribute('from'); - if (from && from !== _converse.bare_jid) { - return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza); - } - } - await api.emojis.initialize(); - attrs = Object.assign({ - 'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead - 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, - 'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs) - }, attrs); - - // We prefer to use one of the XEP-0359 unique and stable stanza IDs - // as the Model id, to avoid duplicates. - attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId(); - - /** - * *Hook* which allows plugins to add additional parsing - * @event _converse#parseMessage - */ - return api.hook('parseMessage', stanza, attrs); - }, - - /** - * Parses a passed in message stanza and returns an object of attributes. - * @method st#parseMUCMessage - * @param { XMLElement } stanza - The message stanza - * @param { XMLElement } original_stanza - The original stanza, that contains the - * message stanza, if it was contained, otherwise it's the message stanza itself. - * @param { _converse.ChatRoom } chatbox - * @param { _converse } _converse - * @returns { Promise } - */ - async parseMUCMessage (stanza, chatbox, _converse) { - const err = rejectUnencapsulatedForward(stanza); - if (err) { - return err; - } - - const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`; - const original_stanza = stanza; - stanza = sizzle(selector, stanza).pop() || stanza; - - if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) { - return new StanzaParseError( - `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`, - stanza - ); - } - const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop(); - const from = stanza.getAttribute('from'); - const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from)); - const marker = st.getChatMarker(stanza); - const now = (new Date()).toISOString(); - /** - * @typedef { Object } MUCMessageAttributes - * The object which {@link st.parseMUCMessage} returns - * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else - * @property { Array } references - A list of objects representing XEP-0372 references - * @property { Boolean } editable - Is this message editable via XEP-0308? - * @property { Boolean } is_archived - Is this message from a XEP-0313 MAM archive? - * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon? - * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203? - * @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted? - * @property { Boolean } is_error - Whether an error was received for this message - * @property { Boolean } is_headline - Is this a "headline" message? - * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker? - * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker? - * @property { Boolean } is_only_emojis - Does the message body contain only emojis? - * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? - * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? - * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored - * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message) - * @property { Object } encrypted - XEP-0384 encryption payload attributes - * @property { String } body - The contents of the tag of the message stanza - * @property { String } chat_state - The XEP-0085 chat state notification contained in this message - * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308 - * @property { String } error_condition - The defined error condition - * @property { String } error_text - The error text received from the server - * @property { String } error_type - The type of error received from the server - * @property { String } from - The sender JID (${muc_jid}/${nick}) - * @property { String } from_muc - The JID of the MUC from which this message was sent - * @property { String } from_real_jid - The real JID of the sender, if available - * @property { String } fullname - The full name of the sender - * @property { String } marker - The XEP-0333 Chat Marker value - * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker - * @property { String } moderated - The type of XEP-0425 moderation (if any) that was applied - * @property { String } moderated_by - The JID of the user that moderated this message - * @property { String } moderated_id - The XEP-0359 Stanza ID of the message that this one moderates - * @property { String } moderation_reason - The reason provided why this message moderates another - * @property { String } msgid - The root `id` attribute of the stanza - * @property { String } nick - The MUC nickname of the sender - * @property { String } oob_desc - The description of the XEP-0066 out of band data - * @property { String } oob_url - The URL of the XEP-0066 out of band data - * @property { String } origin_id - The XEP-0359 Origin ID - * @property { String } receipt_id - The `id` attribute of a XEP-0184 element - * @property { String } received - An ISO8601 string recording the time that the message was received - * @property { String } replace_id - The `id` attribute of a XEP-0308 element - * @property { String } retracted - An ISO8601 string recording the time that the message was retracted - * @property { String } retracted_id - The `id` attribute of a XEP-424 element - * @property { String } spoiler_hint The XEP-0382 spoiler hint - * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple. - * @property { String } subject - The element value - * @property { String } thread - The element value - * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 element, or of receipt. - * @property { String } to - The recipient JID - * @property { String } type - The type of message - */ - let attrs = Object.assign({ - from, - nick, - 'body': stanza.querySelector('body')?.textContent?.trim(), - 'chat_state': getChatState(stanza), - 'from_muc': Strophe.getBareJidFromJid(from), - 'from_real_jid': chatbox.occupants.findOccupant({nick})?.get('jid'), - 'is_archived': st.isArchived(original_stanza), - 'is_carbon': isCarbon(original_stanza), - 'is_delayed': !!delay, - 'is_headline': st.isHeadline(stanza), - 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, - 'is_marker': !!marker, - 'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length, - 'marker_id': marker && marker.getAttribute('id'), - 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), - 'receipt_id': getReceiptId(stanza), - 'received': (new Date()).toISOString(), - 'references': getReferences(stanza), - 'subject': stanza.querySelector('subject')?.textContent, - 'thread': stanza.querySelector('thread')?.textContent, - 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now, - 'to': stanza.getAttribute('to'), - 'type': stanza.getAttribute('type'), - }, - getErrorAttributes(stanza), - getOutOfBandAttributes(stanza), - getSpoilerAttributes(stanza), - getCorrectionAttributes(stanza, original_stanza), - getStanzaIDs(stanza, original_stanza), - getRetractionAttributes(stanza, original_stanza), - getModerationAttributes(stanza), - getEncryptionAttributes(stanza, _converse) - ); - - - await api.emojis.initialize(); - attrs = Object.assign({ - 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, - 'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs), - 'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead - 'sender': attrs.nick === chatbox.get('nick') ? 'me': 'them', - }, attrs); - - if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) { - return new StanzaParseError( - `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`, - stanza - ); - } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) { - return new StanzaParseError( - `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`, - stanza - ); - } else if (attrs.is_carbon) { - return new StanzaParseError( - "Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied", - stanza - ); - } - // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates. - attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId(); - /** - * *Hook* which allows plugins to add additional parsing - * @event _converse#parseMUCMessage - */ - return api.hook('parseMUCMessage', stanza, attrs); - }, - - /** - * Parses a passed in MUC presence stanza and returns an object of attributes. - * @method st#parseMUCPresence - * @param { XMLElement } stanza - The presence stanza - * @returns { Object } - */ - parseMUCPresence (stanza) { - const from = stanza.getAttribute("from"); - const type = stanza.getAttribute("type"); - const data = { - 'from': from, - 'nick': Strophe.getResourceFromJid(from), - 'type': type, - 'states': [], - 'hats': [], - 'show': type !== 'unavailable' ? 'online' : 'offline' - }; - Array.from(stanza.children).forEach(child => { - if (child.matches('status')) { - data.status = child.textContent || null; - } else if (child.matches('show')) { - data.show = child.textContent || 'online'; - } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.MUC_USER) { - Array.from(child.children).forEach(item => { - if (item.nodeName === "item") { - data.affiliation = item.getAttribute("affiliation"); - data.role = item.getAttribute("role"); - data.jid = item.getAttribute("jid"); - data.nick = item.getAttribute("nick") || data.nick; - } else if (item.nodeName == 'status' && item.getAttribute("code")) { - data.states.push(item.getAttribute("code")); - } - }); - } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) { - data.image_hash = child.querySelector('photo')?.textContent; - } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) { - data['hats'] = Array.from(child.children).map(c => c.matches('hat') && { - 'title': c.getAttribute('title'), - 'uri': c.getAttribute('uri') - }); - } - }); - return data; - } -} - -export default st; diff --git a/src/modals/muc-list.js b/src/modals/muc-list.js index 3b9fee663..73eaa7fa2 100644 --- a/src/modals/muc-list.js +++ b/src/modals/muc-list.js @@ -1,11 +1,11 @@ import BootstrapModal from "./base.js"; import log from "@converse/headless/log"; -import st from "@converse/headless/utils/stanza"; import tpl_list_chatrooms_modal from "./templates/muc-list.js"; import tpl_room_description from "templates/room_description.html"; import tpl_spinner from "templates/spinner.js"; import { __ } from '../i18n'; import { _converse, api, converse } from "@converse/headless/core"; +import { getAttributes } from '@converse/headless/shared/parsers'; import { head } from "lodash-es"; const { Strophe, $iq, sizzle } = converse.env; @@ -144,7 +144,7 @@ export default BootstrapModal.extend({ const rooms = iq ? sizzle('query item', iq) : []; if (rooms.length) { this.model.set({'feedback_text': __('Groupchats found')}, {'silent': true}); - this.items = rooms.map(st.getAttributes); + this.items = rooms.map(getAttributes); } else { this.items = []; this.model.set({'feedback_text': __('No groupchats found')}, {'silent': true}); diff --git a/src/plugins/muc-views/index.js b/src/plugins/muc-views/index.js index 23a04dd18..e465d8adf 100644 --- a/src/plugins/muc-views/index.js +++ b/src/plugins/muc-views/index.js @@ -7,7 +7,6 @@ import '../../components/muc-sidebar'; import '../chatview/index.js'; import '../modal.js'; -import '@converse/headless/utils/muc'; import ChatRoomViewMixin from './muc.js'; import MUCConfigForm from './config-form.js'; import MUCPasswordForm from './password-form.js';