- 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`)
473 lines
19 KiB
Python
473 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# 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/>.
|
|
|
|
from typing import cast
|
|
from twisted.internet import defer
|
|
from twisted.words.protocols.jabber import jid
|
|
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
|
from twisted.words.xish import domish
|
|
from tx_xmpp import disco, itx_xmpp
|
|
from tx_xmpp import muc
|
|
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 D_, _
|
|
from libervia.backend.core.log import getLogger
|
|
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
|
|
|
|
log = getLogger(__name__)
|
|
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "Multiparty Jingle (Muji)",
|
|
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-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 too big to make it work correctly."
|
|
),
|
|
}
|
|
|
|
NS_MUJI = "urn:xmpp:jingle:muji:0"
|
|
PRESENCE_MUJI = f'/presence/muji[@xmlns="{NS_MUJI}"]'
|
|
|
|
|
|
class XEP_0272:
|
|
|
|
def __init__(self, host):
|
|
log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization')
|
|
self.host = host
|
|
host.register_namespace("muji", NS_MUJI)
|
|
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",
|
|
in_sign="asss",
|
|
out_sign="s",
|
|
method=self._call_group_start,
|
|
async_=True,
|
|
)
|
|
host.bridge.add_method(
|
|
"call_group_data_set",
|
|
".plugin",
|
|
in_sign="sss",
|
|
out_sign="",
|
|
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):
|
|
return XEP_0272_handler(self)
|
|
|
|
def _call_group_start(
|
|
self,
|
|
entities_s: str,
|
|
extra_s: str,
|
|
profile_key: str,
|
|
) -> defer.Deferred[str]:
|
|
client = self.host.get_client(profile_key)
|
|
d = defer.ensureDeferred(
|
|
self.call_group_start(
|
|
client, [jid.JID(e) for e in entities_s], data_format.deserialise(extra_s)
|
|
)
|
|
)
|
|
d.addCallback(data_format.serialise)
|
|
return d
|
|
|
|
def _call_group_data_set(
|
|
self,
|
|
room_jid_s: str,
|
|
call_data_s: str,
|
|
profile_key: str,
|
|
) -> None:
|
|
client = self.host.get_client(profile_key)
|
|
defer.ensureDeferred(
|
|
self.call_group_data_set(
|
|
client, jid.JID(room_jid_s), data_format.deserialise(call_data_s)
|
|
)
|
|
)
|
|
|
|
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 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:
|
|
room = self._muc.get_room(client, room_jid)
|
|
except exceptions.NotFound:
|
|
log.warning(
|
|
f"Ignoring MUJI element from an unknown room: {presence_elt.toXml()}"
|
|
)
|
|
return
|
|
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
|
|
preparing_state = next(muji_elt.elements(NS_MUJI, "preparing"), None) is not None
|
|
|
|
if preparing_state:
|
|
if own_jid:
|
|
# we have received the broadcast of our own preparation message
|
|
muji_data["done_collecting"] = True
|
|
self.try_to_finish_preparation(client, room, muji_data)
|
|
elif not muji_data.get("done_collecting", False):
|
|
# other entities currently doing preparation
|
|
muji_data["preparing_jids"].add(from_jid)
|
|
elif not own_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:
|
|
"""Finish preparation if possible.
|
|
|
|
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:
|
|
muji_data = self.get_muji_data(room)
|
|
muji_data["done_preparing"] = True
|
|
log.debug(f"[{client.profile}] Done preparing.")
|
|
|
|
# ask the frontend to advertise our data and to initiate the sessions
|
|
self.host.bridge.call_group_setup(
|
|
room.roomJID.full(),
|
|
data_format.serialise(
|
|
{"to_call": [entity.full() for entity in muji_data["to_call"]]}
|
|
),
|
|
client.profile,
|
|
)
|
|
|
|
async def call_group_data_set(
|
|
self,
|
|
client: SatXMPPEntity,
|
|
room_jid: jid.JID,
|
|
call_data: dict,
|
|
) -> None:
|
|
"""Advertise the group call data on the MUC.
|
|
|
|
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.
|
|
@param call_data: call data similar to the one used in ``XEP-0167.call_start``.
|
|
"""
|
|
try:
|
|
room = self._muc.get_room(client, room_jid)
|
|
except exceptions.NotFound:
|
|
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)
|
|
for media_type, media_data in sdp_data.items():
|
|
if media_type in ["audio", "video"]:
|
|
application_data = media_data["application_data"]
|
|
content_elt = muji_elt.addElement("content")
|
|
# XXX: the initiator will be actually the last to join, but this attribute
|
|
# will be ignored anyway.
|
|
content_elt["creator"] = self._j.ROLE_INITIATOR
|
|
content_elt["name"] = media_data["id"]
|
|
description_elt = mapping.build_description(
|
|
media_type, application_data, {}
|
|
)
|
|
content_elt.addChild(description_elt)
|
|
|
|
# we only want to keep payload types
|
|
to_remove = []
|
|
for child_elt in description_elt.children:
|
|
if child_elt.name != "payload-type":
|
|
to_remove.append(child_elt)
|
|
for elt in to_remove:
|
|
description_elt.children.remove(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:
|
|
"""Get MUJI related data for this room
|
|
|
|
MUJI data is stored in the room object, so it will be deleted when the room object
|
|
itself will be deleted.
|
|
"""
|
|
try:
|
|
return room._xep_0272_data
|
|
except AttributeError:
|
|
data = room._xep_0272_data = {"preparing_jids": set(), "to_call": set()}
|
|
return data
|
|
|
|
def generate_presence_and_muji(
|
|
self, client: SatXMPPEntity, room: muc.Room
|
|
) -> tuple[domish.Element, domish.Element]:
|
|
"""Generate a <presence> stanza with MUJI element"""
|
|
presence_elt = domish.Element((None, "presence"))
|
|
presence_elt["from"] = client.jid.full()
|
|
presence_elt["to"] = room.roomJID.full()
|
|
muji_elt = presence_elt.addElement((NS_MUJI, "muji"))
|
|
return presence_elt, muji_elt
|
|
|
|
async def start_preparation(self, client: SatXMPPEntity, room: muc.Room) -> None:
|
|
"""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.presence.send(presence_elt)
|
|
|
|
async def call_group_start(
|
|
self,
|
|
client: SatXMPPEntity,
|
|
entities: list[jid.JID],
|
|
extra: dict,
|
|
) -> dict:
|
|
"""Initiate a group call with the given peers.
|
|
|
|
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``
|
|
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, 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_")
|
|
room = await self._muc.join(client, room_jid)
|
|
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.")
|
|
# 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):
|
|
|
|
def __init__(self, plugin_parent):
|
|
self.plugin_parent = plugin_parent
|
|
|
|
def connectionInitialized(self):
|
|
self.xmlstream.addObserver(
|
|
PRESENCE_MUJI, self.plugin_parent.on_muji_request, client=self.parent
|
|
)
|
|
|
|
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
|
|
return [disco.DiscoFeature(NS_MUJI)]
|
|
|
|
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
|
|
return []
|