From c645bd5fa84045c73d31f54703d47f975cbd91b2 Mon Sep 17 00:00:00 2001 From: nicoco Date: Sun, 24 Mar 2024 10:12:31 +0100 Subject: [PATCH] feat: support for Message Displayed Synchronization in MUCs --- slidge/core/gateway/base.py | 53 ++++++ slidge/core/gateway/session_dispatcher.py | 13 ++ slidge/core/mixins/message.py | 31 +++- slidge/slixfix/__init__.py | 2 + slidge/slixfix/xep_mds/__init__.py | 8 + slidge/slixfix/xep_mds/mds.py | 47 ++++++ slidge/slixfix/xep_mds/stanza.py | 17 ++ tests/test_mds.py | 190 ++++++++++++++++++++++ tests/test_session.py | 6 +- tests/test_shakespeare.py | 2 + 10 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 slidge/slixfix/xep_mds/__init__.py create mode 100644 slidge/slixfix/xep_mds/mds.py create mode 100644 slidge/slixfix/xep_mds/stanza.py create mode 100644 tests/test_mds.py diff --git a/slidge/core/gateway/base.py b/slidge/core/gateway/base.py index 564305f..f6b0f96 100644 --- a/slidge/core/gateway/base.py +++ b/slidge/core/gateway/base.py @@ -14,6 +14,7 @@ import aiohttp import qrcode from slixmpp import JID, ComponentXMPP, Iq, Message, Presence from slixmpp.exceptions import IqError, IqTimeout, XMPPError +from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation from slixmpp.types import MessageTypes from slixmpp.xmlstream.xmlstream import NotConnectedError @@ -384,6 +385,7 @@ class BaseGateway( # as last resort. try: await self["xep_0100"].add_component_to_roster(user.jid) + await self.__add_component_to_mds_whitelist(user.jid) except IqError as e: # TODO: remove the user when this happens? or at least # this can happen when the user has unsubscribed from the XMPP server @@ -401,6 +403,56 @@ class BaseGateway( log.info("Slidge has successfully started") + async def __add_component_to_mds_whitelist(self, user_jid: JID): + # Uses privileged entity to add ourselves to the whitelist of the PEP + # MDS node so we receive MDS events + iq_creation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") + iq_creation["pubsub"]["create"]["node"] = self["mds"].stanza.NS + + try: + await self["xep_0356"].send_privileged_iq(iq_creation) + except PermissionError: + log.warning( + "IQ privileges not granted for pubsub namespace, we cannot " + "create the MDS node of %s", + user_jid, + ) + except IqError as e: + # conflict this means the node already exists, we can ignore that + if e.condition != "conflict": + log.exception( + "Could not create the MDS node of %s", user_jid, exc_info=e + ) + except Exception as e: + log.exception( + "Error while trying to create to the MDS node of %s", + user_jid, + exc_info=e, + ) + + iq_affiliation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set") + iq_affiliation["pubsub_owner"]["affiliations"]["node"] = self["mds"].stanza.NS + + aff = OwnerAffiliation() + aff["jid"] = self.boundjid.bare + aff["affiliation"] = "member" + iq_affiliation["pubsub_owner"]["affiliations"].append(aff) + + try: + await self["xep_0356"].send_privileged_iq(iq_affiliation) + except PermissionError: + log.warning( + "IQ privileges not granted for pubsub#owner namespace, we cannot " + "listen to the MDS events of %s", + user_jid, + ) + except Exception as e: + log.exception( + "Error while trying to subscribe to the MDS node of %s", + user_jid, + exc_info=e, + ) + async def __login_wrap(self, session: "BaseSession"): session.send_gateway_status("Logging in…", show="dnd") try: @@ -830,6 +882,7 @@ SLIXMPP_PLUGINS = [ "xep_0444", # Message reactions "xep_0447", # Stateless File Sharing "xep_0461", # Message replies + "mds", # Message Displayed Synchronization ] LOG_STRIP_ELEMENTS = ["data", "binval"] diff --git a/slidge/core/gateway/session_dispatcher.py b/slidge/core/gateway/session_dispatcher.py index b8bfed4..706f92f 100644 --- a/slidge/core/gateway/session_dispatcher.py +++ b/slidge/core/gateway/session_dispatcher.py @@ -79,6 +79,7 @@ class SessionDispatcher: "groupchat_direct_invite", "groupchat_subject", "avatar_metadata_publish", + "message_displayed_synchronization_publish", ): xmpp.add_event_handler( event, _exceptions_to_xmpp_errors(getattr(self, "on_" + event)) @@ -476,6 +477,18 @@ class SessionDispatcher: muc = await session.bookmarks.by_jid(p.get_to()) await muc.join(p) + async def on_message_displayed_synchronization_publish(self, msg: Message): + session = await self.__get_session(msg, timeout=None) + + chat_jid = msg["pubsub_event"]["items"]["item"]["id"] + chat = await session.get_contact_or_group_or_participant(JID(chat_jid)) + if not isinstance(chat, LegacyMUC): + session.log.debug("Ignoring non-groupchat MDS event") + return + + stanza_id = msg["pubsub_event"]["items"]["item"]["displayed"]["stanza_id"]["id"] + await session.on_displayed(chat, _xmpp_msg_id_to_legacy(session, stanza_id)) + async def on_avatar_metadata_publish(self, m: Message): if not config.SYNC_AVATAR: return diff --git a/slidge/core/mixins/message.py b/slidge/core/mixins/message.py index 6a56622..96c66bd 100644 --- a/slidge/core/mixins/message.py +++ b/slidge/core/mixins/message.py @@ -4,8 +4,9 @@ import warnings from datetime import datetime from typing import TYPE_CHECKING, Iterable, Optional -from slixmpp import Message +from slixmpp import Iq, Message +from ...slixfix.xep_mds.mds import PUBLISH_OPTIONS from ...util.types import ( ChatState, LegacyMessageType, @@ -127,6 +128,34 @@ class MarkerMixin(MessageMaker): self._make_marker(legacy_msg_id, "displayed", carbon=kwargs.get("carbon")), **kwargs, ) + if getattr(self, "is_user", False): + self.xmpp.loop.create_task(self.__send_mds(legacy_msg_id)) + + async def __send_mds(self, legacy_msg_id: LegacyMessageType): + # Send a MDS displayed marker on behalf of the user for a group chat + if muc := getattr(self, "muc", None): + muc_jid = muc.jid.bare + else: + # This is not implemented for 1:1 chat because it would rely on + # storing the XMPP-server injected stanza-id, which we don't track + # ATM. + # In practice, MDS should mostly be useful for public group chats, + # so it should not be an issue. + # We'll see if we need to implement that later + return + xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id) + iq = Iq(sto=self.user.bare_jid, sfrom=self.user.bare_jid) + iq["pubsub"]["publish"]["node"] = self.xmpp["mds"].stanza.NS + iq["pubsub"]["publish"]["item"]["id"] = muc_jid + displayed = self.xmpp["mds"].stanza.Displayed() + displayed["stanza_id"]["id"] = xmpp_msg_id + displayed["stanza_id"]["by"] = muc_jid + iq["pubsub"]["publish"]["item"]["payload"] = displayed + iq["pubsub"]["publish_options"] = PUBLISH_OPTIONS + try: + await self.xmpp["xep_0356"].send_privileged_iq(iq) + except Exception as e: + self.session.log.debug("Could not MDS mark", exc_info=e) class ContentMessageMixin(AttachmentMixin): diff --git a/slidge/slixfix/__init__.py b/slidge/slixfix/__init__.py index b8d4664..503f3d6 100644 --- a/slidge/slixfix/__init__.py +++ b/slidge/slixfix/__init__.py @@ -19,6 +19,7 @@ from . import ( # xep_0356, xep_0317, xep_0356_old, xep_0424, + xep_mds, ) @@ -59,6 +60,7 @@ slixmpp.plugins.PLUGINS.extend( "xep_0292_provider", "xep_0317", "xep_0356_old", + "mds", ] ) diff --git a/slidge/slixfix/xep_mds/__init__.py b/slidge/slixfix/xep_mds/__init__.py new file mode 100644 index 0000000..69ab7de --- /dev/null +++ b/slidge/slixfix/xep_mds/__init__.py @@ -0,0 +1,8 @@ +from slixmpp.plugins.base import register_plugin + +from . import stanza +from .mds import XEP_xxxx_mds + +register_plugin(XEP_xxxx_mds) + +__all__ = ["stanza", "XEP_xxxx_mds"] diff --git a/slidge/slixfix/xep_mds/mds.py b/slidge/slixfix/xep_mds/mds.py new file mode 100644 index 0000000..eb27e1a --- /dev/null +++ b/slidge/slixfix/xep_mds/mds.py @@ -0,0 +1,47 @@ +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0004 import Form +from slixmpp.types import JidStr + +from . import stanza + + +class XEP_xxxx_mds(BasePlugin): # FIXME + """ + XEP-xxxx: Message Displayed Synchronization + """ + + name = "mds" + description = "XEP-xxxx: Message Displayed Synchronization" + dependencies = {"xep_0060", "xep_0163", "xep_0359"} + stanza = stanza + + def plugin_init(self): + stanza.register_plugin() + self.xmpp.plugin["xep_0163"].register_pep( + "message_displayed_synchronization", + stanza.Displayed, + ) + + def flag_chat(self, chat: JidStr, stanza_id: str, **kwargs) -> Iq: + displayed = stanza.Displayed() + displayed["stanza_id"]["id"] = stanza_id + return self.xmpp.plugin["xep_0163"].publish( + displayed, node=stanza.NS, options=PUBLISH_OPTIONS, id=str(chat), **kwargs + ) + + def catch_up(self, **kwargs): + return self.xmpp.plugin["xep_0060"].get_items( + self.xmpp.boundjid.bare, stanza.NS, **kwargs + ) + + +PUBLISH_OPTIONS = Form() +PUBLISH_OPTIONS["type"] = "submit" +PUBLISH_OPTIONS.add_field( + "FORM_TYPE", "hidden", value="http://jabber.org/protocol/pubsub#publish-options" +) +PUBLISH_OPTIONS.add_field("pubsub#persist_items", value="true") +PUBLISH_OPTIONS.add_field("pubsub#max_items", value="max") +PUBLISH_OPTIONS.add_field("pubsub#send_last_published_item", value="never") +PUBLISH_OPTIONS.add_field("pubsub#access_model", value="whitelist") diff --git a/slidge/slixfix/xep_mds/stanza.py b/slidge/slixfix/xep_mds/stanza.py new file mode 100644 index 0000000..0a73066 --- /dev/null +++ b/slidge/slixfix/xep_mds/stanza.py @@ -0,0 +1,17 @@ +from slixmpp import register_stanza_plugin +from slixmpp.plugins.xep_0060.stanza import Item +from slixmpp.plugins.xep_0359.stanza import StanzaID +from slixmpp.xmlstream import ElementBase + +NS = "urn:xmpp:mds:displayed:0" + + +class Displayed(ElementBase): + namespace = NS + name = "displayed" + plugin_attrib = "displayed" + + +def register_plugin(): + register_stanza_plugin(Displayed, StanzaID) + register_stanza_plugin(Item, Displayed) diff --git a/tests/test_mds.py b/tests/test_mds.py new file mode 100644 index 0000000..2139dba --- /dev/null +++ b/tests/test_mds.py @@ -0,0 +1,190 @@ +import unittest.mock + +from slixmpp import JID +from slixmpp.plugins.xep_0356.permissions import IqPermission +from test_muc import Base as BaseMUC + +# from test_shakespeare import Base as BaseNoMUC + + +class MDSMixin: + def setUp(self): + super().setUp() + for domain in "test.com", "montague.lit": + self.xmpp["xep_0356"].granted_privileges[domain].iq[ + "http://jabber.org/protocol/pubsub" + ] = IqPermission.BOTH + self.xmpp["xep_0356"].granted_privileges[domain].iq[ + "http://jabber.org/protocol/pubsub#owner" + ] = IqPermission.BOTH + patch = unittest.mock.patch("uuid.uuid4", return_value="uuid") + patch.start() + + +class TestMDS(MDSMixin, BaseMUC): + def test_add_to_whitelist(self): + task = self.xmpp.loop.create_task( + self.xmpp._BaseGateway__add_component_to_mds_whitelist(JID("test@test.com")) + ) + self.send( # language=XML + """ + + + + + + + + + + """, + use_values=False, + ) + self.recv( # language=XML + """ + + + + + + + + """ + ) + self.send( # language=XML + """ + + + + + + + + + + + + """, + use_values=False, + ) + self.recv( # language=XML + """ + + + + + + + + """ + ) + assert task.done() + + def test_receive_event(self): + session = self.get_romeo_session() + # juliet = self.juliet + muc = self.get_private_muc() + + with unittest.mock.patch("test_muc.Session.on_displayed") as on_displayed: + self.recv( # language=XML + f""" + + + + + + + + + + + + """ + ) + on_displayed.assert_awaited_once_with(muc, "legacy-1337") + + # class TestSendMDS(MDSMixin, BaseMUC): + def test_send_mds(self): + muc = self.get_private_muc() + participant = self.run_coro(muc.get_user_participant()) + participant.displayed("legacy-msg-id") + self.send( # language=XML + """ + + + + + + + + + + + + + + + http://jabber.org/protocol/pubsub#publish-options + + + 1 + + + max + + + never + + + whitelist + + + + + + + + """, + use_values=False, + ) diff --git a/tests/test_session.py b/tests/test_session.py index 9136c33..71b1b03 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -12,7 +12,7 @@ from slidge.util.types import LinkPreview class Gateway(BaseGateway): - pass + COMPONENT_NAME = "A test" class Session(BaseSession): @@ -79,6 +79,10 @@ class TestSession(AvatarFixtureMixin, SlidgeTest): from="{self.xmpp.boundjid.bare}"> YUP chat + """ ) diff --git a/tests/test_shakespeare.py b/tests/test_shakespeare.py index 7a6bdab..5cad79f 100644 --- a/tests/test_shakespeare.py +++ b/tests/test_shakespeare.py @@ -803,6 +803,8 @@ class TestAimShakespeareBase(Base): + + """ -- 2.45.2