diff --git a/spec/autocomplete.js b/spec/autocomplete.js index c2bd2bfef..d85263139 100644 --- a/spec/autocomplete.js +++ b/spec/autocomplete.js @@ -37,7 +37,7 @@ describe("The nickname autocomplete feature", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Hello world').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); // Test that pressing @ brings up all options const textarea = view.el.querySelector('textarea.chat-textarea'); diff --git a/spec/headline.js b/spec/headline.js index 57600b5a0..0163e4bc1 100644 --- a/spec/headline.js +++ b/spec/headline.js @@ -6,7 +6,7 @@ describe("A headlines box", function () { mock.initConverse( ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) { - const { u, $msg} = converse.env; + const { $msg } = converse.env; /* XMPP spam message: * * SORRY FOR THIS ADVERT * */ - sinon.spy(u, 'isHeadlineMessage'); const stanza = $msg({ 'type': 'headline', 'from': 'notify.example.com', @@ -65,9 +60,6 @@ describe("A headlines box", function () { _converse.connection._dataRecv(mock.createRequest(stanza)); await u.waitUntil(() => _converse.chatboxviews.keys().includes('notify.example.com')); - expect(u.isHeadlineMessage.called).toBeTruthy(); - expect(u.isHeadlineMessage.returned(true)).toBeTruthy(); - u.isHeadlineMessage.restore(); // unwraps const view = _converse.chatboxviews.get('notify.example.com'); expect(view.model.get('show_avatar')).toBeFalsy(); expect(view.el.querySelector('img.avatar')).toBe(null); @@ -155,9 +147,8 @@ describe("A headlines box", function () { mock.initConverse( ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) { - const { u, $msg, _ } = converse.env; + const { $msg, _ } = converse.env; _converse.allow_non_roster_messaging = false; - sinon.spy(u, 'isHeadlineMessage'); const stanza = $msg({ 'type': 'headline', 'from': 'andre5114@jabber.snc.ru/Spark', @@ -168,9 +159,6 @@ describe("A headlines box", function () { .c('body').t('Здравствуйте друзья'); _converse.connection._dataRecv(mock.createRequest(stanza)); expect(_.without('controlbox', _converse.chatboxviews.keys()).length).toBe(0); - expect(u.isHeadlineMessage.called).toBeTruthy(); - expect(u.isHeadlineMessage.returned(true)).toBeTruthy(); - u.isHeadlineMessage.restore(); // unwraps done(); })); }); diff --git a/spec/mam.js b/spec/mam.js index 4ff3a6312..39e66192f 100644 --- a/spec/mam.js +++ b/spec/mam.js @@ -294,7 +294,7 @@ describe("Message Archive Management", function () { `); spyOn(view.model, 'getDuplicateMessage').and.callThrough(); spyOn(view.model, 'updateMessage').and.callThrough(); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); expect(view.model.getDuplicateMessage.calls.count()).toBe(1); const result = view.model.getDuplicateMessage.calls.all()[0].returnValue @@ -338,7 +338,7 @@ describe("Message Archive Management", function () { `); spyOn(view.model, 'getDuplicateMessage').and.callThrough(); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); expect(view.model.getDuplicateMessage.calls.count()).toBe(1); const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue @@ -368,7 +368,7 @@ describe("Message Archive Management", function () { `); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length); expect(view.content.querySelectorAll('.chat-msg').length).toBe(1); @@ -388,7 +388,7 @@ describe("Message Archive Management", function () { `); spyOn(view.model, 'getDuplicateMessage').and.callThrough(); - view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); expect(view.model.getDuplicateMessage.calls.count()).toBe(1); const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue diff --git a/spec/messages.js b/spec/messages.js index 63ed946a9..adb605aab 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -512,9 +512,8 @@ describe("A Chat Message", function () { // Ideally we wouldn't have to filter out headline // messages, but Prosody gives them the wrong 'type' :( - sinon.spy(converse.env.log, 'info'); + spyOn(converse.env.log, 'info'); sinon.spy(_converse.api.chatboxes, 'get'); - sinon.spy(u, 'isHeadlineMessage'); const msg = $msg({ from: 'montague.lit', to: _converse.bare_jid, @@ -522,16 +521,12 @@ describe("A Chat Message", function () { id: u.getUniqueId() }).c('body').t("This headline message will not be shown").tree(); await _converse.handleMessageStanza(msg); - expect(converse.env.log.info.calledWith( - "handleMessageStanza: Ignoring incoming headline message from JID: montague.lit" - )).toBeTruthy(); - expect(u.isHeadlineMessage.called).toBeTruthy(); - expect(u.isHeadlineMessage.returned(true)).toBeTruthy(); + expect(converse.env.log.info).toHaveBeenCalledWith( + "handleMessageStanza: Ignoring incoming server message from JID: montague.lit" + ); expect(_converse.api.chatboxes.get.called).toBeFalsy(); // Remove sinon spies - converse.env.log.info.restore(); _converse.api.chatboxes.get.restore(); - u.isHeadlineMessage.restore(); done(); })); @@ -1561,7 +1556,7 @@ describe("A Chat Message", function () { * */ const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout'; - const sender_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; let fullname = _converse.xmppstatus.get('fullname'); // eslint-disable-line no-unused-vars fullname = _.isEmpty(fullname) ? _converse.bare_jid: fullname; await _converse.api.chats.open(sender_jid) @@ -1757,7 +1752,7 @@ describe("A Chat Message", function () { await mock.waitForRoster(_converse, 'current'); await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length) // Send a message from a different resource - spyOn(converse.env.log, 'info'); + spyOn(converse.env.log, 'error'); spyOn(_converse.api.chatboxes, 'create').and.callThrough(); _converse.filter_by_resource = true; const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; @@ -1770,8 +1765,8 @@ describe("A Chat Message", function () { .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); await _converse.handleMessageStanza(msg); - expect(converse.env.log.info).toHaveBeenCalledWith( - "handleMessageStanza: Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource", + expect(converse.env.log.error.calls.all().pop().args[0]).toBe( + "Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource", ); expect(_converse.api.chatboxes.create).not.toHaveBeenCalled(); _converse.filter_by_resource = false; diff --git a/spec/minchats.js b/spec/minchats.js index 953ef4ff8..99203264f 100644 --- a/spec/minchats.js +++ b/spec/minchats.js @@ -156,7 +156,7 @@ describe("The Minimized Chats Widget", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(message).tree(); - view.model.queueMessage(msg); + view.model.handleMessageStanza(msg); await u.waitUntil(() => view.model.messages.length); expect(u.isVisible(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count'))).toBeTruthy(); expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe('1'); diff --git a/spec/muc.js b/spec/muc.js index 496403b31..8932542cb 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -518,8 +518,7 @@ describe("Groupchats", function () { _converse.connection._dataRecv(mock.createRequest(stanza)); const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); await new Promise(resolve => view.model.once('change:subject', resolve)); - - const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc')); + const head_desc = await u.waitUntil(() => view.el.querySelector('.chat-head__desc'), 1000); expect(head_desc?.textContent.trim()).toBe(text); stanza = u.toStanza( @@ -701,7 +700,7 @@ describe("Groupchats", function () { 'type': 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); spyOn(view.model, 'clearMessages').and.callThrough(); await view.model.close(); @@ -732,7 +731,7 @@ describe("Groupchats", function () { 'type': 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-msg__text a')); view.el.querySelector('.chat-msg__text a').click(); await u.waitUntil(() => _converse.chatboxes.length === 3) @@ -1269,7 +1268,7 @@ describe("Groupchats", function () { 'type': 'groupchat' }).c('body').t('Some message').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop()); let stanza = u.toStanza( @@ -1318,7 +1317,7 @@ describe("Groupchats", function () { 'to': 'romeo@montague.lit', 'type': 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop()); expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Dyon van de Wege')).toBeTruthy(); expect(view.el.querySelector('.chat-msg__text').textContent.trim()).toBe('is tired'); @@ -1330,7 +1329,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); expect(sizzle('.chat-msg__author:last', view.el).pop().textContent.includes('**Romeo Montague')).toBeTruthy(); expect(sizzle('.chat-msg__text:last', view.el).pop().textContent.trim()).toBe('is as well'); @@ -2016,7 +2015,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length); expect(view.content.querySelectorAll('.chat-msg').length).toBe(1); expect(view.content.querySelector('.chat-msg__text').textContent.trim()).toBe(text); @@ -2061,7 +2060,7 @@ describe("Groupchats", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(stanza); + await view.model.handleMessageStanza(stanza); expect(view.content.querySelectorAll('.chat-msg').length).toBe(1); expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text); expect(view.model.messages.length).toBe(1); @@ -2083,7 +2082,7 @@ describe("Groupchats", function () { const promises = []; for (let i=0; i<20; i++) { promises.push( - view.model.queueMessage( + view.model.handleMessageStanza( $msg({ from: 'lounge@montague.lit/someone', to: 'romeo@montague.lit.com', @@ -2096,7 +2095,7 @@ describe("Groupchats", function () { // Give enough time for `markScrolled` to have been called setTimeout(async () => { view.content.scrollTop = 0; - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: 'lounge@montague.lit/someone', to: 'romeo@montague.lit.com', @@ -4863,8 +4862,7 @@ describe("Groupchats", function () { view.model.set({'minimized': true}); const nick = mock.chatroom_names[0]; - - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ from: muc_jid+'/'+nick, id: u.getUniqueId(), to: 'romeo@montague.lit', @@ -4875,7 +4873,7 @@ describe("Groupchats", function () { expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1); expect(roomspanel.el.querySelector('.msgs-indicator').textContent.trim()).toBe('1'); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': muc_jid+'/'+nick, 'id': u.getUniqueId(), 'to': 'romeo@montague.lit', @@ -5027,7 +5025,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing'); // state for a different occupant @@ -5037,7 +5035,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing'); // state for a different occupant @@ -5047,7 +5045,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing'); // Check that new messages appear under the chat state notifications @@ -5057,7 +5055,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('hello world').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); const messages = view.el.querySelectorAll('.message'); @@ -5146,7 +5144,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent); expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing'); @@ -5157,7 +5155,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() == 'newguy and nomorenicks are typing'); @@ -5168,7 +5166,7 @@ describe("Groupchats", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() == 'nomorenicks is typing\nnewguy has stopped typing'); done(); })); diff --git a/spec/muc_messages.js b/spec/muc_messages.js index ad99451b0..a2b7c7c90 100644 --- a/spec/muc_messages.js +++ b/spec/muc_messages.js @@ -104,14 +104,11 @@ describe("A Groupchat Message", function () { `); const view = _converse.api.chatviews.get(muc_jid); spyOn(view.model, 'onMessage').and.callThrough(); - - - await view.model.queueMessage(received_stanza); - spyOn(converse.env.log, 'warn'); + spyOn(converse.env.log, 'error'); _converse.connection._dataRecv(mock.createRequest(received_stanza)); - await u.waitUntil(() => view.model.onMessage.calls.count()); - expect(converse.env.log.warn).toHaveBeenCalledWith( - 'onMessage: Ignoring unencapsulated forwarded groupchat message' + await u.waitUntil(() => view.model.onMessage.calls.count() === 1); + expect(converse.env.log.error).toHaveBeenCalledWith( + `Ignoring unencapsulated forwarded message from ${muc_jid}/mallory` ); expect(view.el.querySelectorAll('.chat-msg').length).toBe(0); expect(view.model.messages.length).toBe(0); @@ -137,7 +134,7 @@ describe("A Groupchat Message", function () { }).c('body').t(message) .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"}) .tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(view.el.querySelector('.chat-msg')).not.toBe(null); done(); @@ -160,7 +157,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(message).tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy(); done(); @@ -182,7 +179,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('First message').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); msg = $msg({ @@ -191,7 +188,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Another message').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2); expect(view.model.messages.length).toBe(2); done(); @@ -237,7 +234,7 @@ describe("A Groupchat Message", function () { `); spyOn(view.model, 'updateMessage'); - await view.model.queueMessage(stanza); + view.model.handleMAMResult({ 'messages': [stanza] }); await u.waitUntil(() => view.model.getDuplicateMessage.calls.count() === 2); result = await view.model.getDuplicateMessage.calls.all()[1].returnValue; expect(result instanceof _converse.Message).toBe(true); @@ -342,11 +339,10 @@ describe("A Groupchat Message", function () { 'type': 'groupchat' }).c('body').t('I am groot').tree(); const view = _converse.api.chatviews.get(muc_jid); - spyOn(converse.env.log, 'warn'); - await view.model.queueMessage(msg); - expect(converse.env.log.warn).toHaveBeenCalledWith( - 'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+ - 'according to the XEP groupchat messages SHOULD NOT be carbon copied' + spyOn(converse.env.log, 'error'); + await view.model.handleMAMResult({ 'messages': [msg] }); + expect(converse.env.log.error).toHaveBeenCalledWith( + 'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied' ); expect(view.el.querySelectorAll('.chat-msg').length).toBe(0); expect(view.model.messages.length).toBe(0); @@ -367,7 +363,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('I wrote this message!').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length); expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner'); expect(view.model.messages.last().occupant.get('role')).toBe('moderator'); @@ -393,7 +389,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Another message!').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(view.model.messages.last().occupant.get('affiliation')).toBe('member'); expect(view.model.messages.last().occupant.get('role')).toBe('participant'); @@ -430,7 +426,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('Message from someone not in the MUC right now').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); await new Promise(resolve => view.once('messageInserted', resolve)); expect(view.model.messages.last().occupant).toBeUndefined(); // Check that there's a new "add" event handler, for when the occupant appears. @@ -495,7 +491,7 @@ describe("A Groupchat Message", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t('I wrote this message!').tree(); - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); expect(view.model.messages.last().get('sender')).toBe('me'); done(); })); @@ -520,7 +516,7 @@ describe("A Groupchat Message", function () { }).tree(); _converse.connection._dataRecv(mock.createRequest(stanza)); const msg_id = u.getUniqueId(); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': 'lounge@montague.lit/newguy', 'to': _converse.connection.jid, 'type': 'groupchat', @@ -532,7 +528,7 @@ describe("A Groupchat Message", function () { expect(view.el.querySelector('.chat-msg__text').textContent) .toBe('But soft, what light through yonder airlock breaks?'); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': 'lounge@montague.lit/newguy', 'to': _converse.connection.jid, 'type': 'groupchat', @@ -544,7 +540,7 @@ describe("A Groupchat Message", function () { expect(view.el.querySelectorAll('.chat-msg').length).toBe(1); expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': 'lounge@montague.lit/newguy', 'to': _converse.connection.jid, 'type': 'groupchat', @@ -641,7 +637,7 @@ describe("A Groupchat Message", function () { expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false); // Check that messages from other users are skipped - await view.model.queueMessage($msg({ + await view.model.handleMessageStanza($msg({ 'from': muc_jid+'/someone-else', 'id': u.getUniqueId(), 'to': 'romeo@montague.lit', @@ -703,7 +699,7 @@ describe("A Groupchat Message", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(stanza); + await view.model.handleMessageStanza(stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0); expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1); @@ -776,13 +772,11 @@ describe("A Groupchat Message", function () { `); - spyOn(_converse.api, "trigger").and.callThrough(); - spyOn(stanza_utils, "isReceipt").and.callThrough(); + spyOn(stanza_utils, "parseMUCMessage").and.callThrough(); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isReceipt.calls.count() === 1); + 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); - expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); done(); })); @@ -814,9 +808,9 @@ describe("A Groupchat Message", function () { `); const stanza_utils = converse.env.stanza_utils; - spyOn(stanza_utils, "isChatMarker").and.callThrough(); + spyOn(stanza_utils, "getChatMarker").and.callThrough(); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 1); + 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); @@ -826,7 +820,7 @@ describe("A Groupchat Message", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 2); + 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); @@ -837,7 +831,7 @@ describe("A Groupchat Message", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 3); + 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); @@ -848,8 +842,8 @@ describe("A Groupchat Message", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await u.waitUntil(() => stanza_utils.isChatMarker.calls.count() === 4); - expect(view.el.querySelectorAll('.chat-msg').length).toBe(2); + 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(); })); @@ -887,7 +881,7 @@ describe("A Groupchat Message", function () { .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); expect(message.classList.length).toEqual(1); expect(message.innerHTML).toBe( @@ -928,7 +922,7 @@ describe("A Groupchat Message", function () { .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; - await view.model.queueMessage(msg); + await view.model.handleMessageStanza(msg); const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text')); expect(message.classList.length).toEqual(1); expect(message.innerHTML).toBe( @@ -972,7 +966,7 @@ describe("A Groupchat Message", function () { type="groupchat"> Boo! `); - await view.model.queueMessage(stanza); + await view.model.handleMessageStanza(stanza); // Run a few unit tests for the parseTextForReferences method let [text, references] = view.model.parseTextForReferences('hello z3r0') diff --git a/spec/notification.js b/spec/notification.js index b17c68b75..6733b61cf 100644 --- a/spec/notification.js +++ b/spec/notification.js @@ -95,10 +95,7 @@ describe("Notifications", function () { await u.waitUntil(() => _converse.chatboxviews.keys().length); const view = _converse.chatboxviews.get('notify.example.com'); await new Promise(resolve => view.once('messageInserted', resolve)); - expect( - _.includes(_converse.chatboxviews.keys(), - 'notify.example.com') - ).toBeTruthy(); + expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy(); expect(_converse.showMessageNotification).toHaveBeenCalled(); done(); })); @@ -175,7 +172,7 @@ describe("Notifications", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); await u.waitUntil(() => _converse.playSoundNotification.calls.count()); expect(_converse.playSoundNotification).toHaveBeenCalled(); @@ -186,7 +183,7 @@ describe("Notifications", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); expect(_converse.playSoundNotification, 1); _converse.play_sounds = false; @@ -197,7 +194,7 @@ describe("Notifications", function () { to: 'romeo@montague.lit', type: 'groupchat' }).c('body').t(text); - await view.model.queueMessage(message.nodeTree); + await view.model.handleMessageStanza(message.nodeTree); expect(_converse.playSoundNotification, 1); _converse.play_sounds = false; done(); diff --git a/spec/retractions.js b/spec/retractions.js index 53d869b9b..35844072c 100644 --- a/spec/retractions.js +++ b/spec/retractions.js @@ -19,7 +19,7 @@ async function sendAndThenRetractMessage (_converse, view) { by="lounge@montague.lit"/> `); - await view.model.queueMessage(reflection_stanza); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); const retract_button = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .chat-msg__action-retract')); @@ -52,7 +52,7 @@ describe("Message Retractions", function () { `); const view = _converse.api.chatviews.get(muc_jid); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); @@ -394,7 +394,7 @@ describe("Message Retractions", function () { `); const view = _converse.api.chatviews.get(muc_jid); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1); expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); @@ -440,7 +440,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.model.messages.length === 1); expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); @@ -498,7 +498,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); @@ -524,7 +524,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.el.querySelector('.chat-msg__content')); expect(view.el.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null); const result = await view.model.canModerateMessages(); @@ -551,7 +551,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(received_stanza); + await view.model.handleMessageStanza(received_stanza); await u.waitUntil(() => view.model.messages.length === 1); expect(view.model.messages.length).toBe(1); @@ -579,7 +579,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1); expect(view.model.messages.length).toBe(1); @@ -778,7 +778,7 @@ describe("Message Retractions", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(reflection_stanza); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('editable')).toBe(true); @@ -794,7 +794,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); @@ -830,7 +830,7 @@ describe("Message Retractions", function () { by="lounge@montague.lit"/> `); - await view.model.queueMessage(reflection_stanza); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('editable')).toBe(true); @@ -879,7 +879,7 @@ describe("Message Retractions", function () { `); - await view.model.queueMessage(retraction); + await view.model.handleMessageStanza(retraction); expect(view.model.messages.length).toBe(1); expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); diff --git a/spec/roomslist.js b/spec/roomslist.js index 300084212..2457d9e69 100644 --- a/spec/roomslist.js +++ b/spec/roomslist.js @@ -289,7 +289,7 @@ describe("A groupchat shown in the groupchats list", function () { const view = _converse.chatboxviews.get(room_jid); view.model.set({'minimized': true}); const nick = mock.chatroom_names[0]; - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: room_jid+'/'+nick, id: u.getUniqueId(), @@ -303,7 +303,7 @@ describe("A groupchat shown in the groupchats list", function () { expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); // If the user is mentioned, the counter also gets updated - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: room_jid+'/'+nick, id: u.getUniqueId(), @@ -316,7 +316,7 @@ describe("A groupchat shown in the groupchats list", function () { expect(indicator_el.textContent).toBe('1'); spyOn(view.model, 'incrementUnreadMsgCounter').and.callThrough(); - await view.model.queueMessage( + await view.model.handleMessageStanza( $msg({ from: room_jid+'/'+nick, id: u.getUniqueId(), diff --git a/src/converse-notification.js b/src/converse-notification.js index 125ac2567..6f9dd34a1 100644 --- a/src/converse-notification.js +++ b/src/converse-notification.js @@ -5,6 +5,7 @@ */ import { converse } from "@converse/headless/converse-core"; import log from "@converse/headless/log"; +import st from "@converse/headless/utils/stanza"; const { Strophe, sizzle } = converse.env; const u = converse.env.utils; @@ -79,7 +80,7 @@ converse.plugins.add('converse-notification', { return false; } else if (message.getAttribute('type') === 'groupchat') { return _converse.shouldNotifyOfGroupMessage(message); - } else if (u.isHeadlineMessage(_converse, message)) { + } else if (st.isHeadline(message)) { // We want to show notifications for headline messages. return _converse.isMessageToHiddenChat(message); } diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js index 814165a76..d54ed0b8a 100644 --- a/src/headless/converse-chat.js +++ b/src/headless/converse-chat.js @@ -397,6 +397,12 @@ converse.plugins.add('converse-chat', { return this.messages.fetched; }, + async handleErrormessageStanza (stanza) { + if (await this.shouldShowErrorMessage(stanza)) { + this.createMessage(await st.parseMessage(stanza, _converse)); + } + }, + /** * Queue an incoming `chat` message stanza for processing. * @async @@ -404,25 +410,29 @@ converse.plugins.add('converse-chat', { * @method _converse.ChatRoom#queueMessage * @param { XMLElement } stanza - The message stanza. */ - queueMessage (stanza, original_stanza, from_jid) { + queueMessage (attrs) { this.msg_chain = (this.msg_chain || this.messages.fetched); - this.msg_chain = this.msg_chain.then(() => this.onMessage(stanza, original_stanza, from_jid)); + this.msg_chain = this.msg_chain.then(() => this.onMessage(attrs)); return this.msg_chain; }, - async onMessage (stanza, original_stanza, from_jid) { - const attrs = await st.parseMessage(stanza, original_stanza, this, _converse); + async onMessage (attrs) { + attrs = await attrs; + if (u.isErrorObject(attrs)) { + attrs.stanza && log.error(attrs.stanza); + return log.error(attrs.message); + } + // TODO: move to OMEMO + attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs; const message = this.getDuplicateMessage(attrs); if (message) { - this.updateMessage(message, original_stanza); + this.updateMessage(message, attrs); } else if ( - !this.handleReceipt (stanza, original_stanza, from_jid) && - !this.handleChatMarker(stanza, from_jid) + !this.handleReceipt(attrs) && + !this.handleChatMarker(attrs) && + !(await this.handleRetraction(attrs)) ) { - if (await this.handleRetraction(attrs)) { - return; - } - this.setEditable(attrs, attrs.time, stanza); + this.setEditable(attrs, attrs.time); if (attrs['chat_state'] && attrs.sender === 'them') { this.notifications.set('chat_state', attrs.chat_state); @@ -525,17 +535,16 @@ converse.plugins.add('converse-chat', { } }, - getUpdatedMessageAttributes (message, stanza) { // eslint-disable-line no-unused-vars - return { - 'is_archived': st.isArchived(stanza), - } + getUpdatedMessageAttributes (message, attrs) { + // Filter the attrs object, restricting it to only the `is_archived` key. + return (({ is_archived }) => ({ is_archived }))(attrs) }, - updateMessage (message, stanza) { + updateMessage (message, attrs) { // Overridden in converse-muc and converse-mam - const attrs = this.getUpdatedMessageAttributes(message, stanza); + const new_attrs = this.getUpdatedMessageAttributes(message, attrs); if (attrs) { - message.save(attrs); + message.save(new_attrs); } }, @@ -682,10 +691,10 @@ converse.plugins.add('converse-chat', { * message or `undefined` if not applicable. */ handleCorrection (attrs) { - if (!attrs.replaced_id || !attrs.from) { + if (!attrs.replace_id || !attrs.from) { return; } - const message = this.messages.findWhere({'msgid': attrs.replaced_id, 'from': attrs.from}); + const message = this.messages.findWhere({'msgid': attrs.replace_id, 'from': attrs.from}); if (!message) { return; } @@ -797,35 +806,23 @@ converse.plugins.add('converse-chat', { api.send(stanza); }, - handleChatMarker (stanza, from_jid) { - const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to')); + handleChatMarker (attrs) { + const to_bare_jid = Strophe.getBareJidFromJid(attrs.to); if (to_bare_jid !== _converse.bare_jid) { return false; } - const markers = sizzle(`[xmlns="${Strophe.NS.MARKERS}"]`, stanza); - if (markers.length === 0) { - return false; - } else if (markers.length > 1) { - log.error('handleChatMarker: Ignoring incoming stanza with multiple message markers'); - log.error(stanza); - return false; - } else { - const marker = markers.pop(); - if (marker.nodeName === 'markable') { - if (this.contact && !u.isMAMMessage(stanza) && !u.isCarbonMessage(stanza)) { - this.sendMarker(from_jid, stanza.getAttribute('id'), 'received'); - } - return false; - } else { - const msgid = marker && marker.getAttribute('id'), - message = msgid && this.messages.findWhere({msgid}), - field_name = `marker_${marker.nodeName}`; - - if (message && !message.get(field_name)) { - message.save({field_name: (new Date()).toISOString()}); - } - return true; + if (attrs.is_markable) { + if (this.contact && !attrs.is_mam && !attrs.is_carbon) { + this.sendMarker(attrs.from, attrs.msgid, 'received'); } + return false; + } else if (attrs.marker_id) { + const message = this.messages.findWhere({'msgid': attrs.marker_id}); + const field_name = `marker_${attrs.marker}`; + if (message && !message.get(field_name)) { + message.save({field_name: (new Date()).toISOString()}); + } + return true; } }, @@ -840,18 +837,12 @@ converse.plugins.add('converse-chat', { api.send(receipt_stanza); }, - handleReceipt (stanza, original_stanza, from_jid) { - const is_me = Strophe.getBareJidFromJid(from_jid) === _converse.bare_jid; - const requests_receipt = sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop() !== undefined; - if (requests_receipt && !is_me && !u.isCarbonMessage(stanza) && !u.isMAMMessage(original_stanza)) { - this.sendReceiptStanza(from_jid, stanza.getAttribute('id')); - } - const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to')); - if (to_bare_jid === _converse.bare_jid) { - const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop(); - if (receipt) { - const msgid = receipt && receipt.getAttribute('id'), - message = msgid && this.messages.findWhere({msgid}); + handleReceipt (attrs) { + if (attrs.sender === 'them') { + if (attrs.is_receipt_request) { + this.sendReceiptStanza(attrs.from, attrs.msgid); + } else if (attrs.receipt_id) { + const message = this.messages.findWhere({'msgid': attrs.receipt_id}); if (message && !message.get('received')) { message.save({'received': (new Date()).toISOString()}); } @@ -945,8 +936,8 @@ converse.plugins.add('converse-chat', { * @param { Object } attrs An object containing message attributes. * @param { String } send_time - time when the message was sent */ - setEditable (attrs, send_time, stanza) { - if (stanza && u.isHeadlineMessage(_converse, stanza)) { + setEditable (attrs, send_time) { + if (attrs.is_headline) { return; } if (u.isEmptyMessage(attrs) || attrs.sender !== 'me') { @@ -1124,37 +1115,13 @@ converse.plugins.add('converse-chat', { }); - 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); - } - - async function handleErrorMessage (stanza) { const from_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from')); if (utils.isSameBareJID(from_jid, _converse.bare_jid)) { return; } const chatbox = await api.chatboxes.get(from_jid); - if (!chatbox) { - return; - } - const should_show = await chatbox.shouldShowErrorMessage(stanza); - if (!should_show) { - return; - } - const attrs = await st.parseMessage(stanza, stanza, chatbox, _converse); - await chatbox.createMessage(attrs); + chatbox?.handleErrormessageStanza(stanza); } @@ -1162,77 +1129,30 @@ converse.plugins.add('converse-chat', { * Handler method for all incoming single-user chat "message" stanzas. * @private * @method _converse#handleMessageStanza - * @param { XMLElement } stanza - The incoming message stanza + * @param { MessageAttributes } attrs - The message attributes */ _converse.handleMessageStanza = async function (stanza) { - const original_stanza = stanza; - 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 log.info(`handleMessageStanza: Ignoring incoming message intended for a different resource: ${to_jid}`); - } else if (utils.isHeadlineMessage(_converse, stanza)) { - // XXX: Prosody sends headline messages with the - // wrong type ('chat'), so we need to filter them out here. - return log.info(`handleMessageStanza: Ignoring incoming headline message from JID: ${stanza.getAttribute('from')}`); + if (st.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 bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length; - if (bare_forward) { - return rejectMessage( - stanza, - 'Forwarded messages not part of an encapsulating protocol are not supported' - ); + const attrs = await st.parseMessage(stanza, _converse); + if (u.isErrorObject(attrs)) { + attrs.stanza && log.error(attrs.stanza); + return log.error(attrs.message); } - let from_jid = stanza.getAttribute('from') || _converse.bare_jid; - if (u.isCarbonMessage(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 - return rejectMessage(stanza, 'Rejecting carbon from invalid JID'); - } - } - - if (u.isMAMMessage(stanza)) { - 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 log.warn(`handleMessageStanza: Ignoring alleged MAM message from ${stanza.getAttribute('from')}`); - } - } - - const from_bare_jid = Strophe.getBareJidFromJid(from_jid); - const is_me = from_bare_jid === _converse.bare_jid; - if (is_me && to_jid === null) { - return log.error(`Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`); - } - const contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid; - const contact = await api.contacts.get(contact_jid); - if (contact === undefined && !api.settings.get("allow_non_roster_messaging")) { - log.error(`Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`); - return log.error(stanza); - } - // Get chat box, but only create when the message has something to show to the user - const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length > 0; - const roster_nick = contact?.attributes?.nickname; - const chatbox = await api.chats.get(contact_jid, {'nickname': roster_nick}, has_body); - chatbox && await chatbox.queueMessage(stanza, original_stanza, from_jid); + const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length; + const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body); + chatbox && await chatbox.queueMessage(attrs); /** * Triggered when a message stanza is been received and processed. * @event _converse#message * @type { object } - * @property { _converse.ChatBox | _converse.ChatRoom } chatbox * @property { XMLElement } stanza * @example _converse.api.listen.on('message', obj => { ... }); */ - api.trigger('message', {'stanza': original_stanza, 'chatbox': chatbox}); + api.trigger('message', {'stanza': stanza}); } diff --git a/src/headless/converse-headlines.js b/src/headless/converse-headlines.js index 3e8d7f32b..1590df0dc 100644 --- a/src/headless/converse-headlines.js +++ b/src/headless/converse-headlines.js @@ -7,8 +7,6 @@ import { isString } from "lodash"; import { converse } from "@converse/headless/converse-core"; import st from "./utils/stanza"; -const u = converse.env.utils; - converse.plugins.add('converse-headlines', { /* Plugin dependencies are other plugins which might be @@ -83,7 +81,7 @@ converse.plugins.add('converse-headlines', { async function onHeadlineMessage (stanza) { // Handler method for all incoming messages of type "headline". - if (u.isHeadlineMessage(_converse, stanza)) { + if (st.isHeadline(stanza) || st.isServerMessage(stanza)) { const from_jid = stanza.getAttribute('from'); if (from_jid.includes('@') && !_converse.roster.get(from_jid) && @@ -100,7 +98,7 @@ converse.plugins.add('converse-headlines', { 'type': _converse.HEADLINES_TYPE, 'from': from_jid }); - const attrs = await st.parseMessage(stanza, stanza, chatbox, _converse); + const attrs = await st.parseMessage(stanza, _converse); await chatbox.createMessage(attrs); api.trigger('message', {'chatbox': chatbox, 'stanza': stanza}); } @@ -109,10 +107,7 @@ converse.plugins.add('converse-headlines', { /************************ BEGIN Event Handlers ************************/ function registerHeadlineHandler () { - _converse.connection.addHandler(message => { - onHeadlineMessage(message); - return true - }, null, 'message'); + _converse.connection.addHandler(message => (onHeadlineMessage(message) || true), null, 'message'); } api.listen.on('connected', registerHeadlineHandler); api.listen.on('reconnected', registerHeadlineHandler); diff --git a/src/headless/converse-mam.js b/src/headless/converse-mam.js index 6f91bd24e..2324580ed 100644 --- a/src/headless/converse-mam.js +++ b/src/headless/converse-mam.js @@ -11,9 +11,11 @@ import { intersection, pick } from 'lodash' import { converse } from "./converse-core"; import log from "./log"; import sizzle from "sizzle"; +import st from "./utils/stanza"; let _converse; const { Strophe, $iq, dayjs } = converse.env; +const { NS } = Strophe; const u = converse.env.utils; // XEP-0313 Message Archive Management @@ -47,6 +49,26 @@ const MAMEnabledChat = { } }, + async handleMAMResult (result, query, options, page_direction) { + 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)) + ); + + /** + * Synchronous event which allows listeners to first do some + * work based on the MAM result before calling the handlers here. + * @event _converse#MAMResult + */ + await api.trigger('MAMResult', result, query, {'synchronous': true}); + + result.messages.forEach(m => this.queueMessage(m)); + if (result.error) { + result.error.retry = () => this.fetchArchivedMessages(options, page_direction); + this.createMessageFromError(result.error); + } + }, + /** * Fetch XEP-0313 archived messages based on the passed in criteria. * @private @@ -64,55 +86,34 @@ const MAMEnabledChat = { * @param { string } [options.with] - The JID of the entity with * which messages were exchanged. * @param { boolean } [options.groupchat] - True if archive in groupchat. - * @param { boolean } [page] - Whether this function should recursively - * page through the entire result set if a limited number of results - * were returned. + * @param { ('forwards'|'backwards')} [page_direction] - Determines whether this function should + * recursively page through the entire result set if a limited number of results were returned. */ - async fetchArchivedMessages (options={}, page) { + async fetchArchivedMessages (options={}, page_direction) { if (this.disable_mam) { return; } - const is_groupchat = this.get('type') === _converse.CHATROOMS_TYPE; - const mam_jid = is_groupchat ? this.get('jid') : _converse.bare_jid; - if (!(await api.disco.supports(Strophe.NS.MAM, mam_jid))) { + const is_muc = this.get('type') === _converse.CHATROOMS_TYPE; + const mam_jid = is_muc ? this.get('jid') : _converse.bare_jid; + if (!(await api.disco.supports(NS.MAM, mam_jid))) { return; } - const msg_handler = is_groupchat ? s => this.queueMessage(s) : s => _converse.handleMessageStanza(s); - const query = Object.assign({ - 'groupchat': is_groupchat, - 'max': api.settings.get('archived_messages_page_size'), - 'with': this.get('jid'), - }, options); + 'groupchat': is_muc, + 'max': api.settings.get('archived_messages_page_size'), + 'with': this.get('jid'), + }, options); const result = await api.archive.query(query); - /** - * *Hook* which allows plugins to inspect and potentially modify the result of a MAM query - * from {@link MAMEnabledChat.fetchArchivedMessages}. - * @event _converse#MAMResult - */ - api.hook('MAMResult', this, { result, query }); + await this.handleMAMResult(result, query, options, page_direction); - for (const message of result.messages) { - try { - await msg_handler(message); - } catch (e) { - log.error(e); - } - } - - if (result.error) { - result.error.retry = () => this.fetchArchivedMessages(options, page); - this.createMessageFromError(result.error); - } - - if (page && result.rsm) { - if (page === 'forwards') { + if (page_direction && result.rsm) { + if (page_direction === 'forwards') { options = result.rsm.next(api.settings.get('archived_messages_page_size'), options.before); - } else if (page === 'backwards') { + } else if (page_direction === 'backwards') { options = result.rsm.previous(api.settings.get('archived_messages_page_size'), options.after); } - return this.fetchArchivedMessages(options, page); + return this.fetchArchivedMessages(options, page_direction); } else { // TODO: Add a special kind of message which will // render as a link to fetch further messages, either @@ -162,12 +163,12 @@ converse.plugins.add('converse-mam', { * Per JID preferences will be set in chat boxes, so it'll * probbaly be handled elsewhere in any case. */ - const preference = sizzle(`prefs[xmlns="${Strophe.NS.MAM}"]`, iq).pop(); + const preference = sizzle(`prefs[xmlns="${NS.MAM}"]`, iq).pop(); const default_pref = preference.getAttribute('default'); if (default_pref !== api.settings.get('message_archiving')) { const stanza = $iq({'type': 'set'}) .c('prefs', { - 'xmlns':Strophe.NS.MAM, + 'xmlns':NS.MAM, 'default':api.settings.get('message_archiving') }); Array.from(preference.children).forEach(child => stanza.cnode(child).up()); @@ -185,11 +186,11 @@ converse.plugins.add('converse-mam', { function getMAMPrefsFromFeature (feature) { const prefs = feature.get('preferences') || {}; - if (feature.get('var') !== Strophe.NS.MAM || api.settings.get('message_archiving') === undefined) { + if (feature.get('var') !== NS.MAM || api.settings.get('message_archiving') === undefined) { return; } if (prefs['default'] !== api.settings.get('message_archiving')) { - api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': Strophe.NS.MAM})) + api.sendIQ($iq({'type': 'get'}).c('prefs', {'xmlns': NS.MAM})) .then(iq => _converse.onMAMPreferences(iq, feature)) .catch(_converse.onMAMError); } @@ -207,7 +208,7 @@ converse.plugins.add('converse-mam', { } /************************ BEGIN Event Handlers ************************/ - api.listen.on('addClientFeatures', () => api.disco.own.features.add(Strophe.NS.MAM)); + api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM)); api.listen.on('serviceDiscovered', getMAMPrefsFromFeature); api.listen.on('chatRoomViewInitialized', view => { if (_converse.muc_show_logs_before_join) { @@ -437,18 +438,18 @@ converse.plugins.add('converse-mam', { } const jid = attrs.to || _converse.bare_jid; - const supported = await api.disco.supports(Strophe.NS.MAM, jid); + const supported = await api.disco.supports(NS.MAM, jid); if (!supported) { - log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${Strophe.NS.MAM}`); + log.warn(`Did not fetch MAM archive for ${jid} because it doesn't support ${NS.MAM}`); return {'messages': []}; } const queryid = u.getUniqueId(); - const stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid}); + const stanza = $iq(attrs).c('query', {'xmlns':NS.MAM, 'queryid':queryid}); if (options) { - stanza.c('x', {'xmlns':Strophe.NS.XFORM, 'type': 'submit'}) + stanza.c('x', {'xmlns':NS.XFORM, 'type': 'submit'}) .c('field', {'var':'FORM_TYPE', 'type': 'hidden'}) - .c('value').t(Strophe.NS.MAM).up().up(); + .c('value').t(NS.MAM).up().up(); if (options['with'] && !options.groupchat) { stanza.c('field', {'var':'with'}).c('value') @@ -474,7 +475,7 @@ converse.plugins.add('converse-mam', { const messages = []; const message_handler = _converse.connection.addHandler(stanza => { - const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop(); + const result = sizzle(`message > result[xmlns="${NS.MAM}"]`, stanza).pop(); if (result === undefined || result.getAttribute('queryid') !== queryid) { return true; } @@ -490,7 +491,7 @@ converse.plugins.add('converse-mam', { } messages.push(stanza); return true; - }, Strophe.NS.MAM); + }, NS.MAM); let error; const iq_result = await api.sendIQ(stanza, api.settings.get('message_archiving_timeout'), false) @@ -508,9 +509,9 @@ converse.plugins.add('converse-mam', { _converse.connection.deleteHandler(message_handler); let rsm; - const fin = iq_result && sizzle(`fin[xmlns="${Strophe.NS.MAM}"]`, iq_result).pop(); + const fin = iq_result && sizzle(`fin[xmlns="${NS.MAM}"]`, iq_result).pop(); if (fin && [null, 'false'].includes(fin.getAttribute('complete'))) { - const set = sizzle(`set[xmlns="${Strophe.NS.RSM}"]`, fin).pop(); + const set = sizzle(`set[xmlns="${NS.RSM}"]`, fin).pop(); if (set) { rsm = new _converse.RSM({'xml': set}); Object.assign(rsm, Object.assign(pick(options, [...MAM_ATTRIBUTES, ..._converse.RSM_ATTRIBUTES]), rsm)); diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 4b0b4acf6..674a1011a 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -608,6 +608,26 @@ converse.plugins.add('converse-muc', { } }, + async handleErrormessageStanza (stanza) { + if (await this.shouldShowErrorMessage(stanza)) { + this.createMessage(await st.parseMUCMessage(stanza, this, _converse)); + } + }, + + async handleMessageStanza (stanza) { + if (st.isArchived(stanza)) { + // MAM messages are handled in converse-mam. + // We shouldn't get MAM messages here because + // they shouldn't have a `type` attribute. + return log.warn(`Received a MAM message with type "groupchat"`); + } + api.trigger('message', {'stanza': stanza}); + this.createInfoMessages(stanza); + this.fetchFeaturesIfConfigurationChanged(stanza); + const attrs = await st.parseMUCMessage(stanza, this, _converse); + return attrs && this.queueMessage(attrs); + }, + registerHandlers () { // Register presence and message handlers for this groupchat const room_jid = this.get('jid'); @@ -618,17 +638,9 @@ converse.plugins.add('converse-muc', { {'ignoreNamespaceFragment': true, 'matchBareFromJid': true} ); - this.message_handler = _converse.connection.addHandler(stanza => { - if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) { - // MAM messages are handled in converse-mam. - // We shouldn't get MAM messages here because - // they shouldn't have a `type` attribute. - log.warn(`received a mam message with type "chat".`); - return true; - } - this.queueMessage(stanza); - return true; - }, null, 'message', 'groupchat', null, room_jid, + this.message_handler = _converse.connection.addHandler( + stanza => (!!this.handleMessageStanza(stanza) || true), + null, 'message', 'groupchat', null, room_jid, {'matchBareFromJid': true} ); @@ -1650,7 +1662,7 @@ converse.plugins.add('converse-muc', { * @private * @method _converse.ChatRoom#handleSubjectChange * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link st.parseMUCMessage} */ async handleSubjectChange (attrs) { if (isString(attrs.subject) && !attrs.thread && !attrs.message) { @@ -1703,8 +1715,7 @@ converse.plugins.add('converse-muc', { * @param { Object } attrs - The message attributes */ ignorableCSN (attrs) { - const is_csn = u.isOnlyChatStateNotification(attrs); - return is_csn && (attrs.is_delayed || this.isOwnMessage(attrs)); + return attrs.chat_state && !attrs.body && (attrs.is_delayed || this.isOwnMessage(attrs)); }, @@ -1729,21 +1740,17 @@ converse.plugins.add('converse-muc', { }, - getUpdatedMessageAttributes (message, stanza) { + getUpdatedMessageAttributes (message, attrs) { // Overridden in converse-muc and converse-mam - const attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, stanza); - if (this.isOwnMessage(message)) { - const stanza_id = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop(); - const by_jid = stanza_id ? stanza_id.getAttribute('by') : undefined; - if (by_jid) { - const key = `stanza_id ${by_jid}`; - attrs[key] = stanza_id.getAttribute('id'); - } + const new_attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs); + if (this.isOwnMessage(attrs)) { + const stanza_id_keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id')); + Object.assign(new_attrs, pick(attrs, stanza_id_keys)); if (!message.get('received')) { - attrs.received = (new Date()).toISOString(); + new_attrs.received = (new Date()).toISOString(); } } - return attrs; + return new_attrs; }, /** @@ -1808,7 +1815,7 @@ converse.plugins.add('converse-muc', { * @private * @method _converse.ChatRoom#findDanglingModeration * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link st.parseMUCMessage} * @returns { _converse.ChatRoomMessage } */ findDanglingModeration (attrs) { @@ -1839,7 +1846,7 @@ converse.plugins.add('converse-muc', { * @private * @method _converse.ChatRoom#handleModeration * @param { object } attrs - Attributes representing a received - * message, as returned by {@link st.parseMessage} + * message, as returned by {@link st.parseMUCMessage} * @returns { Boolean } Returns `true` or `false` depending on * whether a message was moderated or not. */ @@ -1954,47 +1961,25 @@ converse.plugins.add('converse-muc', { * should be called. * @private * @method _converse.ChatRoom#onMessage - * @param { XMLElement } stanza - The message stanza. + * @param { MessageAttributes } attrs - The message attributes */ - async onMessage (stanza) { - if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) { - return log.warn('onMessage: Ignoring unencapsulated forwarded groupchat message'); + async onMessage (attrs) { + if (u.isErrorObject(attrs)) { + attrs.stanza && log.error(attrs.stanza); + return log.error(attrs.message); } - if (u.isCarbonMessage(stanza)) { - return log.warn( - 'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+ - 'according to the XEP groupchat messages SHOULD NOT be carbon copied' - ); - } - const original_stanza = stanza; - if (u.isMAMMessage(stanza)) { - if (original_stanza.getAttribute('from') === this.get('jid')) { - const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`; - stanza = sizzle(selector, stanza).pop(); - } else { - return log.warn(`onMessage: Ignoring alleged MAM groupchat message from ${stanza.getAttribute('from')}`); - } - } - await this.createInfoMessages(stanza); - this.fetchFeaturesIfConfigurationChanged(stanza); - - const attrs = await st.parseMessage(stanza, original_stanza, this, _converse); + // TODO: move to OMEMO + attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs; const message = this.getDuplicateMessage(attrs); if (message) { - this.updateMessage(message, original_stanza); + return this.updateMessage(message, attrs); + } else if (attrs.is_receipt_request || attrs.is_marker || this.ignorableCSN(attrs)) { + return; } - if (message || - st.isReceipt(stanza) || - st.isChatMarker(stanza) || - this.ignorableCSN(attrs)) { - return api.trigger('message', {'stanza': original_stanza}); - } - if (await this.handleRetraction(attrs) || await this.handleModeration(attrs) || await this.handleSubjectChange(attrs)) { - this.removeNotification(attrs.nick, ['composing', 'paused']); - return api.trigger('message', {'stanza': original_stanza}); + return this.removeNotification(attrs.nick, ['composing', 'paused']); } this.setEditable(attrs, attrs.time); @@ -2006,7 +1991,6 @@ converse.plugins.add('converse-muc', { this.removeNotification(attrs.nick, ['composing', 'paused']); this.incrementUnreadMsgCounter(msg); } - api.trigger('message', {'stanza': original_stanza, 'chatbox': this}); }, handleModifyError(pres) { diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index 17debc132..e56ca1897 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -42,16 +42,6 @@ u.toStanza = function (string) { return node.firstElementChild; } -u.isMAMMessage = function (stanza) { - return sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).length > 0; -} - -u.isCarbonMessage = function (stanza) { - const xmlns = Strophe.NS.CARBONS; - return sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 || - sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0; -} - u.getLongestSubstring = function (string, candidates) { function reducer (accumulator, current_value) { if (string.startsWith(current_value)) { @@ -142,6 +132,7 @@ u.isEmptyMessage = function (attrs) { !attrs['message']; }; +//TODO: Remove u.isOnlyChatStateNotification = function (msg) { if (msg instanceof Element) { // See XEP-0085 Chat State Notification @@ -174,25 +165,6 @@ u.isChatRoom = function (model) { return model && (model.get('type') === 'chatroom'); } -u.isHeadlineMessage = function (_converse, message) { - const from_jid = message.getAttribute('from'); - if (message.getAttribute('type') === 'headline') { - return true; - } - const chatbox = _converse.chatboxes.get(Strophe.getBareJidFromJid(from_jid)); - if (u.isChatRoom(chatbox)) { - return false; - } - if (message.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) { - // Some servers (I'm looking at you Prosody) don't set the message - // 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 message. - return true; - } - return false; -}; - u.isErrorObject = function (o) { return o instanceof Error; } diff --git a/src/headless/utils/stanza.js b/src/headless/utils/stanza.js index 5a1d2b809..f9168f150 100644 --- a/src/headless/utils/stanza.js +++ b/src/headless/utils/stanza.js @@ -1,42 +1,16 @@ import * as strophe from 'strophe.js/src/core'; -import { propertyOf } from "lodash"; import dayjs from 'dayjs'; -import log from '@converse/headless/log'; import sizzle from 'sizzle'; import u from '@converse/headless/utils/core'; +import log from "../log"; +import { __ } from '@converse/headless/i18n'; +import { api } from "@converse/headless/converse-core"; const Strophe = strophe.default.Strophe; +const $msg = strophe.default.$msg; +const { NS } = Strophe; -function getSenderAttributes (stanza, chatbox, _converse) { - if (u.isChatRoom(chatbox)) { - const from = stanza.getAttribute('from'); - const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from)); - return { - 'from': from, - 'from_muc': Strophe.getBareJidFromJid(from), - 'nick': nick, - 'sender': nick === chatbox.get('nick') ? 'me': 'them', - 'received': (new Date()).toISOString(), - } - } else { - const from = Strophe.getBareJidFromJid(stanza.getAttribute('from')); - if (from === _converse.bare_jid) { - return { - from, - 'sender': 'me', - 'fullname': _converse.xmppstatus.get('fullname') - } - } else { - return { - from, - 'sender': 'them', - 'fullname': chatbox.get('fullname') - } - } - } -} - function getSpoilerAttributes (stanza) { const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop(); return { @@ -59,14 +33,14 @@ function getOutOfBandAttributes (stanza) { function getCorrectionAttributes (stanza, original_stanza) { const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop(); if (el) { - const replaced_id = el.getAttribute('id'); - const msgid = replaced_id; - if (replaced_id) { + 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, - replaced_id, + replace_id, 'edited': time } } @@ -74,64 +48,325 @@ function getCorrectionAttributes (stanza, original_stanza) { return {}; } -function getEncryptionAttributes (stanza, original_stanza, attrs, chatbox, _converse) { - const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(); + +function getEncryptionAttributes (stanza, _converse) { + const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop(); if (!encrypted || !_converse.config.get('trusted')) { - return attrs; + return {}; } const device_id = _converse.omemo_store?.get('device_id'); const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop(); if (key) { const header = encrypted.querySelector('header'); - attrs['is_encrypted'] = true; - attrs['encrypted'] = { - 'device_id': header.getAttribute('sid'), - 'iv': header.querySelector('iv').textContent, - 'key': key.textContent, - 'payload': encrypted.querySelector('payload')?.textContent || null, - 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) + return { + 'is_encrypted': true, + 'encrypted': { + 'device_id': header.getAttribute('sid'), + 'iv': header.querySelector('iv').textContent, + 'key': key.textContent, + 'payload': encrypted.querySelector('payload')?.textContent || null, + 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) + } + } + } + return {}; +} + + +function isReceiptRequest (stanza, attrs) { + return ( + attrs.sender !== 'me' && + !attrs.is_carbon && + !attrs.is_mam && + 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'); + if (by_jid) { + attrs[`stanza_id ${by_jid}`] = result.getAttribute('id'); + } else { + attrs[`stanza_id`] = 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 + } + } } - // Returns a promise - return chatbox.decrypt(attrs); } else { - return attrs; + 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') + }; + }); +} + +/** + * Returns the human readable error message contained in an message stanza of type 'error'. + * @private + * @param { XMLElement } stanza - The message stanza + */ +function getErrorMessage (stanza) { + if (stanza.getAttribute('type') === 'error') { + const error = stanza.querySelector('error'); + return error.querySelector('text')?.textContent || + __('Sorry, an error occurred:') + ' ' + error.innerHTML; + } +} + + +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 getMUCErrorMessage (stanza) { + if (stanza.getAttribute('type') === 'error') { + const forbidden = sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); + if (forbidden) { + const msg = __("Your message was not delivered because you weren't allowed to send it."); + const text = sizzle(`error text[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); + const server_msg = text ? __('The message from the server is: "%1$s"', text.textContent) : ''; + return server_msg ? `${msg} ${server_msg}` : msg; + } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { + return __("Your message was not delivered because you're not present in the groupchat."); + } + } +} + + +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 stanza_utils + * The stanza utils object. Contains utility functions related to stanza processing. + * @namespace st */ -const stanza_utils = { +const st = { - isReceipt (stanza) { - return sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length > 0; + isHeadline (stanza) { + return stanza.getAttribute('type') === 'headline'; }, - isChatMarker (stanza) { - return sizzle( - `received[xmlns="${Strophe.NS.MARKERS}"], - displayed[xmlns="${Strophe.NS.MARKERS}"], - acknowledged[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length > 0; + 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 represents a XEP-0313 MAM stanza + * Determines whether the passed in stanza is a XEP-0333 Chat Marker * @private - * @method stanza_utils#isArchived + * @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(`result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); + return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop(); }, /** * Returns an object containing all attribute names and values for a particular element. - * @private - * @method stanza_utils#getAttributes + * @method st#getAttributes * @param { XMLElement } stanza * @returns { Object } */ @@ -142,235 +377,319 @@ const stanza_utils = { }, {}); }, - /** - * Extract the XEP-0359 stanza IDs from the passed in stanza - * and return a map containing them. - * @private - * @method stanza_utils#getStanzaIDs - * @param { XMLElement } stanza - The message stanza - * @returns { Object } - */ - 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'); - 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; - }, - - /** @method stanza_utils#getModerationAttributes - * @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 } room - The MUC in which the moderation stanza is received. - * @returns { Object } - */ - getModerationAttributes (stanza, original_stanza, room) { - 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) { - const from = stanza.getAttribute('from'); - if (from !== room.get('jid')) { - log.warn("getModerationAttributes: ignore moderation stanza that's not from the MUC!"); - log.error(original_stanza); - return {}; - } - 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 {}; - }, - /** - * @method stanza_utils#getRetractionAttributes + * Parses a passed in message stanza and returns an object of attributes. + * @method st#parseMessage * @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 } - */ - 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 {}; - }, - - getReferences (stanza) { - const text = propertyOf(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') - }; - }); - }, - - getErrorMessage (stanza, is_muc, _converse) { - const { __ } = _converse; - if (is_muc) { - const forbidden = sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); - if (forbidden) { - const msg = __("Your message was not delivered because you weren't allowed to send it."); - const text = sizzle(`error text[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop(); - const server_msg = text ? __('The message from the server is: "%1$s"', text.textContent) : ''; - return server_msg ? `${msg} ${server_msg}` : msg; - } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) { - return __("Your message was not delivered because you're not present in the groupchat."); - } - } - const error = stanza.querySelector('error'); - return propertyOf(error.querySelector('text'))('textContent') || - __('Sorry, an error occurred:') + ' ' + error.innerHTML; - }, - - /** - * Given a message stanza, return the text contained in its body. - * @private - * @method stanza_utils#getMessageBody - * @param { XMLElement } stanza - * @param { Boolean } is_muc * @param { _converse } _converse + * @returns { (MessageAttributes|Error) } */ - getMessageBody (stanza, is_muc, _converse) { - const type = stanza.getAttribute('type'); - if (type === 'error') { - return stanza_utils.getErrorMessage(stanza, is_muc, _converse); - } else { - const body = stanza.querySelector('body'); - if (body) { - return body.textContent.trim(); + 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); + } + + 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); } } - }, - getChatState (stanza) { - return stanza.getElementsByTagName('composing').length && 'composing' || - stanza.getElementsByTagName('paused').length && 'paused' || - stanza.getElementsByTagName('inactive').length && 'inactive' || - stanza.getElementsByTagName('active').length && 'active' || - stanza.getElementsByTagName('gone').length && 'gone'; + if (st.isArchived(stanza)) { + 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_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_receipt_request - Does this message request a XEP-0184 receipt? + * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? + * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @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 } edit - An ISO8601 string recording the time that the message was edited per XEP-0308 + * @property { String } error - The error message, in case it's an error stanza + * @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 original_stanza = stanza; + 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_headline, + is_server_message, + 'body': stanza.querySelector('body')?.textContent?.trim(), + 'chat_state': getChatState(stanza), + 'error': getErrorMessage(stanza), + 'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')), + 'is_archived': st.isArchived(original_stanza), + 'is_carbon': isCarbon(original_stanza), + 'is_delayed': !!delay, + 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, + 'is_marker': !!marker, + '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') + }, + 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 && contact_jid && from !== contact_jid) { + return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza); + } + } + 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_receipt_request': isReceiptRequest(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(); + return attrs; }, /** * Parses a passed in message stanza and returns an object of attributes. - * @private - * @method stanza_utils#parseMessage + * @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.ChatBox|_converse.ChatRoom } chatbox + * @param { _converse.ChatRoom } chatbox * @param { _converse } _converse - * @returns { Object } + * @returns { (MUCMessageAttributes|Error) } */ - async parseMessage (stanza, original_stanza, chatbox, _converse) { - const is_muc = u.isChatRoom(chatbox); - let attrs = Object.assign( - stanza_utils.getStanzaIDs(stanza, original_stanza), - stanza_utils.getRetractionAttributes(stanza, original_stanza), - is_muc ? stanza_utils.getModerationAttributes(stanza, original_stanza, chatbox) : {}, - ); - const text = stanza_utils.getMessageBody(stanza, is_muc, _converse) || undefined; + 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(); - attrs = Object.assign( - { - 'chat_state': stanza_utils.getChatState(stanza), - 'is_archived': stanza_utils.isArchived(original_stanza), + const from = stanza.getAttribute('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_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_receipt_request - Does this message request a XEP-0184 receipt? + * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message? + * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone? + * @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 } edit - An ISO8601 string recording the time that the message was edited per XEP-0308 + * @property { String } error - The error message, in case it's an error stanza + * @property { String } from - The sender JID + * @property { String } from_muc - The JID of the MUC from which this message was sent + * @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, + 'body': stanza.querySelector('body')?.textContent?.trim(), + 'chat_state': getChatState(stanza), + 'error': getMUCErrorMessage(stanza), + 'from_muc': Strophe.getBareJidFromJid(from), + 'is_archived': st.isArchived(original_stanza), + 'is_carbon': isCarbon(original_stanza), 'is_delayed': !!delay, - 'is_only_emojis': text ? u.isOnlyEmojis(text) : false, - 'message': text, + 'is_headline': st.isHeadline(stanza), + 'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length, + 'is_marker': !!marker, + 'marker_id': marker && marker.getAttribute('id'), 'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'), - 'references': stanza_utils.getReferences(stanza), - 'subject': propertyOf(stanza.querySelector('subject'))('textContent'), - 'thread': propertyOf(stanza.querySelector('thread'))('textContent'), - 'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(), - 'type': stanza.getAttribute('type') + 'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)), + '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'), }, - attrs, - getSenderAttributes(stanza, chatbox, _converse), getOutOfBandAttributes(stanza), getSpoilerAttributes(stanza), getCorrectionAttributes(stanza, original_stanza), - ) - attrs = await getEncryptionAttributes(stanza, original_stanza, attrs, chatbox, _converse) - // We prefer to use one of the XEP-0359 unique and stable stanza IDs - // as the Model id, to avoid duplicates. + getStanzaIDs(stanza, original_stanza), + getRetractionAttributes(stanza, original_stanza), + getModerationAttributes(stanza), + getEncryptionAttributes(stanza, _converse) + ); + + attrs = Object.assign({ + 'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false, + 'is_receipt_request': isReceiptRequest(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(); return attrs; }, /** * Parses a passed in MUC presence stanza and returns an object of attributes. - * @private - * @method stanza_utils#parseMUCPresence + * @method st#parseMUCPresence * @param { XMLElement } stanza - The presence stanza * @returns { Object } */ @@ -414,4 +733,4 @@ const stanza_utils = { } } -export default stanza_utils; +export default st; diff --git a/webpack.html b/webpack.html index 07cee0dec..eb7cb2d4b 100644 --- a/webpack.html +++ b/webpack.html @@ -34,7 +34,7 @@ websocket_url: 'ws://chat.example.org:5380/xmpp-websocket', // bosh_service_url: 'http://chat.example.org:5280/http-bind', muc_show_logs_before_join: true, - whitelisted_plugins: ['converse-debug'], + whitelisted_plugins: ['converse-debug', 'converse-batched-probe'], });