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: