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 `<muji>` 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 `<muji>` 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`)
This commit is contained in:
Goffi 2026-09-24 12:05:33 +02:00
commit 0c6f852ce9
4 changed files with 358 additions and 94 deletions

View file

@ -442,7 +442,7 @@ class Const:
META_TYPE_CALL = "call" META_TYPE_CALL = "call"
META_TYPE_GROUP_CALL = "group-call" META_TYPE_GROUP_CALL = "group-call"
META_TYPE_REMOTE_CONTROL = "remote-control" META_TYPE_REMOTE_CONTROL = "remote-control"
META_TYPE_MUC_INVIRATION = "muc-invitation" META_TYPE_MUC_INVITATION = "muc-invitation"
META_TYPE_OVERWRITE = "overwrite" META_TYPE_OVERWRITE = "overwrite"
META_TYPE_NOT_IN_ROSTER_LEAK = "not_in_roster_leak" META_TYPE_NOT_IN_ROSTER_LEAK = "not_in_roster_leak"
META_SUBTYPE_CALL_AUDIO = "audio" META_SUBTYPE_CALL_AUDIO = "audio"

View file

@ -198,7 +198,7 @@ class XEP_0249:
else: # leave the default value here else: # leave the default value here
action_extra = { action_extra = {
"type": C.META_TYPE_CONFIRM, "type": C.META_TYPE_CONFIRM,
"subtype": C.META_TYPE_MUC_INVIRATION, "subtype": C.META_TYPE_MUC_INVITATION,
"from_jid": from_jid_s, "from_jid": from_jid_s,
"room_jid": room_jid_s, "room_jid": room_jid_s,
} }

View file

@ -15,6 +15,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import cast
from twisted.internet import defer from twisted.internet import defer
from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber.xmlstream import XMPPHandler 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 import exceptions
from libervia.backend.core.constants import Const as C from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPEntity 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.core.log import getLogger
from libervia.backend.plugins import plugin_xep_0166 from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
from libervia.backend.plugins import plugin_xep_0167 from libervia.backend.plugins.plugin_xep_0166 import XEP_0166
from libervia.backend.plugins.plugin_xep_0167 import mapping 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 libervia.backend.tools.common import data_format
from . import plugin_xep_0045, plugin_xep_0249
log = getLogger(__name__) log = getLogger(__name__)
@ -43,16 +44,16 @@ PLUGIN_INFO = {
C.PI_IMPORT_NAME: "XEP-0272", C.PI_IMPORT_NAME: "XEP-0272",
C.PI_TYPE: "XEP", C.PI_TYPE: "XEP",
C.PI_PROTOCOLS: ["XEP-0272"], 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_MAIN: "XEP_0272",
C.PI_HANDLER: "yes", C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _( C.PI_DESCRIPTION: _(
"Allow to run A/V conference with several participant using P2P connections. " "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}"]' 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') log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization')
self.host = host self.host = host
host.register_namespace("muji", NS_MUJI) host.register_namespace("muji", NS_MUJI)
self._muc: plugin_xep_0045.XEP_0045 = host.plugins["XEP-0045"] self._muc = cast(XEP_0045, host.plugins["XEP-0045"])
self._muc_invite: plugin_xep_0249.XEP_0249 = host.plugins["XEP-0249"] self._j = cast(XEP_0166, host.plugins["XEP-0166"])
self._j: plugin_xep_0166.XEP_0166 = host.plugins["XEP-0166"] self._rtp = cast(XEP_0167, host.plugins["XEP-0167"])
self._rtp: plugin_xep_0167.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( host.bridge.add_method(
"call_group_start", "call_group_start",
".plugin", ".plugin",
@ -82,6 +86,14 @@ class XEP_0272:
method=self._call_group_data_set, method=self._call_group_data_set,
async_=True, 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") host.bridge.add_signal("call_group_setup", ".plugin", signature="sss")
def get_handler(self, client): 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: def _call_group_end(self, room_jid_s: str, profile_key: str) -> None:
pass 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: async def call_group_end(self, client: SatXMPPEntity, room_jid: jid.JID) -> None:
pass """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( def on_muji_request(
self, presence_elt: domish.Element, client: SatXMPPEntity self, presence_elt: domish.Element, client: SatXMPPEntity
) -> None: ) -> None:
"""Handle the MUJI element of a presence received in a MUC room"""
from_jid = jid.JID(presence_elt["from"]) from_jid = jid.JID(presence_elt["from"])
room_jid = from_jid.userhostJID() room_jid = from_jid.userhostJID()
try: try:
@ -133,19 +160,11 @@ class XEP_0272:
f"Ignoring MUJI element from an unknown room: {presence_elt.toXml()}" f"Ignoring MUJI element from an unknown room: {presence_elt.toXml()}"
) )
return return
if from_jid == self._muc.get_room_user_jid(client, room_jid): own_jid = from_jid == self._muc.get_room_user_jid(client, room_jid)
own_jid = True
else:
own_jid = False
muji_data = self.get_muji_data(room) muji_data = self.get_muji_data(room)
muji_elt = presence_elt.muji muji_elt = presence_elt.muji
assert muji_elt is not None assert muji_elt is not None
try: preparing_state = next(muji_elt.elements(NS_MUJI, "preparing"), None) is not None
next(muji_elt.elements(NS_MUJI, "preparing"))
except StopIteration:
preparing_state = False
else:
preparing_state = True
if preparing_state: if preparing_state:
if own_jid: if own_jid:
@ -154,24 +173,34 @@ class XEP_0272:
self.try_to_finish_preparation(client, room, muji_data) self.try_to_finish_preparation(client, room, muji_data)
elif not muji_data.get("done_collecting", False): elif not muji_data.get("done_collecting", False):
# other entities currently doing preparation # other entities currently doing preparation
preparing_jids = muji_data["preparing_jids"] muji_data["preparing_jids"].add(from_jid)
preparing_jids.add(from_jid)
elif not own_jid: elif not own_jid:
done_preparing = muji_data.get("done_preparing", False) # a peer has advertised its data
# if we are still in preparation, we remove the JID from data data we are if not muji_data.get("done_preparing", False):
# still waiting for, and we check if we can finish the preparation. muji_data["preparing_jids"].discard(from_jid)
if not done_preparing: log.debug(f"[{client.profile}] received call data for {from_jid}.")
allowed_payloads = muji_data.setdefault("allowed_payloads") # per spec, the joining participant initiates the sessions with all
# TODO: check allowed_payloads # the other participants; the sender's real JID is in the presence
preparing_jids = muji_data["preparing_jids"] # itself (the room roster is not up to date yet for a joiner)
preparing_jids.discard(from_jid) user_jid = self._get_presence_user_jid(presence_elt)
log.debug( if user_jid is None:
f"[{client.profile}] received call data for {from_jid}.\n" raise exceptions.ExternalRequestError(
f"{preparing_jids=}" f"can't get the real JID of {from_jid}: the MUJI room is "
) "anonymous"
muji_data["to_call"].add(from_jid) )
muji_data["to_call"].add(user_jid)
self.try_to_finish_preparation(client, room, muji_data) 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( def try_to_finish_preparation(
self, client: SatXMPPEntity, room: muc.Room, muji_data: dict self, client: SatXMPPEntity, room: muc.Room, muji_data: dict
) -> None: ) -> None:
@ -180,15 +209,19 @@ class XEP_0272:
This method checks if preparations of other JIDs needs to be waited, and if not, This method checks if preparations of other JIDs needs to be waited, and if not,
finishes our own preparation. 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") preparing_jids = muji_data.get("preparing_jids")
if not 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 = self.get_muji_data(room)
muji_data["done_preparing"] = True muji_data["done_preparing"] = True
log.debug(f"[{client.profile}] Done preparing.") 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( self.host.bridge.call_group_setup(
room.roomJID.full(), room.roomJID.full(),
data_format.serialise( data_format.serialise(
@ -203,10 +236,10 @@ class XEP_0272:
room_jid: jid.JID, room_jid: jid.JID,
call_data: dict, call_data: dict,
) -> None: ) -> 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 Per spec, the late joiner initiates the sessions with the participants
participant which where in preparing state when we made our own preparation. which joined before it.
@param client: SatXMPPEntity instance. @param client: SatXMPPEntity instance.
@param room_jid: JID of the room used for MUJI coordination. @param room_jid: JID of the room used for MUJI coordination.
@ -215,7 +248,7 @@ class XEP_0272:
try: try:
room = self._muc.get_room(client, room_jid) room = self._muc.get_room(client, room_jid)
except exceptions.NotFound: 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 return
sdp_data = mapping.parse_sdp(call_data["sdp"], self._j.ROLE_INITIATOR) sdp_data = mapping.parse_sdp(call_data["sdp"], self._j.ROLE_INITIATOR)
presence_elt, muji_elt = self.generate_presence_and_muji(client, room) presence_elt, muji_elt = self.generate_presence_and_muji(client, room)
@ -240,7 +273,102 @@ class XEP_0272:
for elt in to_remove: for elt in to_remove:
description_elt.children.remove(elt) 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 <muji> 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: def get_muji_data(self, room: muc.Room) -> dict:
"""Get MUJI related data for this room """Get MUJI related data for this room
@ -265,10 +393,10 @@ class XEP_0272:
return presence_elt, muji_elt return presence_elt, muji_elt
async def start_preparation(self, client: SatXMPPEntity, room: muc.Room) -> None: 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) presence_elt, muji_elt = self.generate_presence_and_muji(client, room)
muji_elt.addElement("preparing") muji_elt.addElement("preparing")
await client.a_send(presence_elt) await client.presence.send(presence_elt)
async def call_group_start( async def call_group_start(
self, self,
@ -278,18 +406,20 @@ class XEP_0272:
) -> dict: ) -> dict:
"""Initiate a group call with the given peers. """Initiate a group call with the given peers.
A MUC room will be created, and people in ``list_entities`` will be invited. MUJI A MUC room is created, made non-anonymous, and the peers are invited in it.
session will then been started which each of them upon they arrival.
@param entities: JID of the peer to initiate a call session with. @param client: The client instance.
@param entities: JIDs of the peers to invite.
@param extra: Extra data. @param extra: Extra data.
@return: group call data, with the following keys: @return: group call data, with the following keys:
``room_jid`` ``room_jid``
MUC room where the MUJI coordination is done. It may also be used for JID of the MUC room where the MUJI coordination is done. It may also
normal chatting. 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}") log.debug(f"{client.profile} is starting a MUJI group call with {entities}")
room_jid = self._muc.get_unique_name(client, prefix="_muji_") 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}") log.info(f"[{client.profile}] MUJI room created at {room_jid}")
if not room: if not room:
raise exceptions.ExternalRequestError("Can't create or join group chat room.") raise exceptions.ExternalRequestError("Can't create or join group chat room.")
await self.start_preparation(client, room) # Make the room non-anonymous as participants need to know the real JID of others
room.on_joined_callbacks.append(self.on_room_join) try:
room.on_left_callbacks.append(self.on_room_left) await self._muc.set_room_configuration(
for entity in entities: client, room_jid, {"muc#roomconfig_whois": "anyone"}
self._muc_invite.invite(
client,
entity,
room_jid,
reason="You have been invited to participate in a group call.",
) )
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()} 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) @implementer(itx_xmpp.IDisco)
class XEP_0272_handler(XMPPHandler): class XEP_0272_handler(XMPPHandler):

View file

@ -16,14 +16,19 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from pytest_twisted import ensureDeferred as ed from pytest_twisted import ensureDeferred as ed
from pytest import fixture from pytest import fixture
from libervia.backend.core import exceptions
from twisted.internet import defer from twisted.internet import defer
from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import jid
from twisted.words.xish import domish from twisted.words.xish import domish
from tx_xmpp import muc
from libervia.backend.plugins.plugin_xep_0272 import ( from libervia.backend.plugins.plugin_xep_0272 import (
NS_MUJI, NS_MUJI,
PRESENCE_MUJI, PRESENCE_MUJI,
@ -37,7 +42,7 @@ TEST_ROOM_JID = jid.JID("room@example.com/user")
@fixture @fixture
def xep_0272(host) -> XEP_0272: def xep_0272(host) -> XEP_0272:
"""Fixture for initializing XEP-0272 plugin.""" """XEP-0272 plugin with mocked dependencies."""
host.plugins = { host.plugins = {
"XEP-0045": MagicMock(), "XEP-0045": MagicMock(),
"XEP-0166": MagicMock(), "XEP-0166": MagicMock(),
@ -48,6 +53,20 @@ def xep_0272(host) -> XEP_0272:
class TestXEP0272: 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): def test_get_handler(self, host, client):
"""XEP_0272_handler is instantiated and returned.""" """XEP_0272_handler is instantiated and returned."""
xep_0272 = XEP_0272(host) xep_0272 = XEP_0272(host)
@ -56,56 +75,138 @@ class TestXEP0272:
assert handler.plugin_parent == xep_0272 assert handler.plugin_parent == xep_0272
def test_on_muji_request_preparing_state(self, host, client): 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) xep_0272 = XEP_0272(host)
presence_elt = domish.Element((None, "presence")) presence_elt = domish.Element((None, "presence"))
presence_elt["from"] = TEST_ROOM_JID.full() presence_elt["from"] = TEST_ROOM_JID.full()
muji_elt = presence_elt.addElement((NS_MUJI, "muji")) muji_elt = presence_elt.addElement((NS_MUJI, "muji"))
muji_elt.addElement("preparing") 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._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, "get_muji_data", return_value=muji_data):
with patch.object( with patch.object(
xep_0272, "try_to_finish_preparation" xep_0272, "try_to_finish_preparation"
) as mock_try_to_finish_preparation: ) as mock_try_to_finish_preparation:
xep_0272.on_muji_request(presence_elt, client) xep_0272.on_muji_request(presence_elt, client)
assert muji_data["done_collecting"] is True
mock_try_to_finish_preparation.assert_called_once() mock_try_to_finish_preparation.assert_called_once()
def test_on_muji_request_not_preparing_state(self, host, client): def test_on_muji_request_peer_data(self, host, client):
"""try_to_finish_preparation is not called when preparing_state is False.""" """The real JID of a data-advertising peer is added to to_call."""
xep_0272 = XEP_0272(host) xep_0272 = XEP_0272(host)
presence_elt = domish.Element((None, "presence")) 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")) 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()} room = MagicMock()
with patch.object(xep_0272, "get_muji_data", return_value=muji_data): room.roomJID = TEST_ROOM_JID.userhostJID()
with patch.object( muji_data = {"preparing_jids": set(), "to_call": set()}
xep_0272, "try_to_finish_preparation" with (
) as mock_try_to_finish_preparation: patch.object(xep_0272._muc, "get_room", return_value=room),
xep_0272.on_muji_request(presence_elt, client) patch.object(xep_0272._muc, "get_room_user_jid", return_value=TEST_ROOM_JID),
mock_try_to_finish_preparation.assert_not_called() 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): 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) xep_0272 = XEP_0272(host)
presence_elt = domish.Element((None, "presence")) presence_elt = domish.Element((None, "presence"))
presence_elt["from"] = "room@example.com/user" presence_elt["from"] = "room@example.com/user"
presence_elt.addElement((NS_MUJI, "muji")) presence_elt.addElement((NS_MUJI, "muji"))
client.jid = jid.JID("room@example.com/user") muji_data = {"preparing_jids": set(), "to_call": set()}
with patch.object( with (
xep_0272, "try_to_finish_preparation" patch.object(
) as mock_try_to_finish_preparation: 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) xep_0272.on_muji_request(presence_elt, client)
mock_try_to_finish_preparation.assert_not_called() mock_try_to_finish_preparation.assert_not_called()
def test_try_to_finish_preparation(self, host, client): 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) xep_0272 = XEP_0272(host)
room = MagicMock() 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 # signals are synchronous (see _Bridge.send_signal): use a plain mock, not the
# async one of the bridge fixture # 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(xep_0272, "get_muji_data", return_value=muji_data),
patch.object(host.bridge, "call_group_setup") as mock_call_group_setup, 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) xep_0272.try_to_finish_preparation(client, room, muji_data)
assert muji_data["done_preparing"] is True assert muji_data["done_preparing"] is True
mock_call_group_setup.assert_called_once() 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 @ed
async def test_call_group_data_set(self, host, client): async def test_call_group_data_set(self, host, client):
@ -130,10 +249,10 @@ class TestXEP0272:
return_value=(MagicMock(), MagicMock()), return_value=(MagicMock(), MagicMock()),
): ):
with patch.object( with patch.object(
client, "a_send", return_value=defer.succeed(None) client.presence, "send", return_value=defer.succeed(None)
) as mock_a_send: ) as mock_presence_send:
await xep_0272.call_group_data_set(client, room_jid, call_data) await xep_0272.call_group_data_set(client, room_jid, call_data)
mock_a_send.assert_called() mock_presence_send.assert_called()
@ed @ed
async def test_start_preparation(self, host, client): async def test_start_preparation(self, host, client):
@ -147,10 +266,10 @@ class TestXEP0272:
return_value=(MagicMock(), MagicMock()), return_value=(MagicMock(), MagicMock()),
): ):
with patch.object( with patch.object(
client, "a_send", return_value=defer.succeed(None) client.presence, "send", return_value=defer.succeed(None)
) as mock_a_send: ) as mock_presence_send:
await xep_0272.start_preparation(client, room) await xep_0272.start_preparation(client, room)
mock_a_send.assert_called() mock_presence_send.assert_called()
class TestXEP0272Handler: class TestXEP0272Handler: