From 0c6f852ce93b9a4f7f73681e32755dfe5648c9b4 Mon Sep 17 00:00:00 2001 From: Goffi Date: Thu, 24 Sep 2026 12:05:33 +0200 Subject: [PATCH] plugin XEP-0272: update to v0.2.0 + fixes: - use the proper `urn:xmpp:jingle:muji:0` namespace - replace XEP-0249 invitations with XEP-0482 - the dedicated `META_TYPE_GROUP_CALL` subtype is now used for the confirm action (instead of `META_TYPE_MUC_INVITATION`) - handle MUJI invitation: on accept, join the room, start preparing and answer with the connection method element - add the `` element to outgoing Jingle IQs - propagate the call type (audio/video) in the MUJI invites (the `type` attribute), so the invited clients set up the proper local stream - the joining participant initiates the sessions with the other participants, using their real JIDs, extracted from the presence (the room is set non-anonymous for that) - only trust the `` element of a `session-initiate` from a member of the room - add a `call_group_end` bridge method to clean the MUJI session - typo fix in `META_TYPE_MUC_INVIRATION` (`R` instead of `T`) --- libervia/backend/core/constants.py | 2 +- libervia/backend/plugins/plugin_xep_0249.py | 2 +- libervia/backend/plugins/plugin_xep_0272.py | 279 +++++++++++++++----- tests/unit/test_plugin_xep_0272.py | 171 ++++++++++-- 4 files changed, 359 insertions(+), 95 deletions(-) diff --git a/libervia/backend/core/constants.py b/libervia/backend/core/constants.py index 80f2bd7e..a5546d5c 100644 --- a/libervia/backend/core/constants.py +++ b/libervia/backend/core/constants.py @@ -442,7 +442,7 @@ class Const: META_TYPE_CALL = "call" META_TYPE_GROUP_CALL = "group-call" META_TYPE_REMOTE_CONTROL = "remote-control" - META_TYPE_MUC_INVIRATION = "muc-invitation" + META_TYPE_MUC_INVITATION = "muc-invitation" META_TYPE_OVERWRITE = "overwrite" META_TYPE_NOT_IN_ROSTER_LEAK = "not_in_roster_leak" META_SUBTYPE_CALL_AUDIO = "audio" diff --git a/libervia/backend/plugins/plugin_xep_0249.py b/libervia/backend/plugins/plugin_xep_0249.py index db89b910..2ba2762c 100644 --- a/libervia/backend/plugins/plugin_xep_0249.py +++ b/libervia/backend/plugins/plugin_xep_0249.py @@ -198,7 +198,7 @@ class XEP_0249: else: # leave the default value here action_extra = { "type": C.META_TYPE_CONFIRM, - "subtype": C.META_TYPE_MUC_INVIRATION, + "subtype": C.META_TYPE_MUC_INVITATION, "from_jid": from_jid_s, "room_jid": room_jid_s, } diff --git a/libervia/backend/plugins/plugin_xep_0272.py b/libervia/backend/plugins/plugin_xep_0272.py index 85622d3a..fc8e8e70 100644 --- a/libervia/backend/plugins/plugin_xep_0272.py +++ b/libervia/backend/plugins/plugin_xep_0272.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import cast from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber.xmlstream import XMPPHandler @@ -26,15 +27,15 @@ from zope.interface import implementer from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity -from libervia.backend.core.i18n import _ +from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger -from libervia.backend.plugins import plugin_xep_0166 -from libervia.backend.plugins import plugin_xep_0167 -from libervia.backend.plugins.plugin_xep_0167 import mapping +from libervia.backend.plugins.plugin_xep_0045 import XEP_0045 +from libervia.backend.plugins.plugin_xep_0166 import XEP_0166 +from libervia.backend.plugins.plugin_xep_0167 import XEP_0167, mapping +from libervia.backend.plugins.plugin_xep_0482 import XEP_0482 +from libervia.backend.tools import xml_tools from libervia.backend.tools.common import data_format -from . import plugin_xep_0045, plugin_xep_0249 - log = getLogger(__name__) @@ -43,16 +44,16 @@ PLUGIN_INFO = { C.PI_IMPORT_NAME: "XEP-0272", C.PI_TYPE: "XEP", C.PI_PROTOCOLS: ["XEP-0272"], - C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0166", "XEP-0167", "XEP-0249"], + C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0166", "XEP-0167", "XEP-0482"], C.PI_MAIN: "XEP_0272", C.PI_HANDLER: "yes", C.PI_DESCRIPTION: _( "Allow to run A/V conference with several participant using P2P connections. " - "The number of participant must not be to big to make it work correctly." + "The number of participant must not be too big to make it work correctly." ), } -NS_MUJI = "http://telepathy.freedesktop.org/muji" +NS_MUJI = "urn:xmpp:jingle:muji:0" PRESENCE_MUJI = f'/presence/muji[@xmlns="{NS_MUJI}"]' @@ -62,10 +63,13 @@ class XEP_0272: log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization') self.host = host host.register_namespace("muji", NS_MUJI) - self._muc: plugin_xep_0045.XEP_0045 = host.plugins["XEP-0045"] - self._muc_invite: plugin_xep_0249.XEP_0249 = host.plugins["XEP-0249"] - self._j: plugin_xep_0166.XEP_0166 = host.plugins["XEP-0166"] - self._rtp: plugin_xep_0167.XEP_0167 = host.plugins["XEP-0167"] + self._muc = cast(XEP_0045, host.plugins["XEP-0045"]) + self._j = cast(XEP_0166, host.plugins["XEP-0166"]) + self._rtp = cast(XEP_0167, host.plugins["XEP-0167"]) + self._call_invites = cast(XEP_0482, host.plugins["XEP-0482"]) + host.trigger.add("XEP-0166_initiate_elt_built", self._on_jingle_elt_built) + host.trigger.add("XEP-0166_on_session_initiate", self._on_session_initiate) + host.trigger.add("XEP-0482_invite_received", self._on_call_invite_received) host.bridge.add_method( "call_group_start", ".plugin", @@ -82,6 +86,14 @@ class XEP_0272: method=self._call_group_data_set, async_=True, ) + host.bridge.add_method( + "call_group_end", + ".plugin", + in_sign="ss", + out_sign="", + method=self._call_group_end, + async_=True, + ) host.bridge.add_signal("call_group_setup", ".plugin", signature="sss") def get_handler(self, client): @@ -115,15 +127,30 @@ class XEP_0272: ) ) - async def on_room_join(self, room: muc.Room, user: muc.User) -> None: - pass + def _call_group_end(self, room_jid_s: str, profile_key: str) -> None: + client = self.host.get_client(profile_key) + defer.ensureDeferred(self.call_group_end(client, jid.JID(room_jid_s))) - async def on_room_left(self, room: muc.Room, user: muc.User) -> None: - pass + async def call_group_end(self, client: SatXMPPEntity, room_jid: jid.JID) -> None: + """End and clean call.""" + try: + room = self._muc.get_room(client, room_jid) + except exceptions.NotFound: + log.warning(f"Ignoring MUJI end for unknown room: {room_jid}") + return + # We remove MUJI information from presence, as required by spec. + presence_elt = domish.Element((None, "presence")) + presence_elt["from"] = client.jid.full() + presence_elt["to"] = room.roomJID.full() + await client.presence.send(presence_elt) + muji_data = self.get_muji_data(room) + muji_data.clear() + muji_data["to_call"] = set() def on_muji_request( self, presence_elt: domish.Element, client: SatXMPPEntity ) -> None: + """Handle the MUJI element of a presence received in a MUC room""" from_jid = jid.JID(presence_elt["from"]) room_jid = from_jid.userhostJID() try: @@ -133,19 +160,11 @@ class XEP_0272: f"Ignoring MUJI element from an unknown room: {presence_elt.toXml()}" ) return - if from_jid == self._muc.get_room_user_jid(client, room_jid): - own_jid = True - else: - own_jid = False + own_jid = from_jid == self._muc.get_room_user_jid(client, room_jid) muji_data = self.get_muji_data(room) muji_elt = presence_elt.muji assert muji_elt is not None - try: - next(muji_elt.elements(NS_MUJI, "preparing")) - except StopIteration: - preparing_state = False - else: - preparing_state = True + preparing_state = next(muji_elt.elements(NS_MUJI, "preparing"), None) is not None if preparing_state: if own_jid: @@ -154,24 +173,34 @@ class XEP_0272: self.try_to_finish_preparation(client, room, muji_data) elif not muji_data.get("done_collecting", False): # other entities currently doing preparation - preparing_jids = muji_data["preparing_jids"] - preparing_jids.add(from_jid) + muji_data["preparing_jids"].add(from_jid) elif not own_jid: - done_preparing = muji_data.get("done_preparing", False) - # if we are still in preparation, we remove the JID from data data we are - # still waiting for, and we check if we can finish the preparation. - if not done_preparing: - allowed_payloads = muji_data.setdefault("allowed_payloads") - # TODO: check allowed_payloads - preparing_jids = muji_data["preparing_jids"] - preparing_jids.discard(from_jid) - log.debug( - f"[{client.profile}] received call data for {from_jid}.\n" - f"{preparing_jids=}" - ) - muji_data["to_call"].add(from_jid) + # a peer has advertised its data + if not muji_data.get("done_preparing", False): + muji_data["preparing_jids"].discard(from_jid) + log.debug(f"[{client.profile}] received call data for {from_jid}.") + # per spec, the joining participant initiates the sessions with all + # the other participants; the sender's real JID is in the presence + # itself (the room roster is not up to date yet for a joiner) + user_jid = self._get_presence_user_jid(presence_elt) + if user_jid is None: + raise exceptions.ExternalRequestError( + f"can't get the real JID of {from_jid}: the MUJI room is " + "anonymous" + ) + muji_data["to_call"].add(user_jid) self.try_to_finish_preparation(client, room, muji_data) + def _get_presence_user_jid(self, presence_elt: domish.Element) -> jid.JID | None: + """Get the sender's real JID from the muc#user item of a room presence.""" + x_elt = next(presence_elt.elements(muc.NS_MUC_USER, "x"), None) + if x_elt is None: + return None + item_elt = next(x_elt.elements(muc.NS_MUC_USER, "item"), None) + if item_elt is None or not item_elt.hasAttribute("jid"): + return None + return jid.JID(item_elt["jid"]) + def try_to_finish_preparation( self, client: SatXMPPEntity, room: muc.Room, muji_data: dict ) -> None: @@ -180,15 +209,19 @@ class XEP_0272: This method checks if preparations of other JIDs needs to be waited, and if not, finishes our own preparation. """ + if not muji_data.get("done_collecting", False): + # we have not received the broadcast of our own preparation message yet; + # a joiner receives the other occupants' data presences (join processing) + # before that echo, so waiting for it guarantees that all their data has + # been collected + return preparing_jids = muji_data.get("preparing_jids") if not preparing_jids: - # No preparation left to wait, we can finish our own. muji_data = self.get_muji_data(room) muji_data["done_preparing"] = True - log.debug(f"[{client.profile}] Done preparing.") - # We ask frontend to initiate the session, so we know supported codecs. + # ask the frontend to advertise our data and to initiate the sessions self.host.bridge.call_group_setup( room.roomJID.full(), data_format.serialise( @@ -203,10 +236,10 @@ class XEP_0272: room_jid: jid.JID, call_data: dict, ) -> None: - """Called when frontends has prepared group call. + """Advertise the group call data on the MUC. - Group call data will be advertised on the MUC, and call will be initiated with all - participant which where in preparing state when we made our own preparation. + Per spec, the late joiner initiates the sessions with the participants + which joined before it. @param client: SatXMPPEntity instance. @param room_jid: JID of the room used for MUJI coordination. @@ -215,7 +248,7 @@ class XEP_0272: try: room = self._muc.get_room(client, room_jid) except exceptions.NotFound: - log.warning(f"Ignoring MUJI element from an unknown room: {room_jid}") + log.warning(f"Ignoring MUJI data for unknown room: {room_jid}") return sdp_data = mapping.parse_sdp(call_data["sdp"], self._j.ROLE_INITIATOR) presence_elt, muji_elt = self.generate_presence_and_muji(client, room) @@ -240,7 +273,102 @@ class XEP_0272: for elt in to_remove: description_elt.children.remove(elt) - await client.a_send(presence_elt) + await client.presence.send(presence_elt) + + async def _on_jingle_elt_built( + self, client: SatXMPPEntity, session: dict, iq_elt, jingle_elt: domish.Element + ) -> bool: + """Add the element to the Jingle IQ of a MUJI call""" + metadata = session.get("metadata") + room_jid_s = metadata.get("muji_room") if metadata else None + if room_jid_s is not None: + muji_elt = jingle_elt.addElement((NS_MUJI, "muji")) + muji_elt["room"] = room_jid_s + return True + + async def _on_session_initiate( + self, + client: SatXMPPEntity, + session: dict, + request: domish.Element, + jingle_elt: domish.Element, + ) -> bool: + """Store MUJI room from a session-initiate request, if any. + + Per spec, the room is only trusted if the sender is a member of it. + """ + muji_elt = next(jingle_elt.elements(NS_MUJI, "muji"), None) + if muji_elt is None: + return True + room_jid = jid.JID(muji_elt["room"]) + if not self._muc.is_jid_in_room(client, room_jid, session["peer_jid"]): + log.warning( + f"Ignoring MUJI room {room_jid}: {session['peer_jid']} is not a member" + ) + return True + session["muji_room"] = muji_elt["room"] + return True + + def _on_call_invite_received(self, client, message_elt, invite_elt) -> bool: + """Handle a XEP-0482 call invite for a MUJI conference""" + muji_elt = next(invite_elt.elements(NS_MUJI, "muji"), None) + if muji_elt is None: + return True + try: + room_jid_s = muji_elt["room"] + except KeyError: + log.warning(f"Ignoring MUJI invite without room: {message_elt.toXml()}") + return True + from_jid_s = message_elt["from"] + log.info( + f"MUJI call invite received from {from_jid_s} for room {room_jid_s} " + f"[{client.profile}]" + ) + message_id = message_elt["id"] + try: + sub_type = muji_elt["type"] + except KeyError: + sub_type = C.META_SUBTYPE_CALL_AUDIO + action_extra = { + "type": C.META_TYPE_CONFIRM, + "subtype": C.META_TYPE_GROUP_CALL, + "from_jid": from_jid_s, + "room_jid": room_jid_s, + "sub_type": sub_type, + } + confirm_msg = D_( + "You have been invited by {user} to join the room {room}. Do you accept?" + ).format(user=from_jid_s, room=room_jid_s) + confirm_title = _("Group call invitation") + d = xml_tools.defer_confirm( + self.host, + confirm_msg, + confirm_title, + profile=client.profile, + action_extra=action_extra, + ) + + def answer_cb(accepted: bool) -> None: + # the accept contains the connection method used to join (XEP-0482) + method_elt = domish.Element((NS_MUJI, "muji")) + method_elt["room"] = room_jid_s + if accepted: + self._call_invites.accept( + client, jid.JID(from_jid_s), message_id, method_elt + ) + # join the MUJI room and start preparing the call + defer.ensureDeferred(self._join_and_prepare(client, jid.JID(room_jid_s))) + else: + self._call_invites.reject(client, jid.JID(from_jid_s), message_id) + + d.addCallback(answer_cb) + return False + + async def _join_and_prepare(self, client: SatXMPPEntity, room_jid: jid.JID) -> None: + """Join a MUJI room and start preparing (after accepting an invitation)""" + room = await self._muc.join(client, room_jid, {}) + if room is not None: + await self.start_preparation(client, room) def get_muji_data(self, room: muc.Room) -> dict: """Get MUJI related data for this room @@ -265,10 +393,10 @@ class XEP_0272: return presence_elt, muji_elt async def start_preparation(self, client: SatXMPPEntity, room: muc.Room) -> None: - """Start preparation of MUJI""" + """Advertise the MUJI preparation state on the MUC room""" presence_elt, muji_elt = self.generate_presence_and_muji(client, room) muji_elt.addElement("preparing") - await client.a_send(presence_elt) + await client.presence.send(presence_elt) async def call_group_start( self, @@ -278,18 +406,20 @@ class XEP_0272: ) -> dict: """Initiate a group call with the given peers. - A MUC room will be created, and people in ``list_entities`` will be invited. MUJI - session will then been started which each of them upon they arrival. - @param entities: JID of the peer to initiate a call session with. + A MUC room is created, made non-anonymous, and the peers are invited in it. + + @param client: The client instance. + @param entities: JIDs of the peers to invite. @param extra: Extra data. @return: group call data, with the following keys: ``room_jid`` - MUC room where the MUJI coordination is done. It may also be used for - normal chatting. + JID of the MUC room where the MUJI coordination is done. It may also + be used for normal chatting. - @raise exceptions.ExternalRequestError: The MUC room can't be created or joined. + @raise exceptions.ExternalRequestError: The MUC room can't be created, joined, + or made non-anonymous. """ log.debug(f"{client.profile} is starting a MUJI group call with {entities}") room_jid = self._muc.get_unique_name(client, prefix="_muji_") @@ -297,18 +427,33 @@ class XEP_0272: log.info(f"[{client.profile}] MUJI room created at {room_jid}") if not room: raise exceptions.ExternalRequestError("Can't create or join group chat room.") - await self.start_preparation(client, room) - room.on_joined_callbacks.append(self.on_room_join) - room.on_left_callbacks.append(self.on_room_left) - for entity in entities: - self._muc_invite.invite( - client, - entity, - room_jid, - reason="You have been invited to participate in a group call.", + # Make the room non-anonymous as participants need to know the real JID of others + try: + await self._muc.set_room_configuration( + client, room_jid, {"muc#roomconfig_whois": "anyone"} ) + except Exception: + raise exceptions.ExternalRequestError( + f"Can't make the MUJI room {room_jid} non-anonymous: the MUJI mesh " + "can't be established." + ) + await self.start_preparation(client, room) + sub_type = ( + C.META_SUBTYPE_CALL_VIDEO if extra.get("video") else C.META_SUBTYPE_CALL_AUDIO + ) + for entity in entities: + self.invite(client, entity, room_jid, sub_type) return {"room_jid": room_jid.full()} + def invite( + self, client: SatXMPPEntity, entity: jid.JID, room_jid: jid.JID, sub_type: str + ) -> None: + """Invite an entity to the MUJI conference (XEP-0482)""" + muji_elt = domish.Element((NS_MUJI, "muji")) + muji_elt["room"] = room_jid.full() + muji_elt["type"] = sub_type + self._call_invites.invite(client, entity, [muji_elt]) + @implementer(itx_xmpp.IDisco) class XEP_0272_handler(XMPPHandler): diff --git a/tests/unit/test_plugin_xep_0272.py b/tests/unit/test_plugin_xep_0272.py index 52fd389f..8a42a63b 100644 --- a/tests/unit/test_plugin_xep_0272.py +++ b/tests/unit/test_plugin_xep_0272.py @@ -16,14 +16,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import pytest from unittest.mock import MagicMock, patch from pytest_twisted import ensureDeferred as ed from pytest import fixture + +from libervia.backend.core import exceptions from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.xish import domish +from tx_xmpp import muc + from libervia.backend.plugins.plugin_xep_0272 import ( NS_MUJI, PRESENCE_MUJI, @@ -37,7 +42,7 @@ TEST_ROOM_JID = jid.JID("room@example.com/user") @fixture def xep_0272(host) -> XEP_0272: - """Fixture for initializing XEP-0272 plugin.""" + """XEP-0272 plugin with mocked dependencies.""" host.plugins = { "XEP-0045": MagicMock(), "XEP-0166": MagicMock(), @@ -48,6 +53,20 @@ def xep_0272(host) -> XEP_0272: class TestXEP0272: + def test_get_presence_user_jid(self, host, client): + """The real JID is extracted from the muc#user item of the presence.""" + xep_0272 = XEP_0272(host) + presence_elt = domish.Element((None, "presence")) + x_elt = presence_elt.addElement((muc.NS_MUC_USER, "x")) + item_elt = x_elt.addElement((muc.NS_MUC_USER, "item")) + item_elt["jid"] = "peer@example.com/res" + assert xep_0272._get_presence_user_jid(presence_elt) == jid.JID( + "peer@example.com/res" + ) + + # no muc#user item + assert xep_0272._get_presence_user_jid(domish.Element((None, "presence"))) is None + def test_get_handler(self, host, client): """XEP_0272_handler is instantiated and returned.""" xep_0272 = XEP_0272(host) @@ -56,56 +75,138 @@ class TestXEP0272: assert handler.plugin_parent == xep_0272 def test_on_muji_request_preparing_state(self, host, client): - """try_to_finish_preparation is called when preparing_state is True.""" + """Our own preparing broadcast finishes the data collection.""" xep_0272 = XEP_0272(host) presence_elt = domish.Element((None, "presence")) presence_elt["from"] = TEST_ROOM_JID.full() muji_elt = presence_elt.addElement((NS_MUJI, "muji")) muji_elt.addElement("preparing") - muji_data = {"preparing_jids": set()} + muji_data = {"preparing_jids": set(), "to_call": set()} with patch.object(xep_0272._muc, "get_room_user_jid", return_value=TEST_ROOM_JID): with patch.object(xep_0272, "get_muji_data", return_value=muji_data): with patch.object( xep_0272, "try_to_finish_preparation" ) as mock_try_to_finish_preparation: xep_0272.on_muji_request(presence_elt, client) + assert muji_data["done_collecting"] is True mock_try_to_finish_preparation.assert_called_once() - def test_on_muji_request_not_preparing_state(self, host, client): - """try_to_finish_preparation is not called when preparing_state is False.""" + def test_on_muji_request_peer_data(self, host, client): + """The real JID of a data-advertising peer is added to to_call.""" xep_0272 = XEP_0272(host) presence_elt = domish.Element((None, "presence")) - presence_elt["from"] = "room@example.com/user" + presence_elt["from"] = "room@example.com/peer" presence_elt.addElement((NS_MUJI, "muji")) + x_elt = presence_elt.addElement((muc.NS_MUC_USER, "x")) + item_elt = x_elt.addElement((muc.NS_MUC_USER, "item")) + item_elt["jid"] = "peer@example.com/res" - muji_data = {"done_preparing": True, "preparing_jids": set(), "to_call": set()} - with patch.object(xep_0272, "get_muji_data", return_value=muji_data): - with patch.object( - xep_0272, "try_to_finish_preparation" - ) as mock_try_to_finish_preparation: - xep_0272.on_muji_request(presence_elt, client) - mock_try_to_finish_preparation.assert_not_called() + room = MagicMock() + room.roomJID = TEST_ROOM_JID.userhostJID() + muji_data = {"preparing_jids": set(), "to_call": set()} + with ( + patch.object(xep_0272._muc, "get_room", return_value=room), + patch.object(xep_0272._muc, "get_room_user_jid", return_value=TEST_ROOM_JID), + patch.object(xep_0272, "get_muji_data", return_value=muji_data), + patch.object(host.bridge, "call_group_setup") as mock_call_group_setup, + ): + # the peer's data is collected, but the preparation is not finished yet + # (our own preparation broadcast has not been received) + xep_0272.on_muji_request(presence_elt, client) + assert "done_preparing" not in muji_data + mock_call_group_setup.assert_not_called() + + # our own preparation broadcast: the data collection is done, finish and + # ask the frontend to initiate the sessions + own_presence = domish.Element((None, "presence")) + own_presence["from"] = TEST_ROOM_JID.full() + muji_elt = own_presence.addElement((NS_MUJI, "muji")) + muji_elt.addElement("preparing") + xep_0272.on_muji_request(own_presence, client) + assert muji_data["done_preparing"] is True + mock_call_group_setup.assert_called_once() + to_call = data_format.deserialise(mock_call_group_setup.call_args.args[1])[ + "to_call" + ] + assert to_call == ["peer@example.com/res"] + # the preparation is already done: a data presence received now is + # ignored, no new setup is emitted + mock_call_group_setup.reset_mock() + xep_0272.on_muji_request(presence_elt, client) + mock_call_group_setup.assert_not_called() + + @ed + async def test_on_session_initiate(self, host, client): + """The MUJI room is only trusted if the sender is a member of the room.""" + xep_0272 = XEP_0272(host) + jingle_elt = domish.Element((None, "jingle")) + muji_elt = jingle_elt.addElement((NS_MUJI, "muji")) + muji_elt["room"] = "room@example.com" + + # sender is a member: the room is stored + with patch.object(xep_0272._muc, "is_jid_in_room", return_value=True): + session = {"peer_jid": jid.JID("peer@example.com/res")} + assert await xep_0272._on_session_initiate(client, session, None, jingle_elt) + assert session["muji_room"] == "room@example.com" + + # sender is not a member: the room is ignored + with patch.object(xep_0272._muc, "is_jid_in_room", return_value=False): + session = {"peer_jid": jid.JID("stranger@example.com")} + assert await xep_0272._on_session_initiate(client, session, None, jingle_elt) + assert "muji_room" not in session + + def test_on_muji_request_semi_anonymous_room(self, host, client): + """A room which does not reveal the real JIDs can't be used for MUJI.""" + xep_0272 = XEP_0272(host) + presence_elt = domish.Element((None, "presence")) + presence_elt["from"] = "room@example.com/peer" + presence_elt.addElement((NS_MUJI, "muji")) + # no muc#user item: the sender's real JID is not available + + room = MagicMock() + room.roomJID = TEST_ROOM_JID.userhostJID() + muji_data = {"preparing_jids": set(), "to_call": set()} + with ( + patch.object(xep_0272._muc, "get_room", return_value=room), + patch.object(xep_0272._muc, "get_room_user_jid", return_value=TEST_ROOM_JID), + patch.object(xep_0272, "get_muji_data", return_value=muji_data), + pytest.raises(exceptions.ExternalRequestError), + ): + xep_0272.on_muji_request(presence_elt, client) def test_on_muji_request_own_jid(self, host, client): - """try_to_finish_preparation is not called when the presence is from own JID.""" + """try_to_finish_preparation is not called for a presence from own JID.""" xep_0272 = XEP_0272(host) presence_elt = domish.Element((None, "presence")) presence_elt["from"] = "room@example.com/user" presence_elt.addElement((NS_MUJI, "muji")) - client.jid = jid.JID("room@example.com/user") - with patch.object( - xep_0272, "try_to_finish_preparation" - ) as mock_try_to_finish_preparation: + muji_data = {"preparing_jids": set(), "to_call": set()} + with ( + patch.object( + xep_0272._muc, + "get_room_user_jid", + return_value=jid.JID("room@example.com/user"), + ), + patch.object(xep_0272, "get_muji_data", return_value=muji_data), + patch.object( + xep_0272, "try_to_finish_preparation" + ) as mock_try_to_finish_preparation, + ): xep_0272.on_muji_request(presence_elt, client) mock_try_to_finish_preparation.assert_not_called() def test_try_to_finish_preparation(self, host, client): - """try_to_finish_preparation sets done_preparing to True and emits the signal.""" + """call_group_setup is only emitted once the data collection is done and no + other preparation is left to wait.""" xep_0272 = XEP_0272(host) room = MagicMock() - muji_data = {"preparing_jids": set(), "to_call": set()} + room.roomJID = TEST_ROOM_JID.userhostJID() + muji_data = { + "preparing_jids": set(), + "to_call": {jid.JID("early@example.com")}, + } # signals are synchronous (see _Bridge.send_signal): use a plain mock, not the # async one of the bridge fixture @@ -113,9 +214,27 @@ class TestXEP0272: patch.object(xep_0272, "get_muji_data", return_value=muji_data), patch.object(host.bridge, "call_group_setup") as mock_call_group_setup, ): + # our own preparation broadcast has not been received yet: nothing is done + xep_0272.try_to_finish_preparation(client, room, muji_data) + mock_call_group_setup.assert_not_called() + assert "done_preparing" not in muji_data + + # data collection done, no preparation left: finish and ask the frontend to + # initiate the sessions + muji_data["done_collecting"] = True xep_0272.try_to_finish_preparation(client, room, muji_data) assert muji_data["done_preparing"] is True mock_call_group_setup.assert_called_once() + to_call = data_format.deserialise(mock_call_group_setup.call_args.args[1])[ + "to_call" + ] + assert to_call == ["early@example.com"] + + # a preparation is still in progress: nothing more is done + muji_data["preparing_jids"] = {jid.JID("room@example.com/other")} + mock_call_group_setup.reset_mock() + xep_0272.try_to_finish_preparation(client, room, muji_data) + mock_call_group_setup.assert_not_called() @ed async def test_call_group_data_set(self, host, client): @@ -130,10 +249,10 @@ class TestXEP0272: return_value=(MagicMock(), MagicMock()), ): with patch.object( - client, "a_send", return_value=defer.succeed(None) - ) as mock_a_send: + client.presence, "send", return_value=defer.succeed(None) + ) as mock_presence_send: await xep_0272.call_group_data_set(client, room_jid, call_data) - mock_a_send.assert_called() + mock_presence_send.assert_called() @ed async def test_start_preparation(self, host, client): @@ -147,10 +266,10 @@ class TestXEP0272: return_value=(MagicMock(), MagicMock()), ): with patch.object( - client, "a_send", return_value=defer.succeed(None) - ) as mock_a_send: + client.presence, "send", return_value=defer.succeed(None) + ) as mock_presence_send: await xep_0272.start_preparation(client, room) - mock_a_send.assert_called() + mock_presence_send.assert_called() class TestXEP0272Handler: