libervia-backend/libervia/backend/plugins/plugin_xep_0353.py
Goffi fd58b78762 core, dependencies: update Tx-XMPP to v0.13.0:
`DiscoFeature.identities` format has evolved so `(category, map)` can be checked directly
again. The code using identities has been updated.
2026-04-21 12:49:30 +02:00

489 lines
19 KiB
Python

#!/usr/bin/env python3
# Libervia plugin for Jingle Message Initiation (XEP-0353)
# 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.internet import reactor
from twisted.words.protocols.jabber import error, jid
from twisted.words.protocols.jabber import xmlstream
from twisted.words.xish import domish
from libervia.backend.plugins.plugin_xep_0166.models import ApplicationData
from tx_xmpp import disco, itx_xmpp
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.tools.xml_tools import element_copy
try:
from .plugin_xep_0167 import NS_JINGLE_RTP
except ImportError:
NS_JINGLE_RTP = None
log = getLogger(__name__)
NS_JINGLE_MESSAGE = "urn:xmpp:jingle-message:0"
PLUGIN_INFO = {
C.PI_NAME: "Jingle Message Initiation",
C.PI_IMPORT_NAME: "XEP-0353",
C.PI_TYPE: "XEP",
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_PROTOCOLS: ["XEP-0353"],
C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0334"],
C.PI_MAIN: "XEP_0353",
C.PI_HANDLER: "yes",
C.PI_DESCRIPTION: _("""Implementation of Jingle Message Initiation"""),
}
class RejectException(exceptions.CancelError):
def __init__(self, reason: str, text: str | None = None):
super().__init__(text)
self.reason = reason
class TakenByOtherDeviceException(exceptions.CancelError):
reason: str = "taken_by_other_device"
def __init__(self, device_jid: jid.JID):
super().__init__(device_jid.full())
self.device_jid = device_jid
class RetractException(exceptions.CancelError):
pass
class XEP_0353:
def __init__(self, host):
log.info(_("plugin {name} initialization").format(name=PLUGIN_INFO[C.PI_NAME]))
self.host = host
host.register_namespace("jingle-message", NS_JINGLE_MESSAGE)
self._j = host.plugins["XEP-0166"]
self._h = host.plugins["XEP-0334"]
host.trigger.add_with_check(
"XEP-0166_initiate_elt_built",
self,
self._on_initiate_trigger,
# this plugin set the resource, we want it to happen first so other triggers
# can get the full peer JID
priority=host.trigger.MAX_PRIORITY,
)
host.trigger.add_with_check(
"XEP-0166_terminate",
self,
self._terminate_trigger,
priority=host.trigger.MAX_PRIORITY,
)
host.trigger.add("message_received", self._on_message_received)
def get_handler(self, client):
return Handler()
def profile_connecting(self, client):
# mapping from session id to deferred used to wait for destinee answer
client._xep_0353_pending_sessions = {}
def build_message_data(self, client, peer_jid, verb, session_id):
mess_data = {
"from": client.jid,
"to": peer_jid,
"uid": "",
"message": {},
"type": C.MESS_TYPE_CHAT,
"subject": {},
"extra": {},
}
client.generate_message_xml(mess_data)
message_elt = mess_data["xml"]
verb_elt = message_elt.addElement((NS_JINGLE_MESSAGE, verb))
verb_elt["id"] = session_id
self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
return mess_data
async def _on_initiate_trigger(
self,
client: SatXMPPEntity,
session: dict,
iq_elt: domish.Element,
jingle_elt: domish.Element,
) -> bool:
peer_jid = session["peer_jid"]
if peer_jid.resource:
return True
try:
infos = await self.host.memory.disco.get_infos(client, peer_jid)
except error.StanzaError as e:
if e.condition == "service-unavailable":
categories = {}
else:
raise e
else:
categories = {c for c, __ in infos.identities}
if "component" in categories:
# we don't use message initiation with components
return True
if peer_jid.userhostJID() not in client.roster:
# if the contact is not in our roster, we need to send a directed presence
# according to XEP-0353 §3.1
await client.presence.available(peer_jid)
mess_data = self.build_message_data(client, peer_jid, "propose", session["id"])
message_elt = mess_data["xml"]
for content_data in session["contents"].values():
# we get the full element build by the application plugin
jingle_description_elt = content_data["application_data"]["desc_elt"]
# we need to copy the element
if jingle_description_elt.uri == NS_JINGLE_RTP:
# for RTP, we only keep the root <description> element, no children
description_elt = domish.Element(
(jingle_description_elt.uri, jingle_description_elt.name),
defaultUri=jingle_description_elt.defaultUri,
attribs=jingle_description_elt.attributes,
localPrefixes=jingle_description_elt.localPrefixes,
)
else:
# Otherwise we keep the children to have application useful data
description_elt = element_copy(jingle_description_elt, with_parent=False)
message_elt.propose.addChild(description_elt)
response_d = defer.Deferred()
# we wait for 2 min before cancelling the session init
# FIXME: let's application decide timeout?
response_d.addTimeout(2 * 60, reactor)
client._xep_0353_pending_sessions[session["id"]] = response_d
try:
await client.send_message_data(mess_data)
accepting_jid = await response_d
except defer.TimeoutError:
log.warning(
_("Message initiation with {peer_jid} timed out").format(
peer_jid=peer_jid
)
)
except exceptions.CancelError as e:
for content in session["contents"].values():
await content["application"].handler.jingle_preflight_cancel(
client, session, e
)
self._j.delete_session(client, session["id"])
return False
else:
if iq_elt["to"] != accepting_jid.userhost():
raise exceptions.InternalError(
f"<jingle> 'to' attribute ({iq_elt['to']!r}) must not differ "
f"from bare JID of the accepting entity ({accepting_jid!r}), this "
"may be a sign of an internal bug, a hack attempt, or a MITM attack!"
)
iq_elt["to"] = accepting_jid.full()
session["peer_jid"] = accepting_jid
finally:
del client._xep_0353_pending_sessions[session["id"]]
return True
def _terminate_trigger(
self, client: SatXMPPEntity, session: dict, reason_elt: domish.Element
) -> bool:
session_id = session["id"]
try:
response_d = client._xep_0353_pending_sessions[session_id]
except KeyError:
return True
# we have a XEP-0353 session, that means that we are retracting a proposed session
mess_data = self.build_message_data(
client, session["peer_jid"], "retract", session_id
)
defer.ensureDeferred(client.send_message_data(mess_data))
response_d.errback(RetractException())
return False
async def _on_message_received(self, client, message_elt, post_treat):
for elt in message_elt.elements():
if elt.uri == NS_JINGLE_MESSAGE:
# We use ensureDeferred to process the message initiation workflow in
# parallel and to avoid blocking the message queue.
defer.ensureDeferred(self._handle_mess_init(client, message_elt, elt))
return False
return True
async def _handle_mess_init(
self,
client: SatXMPPEntity,
message_elt: domish.Element,
mess_init_elt: domish.Element,
) -> None:
if mess_init_elt.name == "propose":
await self._handle_propose(client, message_elt, mess_init_elt)
elif mess_init_elt.name == "retract":
self._handle_retract(client, message_elt, mess_init_elt)
elif mess_init_elt.name == "proceed":
self._handle_proceed(client, message_elt, mess_init_elt)
elif mess_init_elt.name == "accept":
self._handle_accept(client, message_elt, mess_init_elt)
elif mess_init_elt.name == "reject":
self._handle_reject(client, message_elt, mess_init_elt)
elif mess_init_elt.name == "ringing":
await self._handle_ringing(client, message_elt, mess_init_elt)
else:
log.warning(f"invalid element: {mess_init_elt.toXml}")
def _get_sid_and_session_d(
self, client: SatXMPPEntity, elt: domish.Element
) -> tuple[str, defer.Deferred | list[defer.Deferred]]:
"""Retrieve session ID and deferred or list of deferred from response element"""
try:
session_id = elt["id"]
except KeyError as e:
assert elt.parent is not None
log.warning(f"invalid proceed element in message_elt: {elt.parent.toXml()}")
raise e
try:
session_d = client._xep_0353_pending_sessions[session_id]
except KeyError as e:
log.warning(
_(
"no pending session found with id {session_id}, did it timed out?"
).format(session_id=session_id)
)
raise e
return session_id, session_d
def _get_sid_and_response_d(
self, client: SatXMPPEntity, elt: domish.Element
) -> tuple[str, defer.Deferred]:
"""Retrieve session ID and response_d from response element"""
session_id, response_d = self._get_sid_and_session_d(client, elt)
assert isinstance(response_d, defer.Deferred)
return session_id, response_d
def _get_sid_and_preflight_d_list(
self, client: SatXMPPEntity, elt: domish.Element
) -> tuple[str, list[defer.Deferred]]:
"""Retrieve session ID and list of preflight_d from response element"""
session_id, preflight_d_list = self._get_sid_and_session_d(client, elt)
assert isinstance(preflight_d_list, list)
return session_id, preflight_d_list
async def _handle_propose(
self, client: SatXMPPEntity, message_elt: domish.Element, elt: domish.Element
) -> None:
peer_jid = jid.JID(message_elt["from"])
local_jid = jid.JID(message_elt["to"])
session_id = elt["id"]
try:
desc_and_apps = [
(description_elt, self._j.get_application(description_elt.uri))
for description_elt in elt.elements()
if description_elt.name == "description"
]
if not desc_and_apps:
raise AttributeError
except AttributeError:
log.warning(f"Invalid propose element: {message_elt.toXml()}")
return
except exceptions.NotFound:
log.warning(
f"There is not registered application to handle this "
f"proposal: {elt.toXml()}"
)
return
if not desc_and_apps:
log.warning("No application specified: {message_elt.toXml()}")
return
cast(list[tuple[domish.Element, ApplicationData]], desc_and_apps)
desc_and_apps.sort(
key=lambda desc_and_app: desc_and_app[1].priority, reverse=True
)
session = self._j.create_session(
client, session_id, self._j.ROLE_RESPONDER, peer_jid, local_jid
)
is_in_roster = peer_jid.userhostJID() in client.roster
if is_in_roster:
# we indicate that device is ringing as explained in
# https://xmpp.org/extensions/xep-0353.html#ring , but we only do that if user
# is in roster to avoid presence leak of all our devices.
mess_data = self.build_message_data(client, peer_jid, "ringing", session_id)
await client.send_message_data(mess_data)
try:
for description_elt, application in desc_and_apps:
try:
preflight_d = defer.ensureDeferred(
application.handler.jingle_preflight(
client, session, description_elt
)
)
client._xep_0353_pending_sessions.setdefault(session_id, []).append(
preflight_d
)
await preflight_d
except TakenByOtherDeviceException as e:
log.info(f"The call has been takend by {e.device_jid}")
await application.handler.jingle_preflight_cancel(client, session, e)
self._j.delete_session(client, session_id)
return
except exceptions.CancelError as e:
log.info(f"{client.profile} refused the session: {e}")
if is_in_roster:
# peer is in our roster, we send reject to them, ou other devices
# will get carbon copies
reject_dest_jid = peer_jid
else:
# peer is not in our roster, we send the "reject" only to our own
# devices to make them stop ringing/doing notification, and we
# don't send anything to peer to avoid presence leak.
reject_dest_jid = client.jid.userhostJID()
mess_data = self.build_message_data(
client, reject_dest_jid, "reject", session_id
)
await client.send_message_data(mess_data)
self._j.delete_session(client, session_id)
return
except defer.CancelledError:
# raised when call is retracted before user can reply
self._j.delete_session(client, session_id)
return
finally:
try:
del client._xep_0353_pending_sessions[session_id]
except KeyError:
pass
if peer_jid.userhostJID() not in client.roster:
await client.presence.available(peer_jid)
mess_data = self.build_message_data(client, peer_jid, "proceed", session_id)
await client.send_message_data(mess_data)
def _handle_retract(self, client, message_elt, retract_elt):
try:
session = self._j.get_session(client, retract_elt["id"])
except KeyError:
log.warning(f"invalid retract element: {message_elt.toXml()}")
return False
except exceptions.NotFound:
log.warning(f"no session found with ID {retract_elt['id']}")
return False
log.debug(
f"{message_elt['from']} are retracting their proposal {retract_elt['id']}"
)
try:
cancellable_deferred = session["cancellable_deferred"]
if not cancellable_deferred:
raise KeyError
except KeyError:
self._j.delete_session(client, session["id"])
else:
for d in cancellable_deferred:
d.cancel()
return False
def _handle_proceed(
self,
client: SatXMPPEntity,
message_elt: domish.Element,
proceed_elt: domish.Element,
) -> None:
from_jid = jid.JID(message_elt["from"])
# session_d is the deferred of the session, it can be preflight_d or response_d
if from_jid.userhostJID() == client.jid.userhostJID():
# an other device took the session
try:
sid, preflight_d_list = self._get_sid_and_preflight_d_list(
client, proceed_elt
)
except KeyError:
return
for preflight_d in preflight_d_list:
if not preflight_d.called:
preflight_d.errback(TakenByOtherDeviceException(from_jid))
try:
session = self._j.get_session(client, sid)
except exceptions.NotFound:
log.warning("No session found with sid {sid!r}.")
else:
# jingle_preflight_cancel?
pass
# FIXME: Is preflight cancel handler correctly? Check if preflight_d is always
# cleaned correctly (use a timeout?)
else:
try:
__, response_d = self._get_sid_and_response_d(client, proceed_elt)
except KeyError:
return
# we have a response deferred
response_d.callback(jid.JID(message_elt["from"]))
def _handle_accept(self, client, message_elt, accept_elt):
pass
def _handle_reject(self, client, message_elt, reject_elt):
try:
__, response_d = self._get_sid_and_response_d(client, reject_elt)
except KeyError:
return True
reason_elt = self._j.get_reason_elt(reject_elt)
reason, text = self._j.parse_reason_elt(reason_elt)
if reason is None:
reason = "busy"
response_d.errback(RejectException(reason, text))
return False
async def _handle_ringing(self, client, message_elt, ringing_elt):
session_id = ringing_elt["id"]
try:
session = self._j.get_session(client, session_id)
except exceptions.NotFound:
log.warning(f"Session {session_id!r} unknown, ignoring ringing.")
return False
for __, content_data in session["contents"].items():
await content_data["application"].handler.jingle_preflight_info(
client, session, "ringing", None
)
return False
@implementer(itx_xmpp.IDisco)
class Handler(xmlstream.XMPPHandler):
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
return [disco.DiscoFeature(NS_JINGLE_MESSAGE)]
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
return []