Mercurial > libervia-backend
diff libervia/backend/plugins/plugin_xep_0167/__init__.py @ 4112:bc60875cb3b8
plugin XEP-0166, XEP-0167, XEP-0234, XEP-0353: call events management to prepare for UI:
- XEP-0166: add `jingle_preflight` and `jingle_preflight_cancel` methods to prepare a
jingle session, principally used by XEP-0353 to create and cancel a session
- XEP-0167: preflight methods implementation, workflow split in more methods/signals to
handle UI and call events (e.g.: retract or reject a call)
- XEP-0234: implementation of preflight methods as they are now mandatory
- XEP-0353: handle various events using the new preflight methods
rel 423
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 09 Aug 2023 00:07:37 +0200 |
parents | 4b842c1fb686 |
children | 0da563780ffc |
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_xep_0167/__init__.py Tue Aug 08 23:59:24 2023 +0200 +++ b/libervia/backend/plugins/plugin_xep_0167/__init__.py Wed Aug 09 00:07:37 2023 +0200 @@ -17,7 +17,9 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from typing import Optional +import uuid +from twisted.internet import reactor from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber.xmlstream import XMPPHandler @@ -37,8 +39,8 @@ from ..plugin_xep_0166 import BaseApplicationHandler from .constants import ( NS_JINGLE_RTP, + NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_INFO, - NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_VIDEO, ) @@ -88,11 +90,11 @@ async_=True, ) host.bridge.add_method( - "call_end", + "call_answer_sdp", ".plugin", in_sign="sss", out_sign="", - method=self._call_end, + method=self._call_answer_sdp, async_=True, ) host.bridge.add_method( @@ -100,17 +102,32 @@ ".plugin", in_sign="ssss", out_sign="", - method=self._call_start, + method=self._call_info, ) + host.bridge.add_method( + "call_end", + ".plugin", + in_sign="sss", + out_sign="", + method=self._call_end, + async_=True, + ) + + # args: session_id, serialised setup data (dict with keys "role" and "sdp"), + # profile host.bridge.add_signal( - "call_accepted", ".plugin", signature="sss" - ) # args: session_id, answer_sdp, profile + "call_setup", ".plugin", signature="sss" + ) + + # args: session_id, data, profile host.bridge.add_signal( "call_ended", ".plugin", signature="sss" - ) # args: session_id, data, profile + ) + + # args: session_id, info_type, extra, profile host.bridge.add_signal( "call_info", ".plugin", signature="ssss" - ) # args: session_id, info_type, extra, profile + ) def get_handler(self, client): return XEP_0167_handler() @@ -135,8 +152,26 @@ client: SatXMPPEntity, peer_jid: jid.JID, call_data: dict, - ) -> None: - """Temporary method to test RTP session""" + ) -> str: + """Initiate a call session with the given peer. + + @param peer_jid: JID of the peer to initiate a call session with. + @param call_data: Dictionary containing data for the call. Must include SDP information. + The dict can have the following keys: + - sdp (str): SDP data for the call. + - metadata (dict): Additional metadata for the call (optional). + Each media type ("audio" and "video") in the SDP should have: + - application_data (dict): Data about the media. + - fingerprint (str): Security fingerprint data (optional). + - id (str): Identifier for the media (optional). + - ice-candidates: ICE candidates for media transport. + - And other transport specific data. + + @return: Session ID (SID) for the initiated call session. + + @raises exceptions.DataError: If media data is invalid or duplicate content name + (mid) is found. + """ contents = [] metadata = call_data.get("metadata") or {} @@ -201,14 +236,35 @@ contents.append(content) if not contents: raise exceptions.DataError("no valid media data found: {call_data}") - return await self._j.initiate( - client, - peer_jid, - contents, - call_type=call_type, - metadata=metadata, - peer_metadata={}, + sid = str(uuid.uuid4()) + defer.ensureDeferred( + self._j.initiate( + client, + peer_jid, + contents, + sid=sid, + call_type=call_type, + metadata=metadata, + peer_metadata={}, + ) ) + return sid + + def _call_answer_sdp( + self, + session_id: str, + answer_sdp: str, + profile: str + ) -> None: + client = self.host.get_client(profile) + session = self._j.get_session(client, session_id) + try: + answer_sdp_d = session.pop("answer_sdp_d") + except KeyError: + raise exceptions.NotFound( + f"No answer SDP expected for session {session_id!r}" + ) + answer_sdp_d.callback(answer_sdp) def _call_end( self, @@ -240,6 +296,123 @@ # jingle callbacks + async def confirm_incoming_call( + self, + client: SatXMPPEntity, + session: dict, + call_type: str + ) -> bool: + """Prompt the user for a call confirmation. + + @param client: The client entity. + @param session: The Jingle session. + @param media_type: Type of media (audio or video). + + @return: True if the call has been accepted + """ + peer_jid = session["peer_jid"] + + session["call_type"] = call_type + cancellable_deferred = session.setdefault("cancellable_deferred", []) + + dialog_d = xml_tools.defer_dialog( + self.host, + _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type), + _(CONFIRM_TITLE), + action_extra={ + "session_id": session["id"], + "from_jid": peer_jid.full(), + "type": C.META_TYPE_CALL, + "sub_type": call_type, + }, + security_limit=SECURITY_LIMIT, + profile=client.profile, + ) + + cancellable_deferred.append(dialog_d) + + resp_data = await dialog_d + + accepted = not resp_data.get("cancelled", False) + + if accepted: + session["call_accepted"] = True + + return accepted + + async def jingle_preflight( + self, + client: SatXMPPEntity, + session: dict, + description_elt: domish.Element + ) -> None: + """Perform preflight checks for an incoming call session. + + Check if the calls is audio only or audio/video, then, prompts the user for + confirmation. + + @param client: The client instance. + @param session: Jingle session. + @param description_elt: The description element. It's parent attribute is used to + determine check siblings to see if it's an audio only or audio/video call. + + @raises exceptions.CancelError: If the user doesn't accept the incoming call. + """ + if session.get("call_accepted", False): + # the call is already accepted, nothing to do + return + + parent_elt = description_elt.parent + assert parent_elt is not None + + assert description_elt.parent is not None + for desc_elt in parent_elt.elements(NS_JINGLE_RTP, "description"): + if desc_elt.getAttribute("media") == "video": + call_type = C.META_SUBTYPE_CALL_VIDEO + break + else: + call_type = C.META_SUBTYPE_CALL_AUDIO + + try: + accepted = await self.confirm_incoming_call(client, session, call_type) + except defer.CancelledError as e: + # raised when call is retracted before user has answered or rejected + self.host.bridge.call_ended( + session["id"], + data_format.serialise({"reason": "retracted"}), + client.profile + ) + raise e + + if not accepted: + raise exceptions.CancelError("User declined the incoming call.") + + async def jingle_preflight_cancel( + self, + client: SatXMPPEntity, + session: dict, + cancel_error: exceptions.CancelError + ) -> None: + """The call has been rejected""" + # call_ended is use to send the signal only once even if there are audio and video + # contents + call_ended = session.get("call_ended", False) + if call_ended: + return + data = { + "reason": getattr(cancel_error, "reason", "cancelled") + } + text = getattr(cancel_error, "text", None) + if text: + data["text"] = text + self.host.bridge.call_ended( + session["id"], + data_format.serialise(data), + client.profile + ) + session["call_ended"] = True + + def jingle_session_init( self, client: SatXMPPEntity, @@ -275,42 +448,56 @@ content_name: str, desc_elt: domish.Element, ) -> bool: + """Requests confirmation from the user for a Jingle session's incoming call. + + This method checks the content type of the Jingle session (audio or video) + based on the session's contents. Confirmation is requested only for the first + content; subsequent contents are automatically accepted. This means, in practice, + that the call confirmation is prompted only once for both audio and video contents. + + @param client: The client instance. + @param action: The action type associated with the Jingle session. + @param session: Jingle session. + @param content_name: Name of the content being checked. + @param desc_elt: The description element associated with the content. + + @return: True if the call is accepted by the user, False otherwise. + """ if content_name != next(iter(session["contents"])): # we request confirmation only for the first content, all others are # automatically accepted. In practice, that means that the call confirmation # is requested only once for audio and video contents. return True - peer_jid = session["peer_jid"] - if any( - c["desc_elt"].getAttribute("media") == "video" - for c in session["contents"].values() - ): - call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO - else: - call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO + if not session.get("call_accepted", False): + if any( + c["desc_elt"].getAttribute("media") == "video" + for c in session["contents"].values() + ): + call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO + else: + call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO + + accepted = await self.confirm_incoming_call(client, session, call_type) + if not accepted: + return False sdp = mapping.generate_sdp_from_session(session) + session["answer_sdp_d"] = answer_sdp_d = defer.Deferred() + # we should have the answer long before 2 min + answer_sdp_d.addTimeout(2 * 60, reactor) - resp_data = await xml_tools.defer_dialog( - self.host, - _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type), - _(CONFIRM_TITLE), - action_extra={ - "session_id": session["id"], - "from_jid": peer_jid.full(), - "type": C.META_TYPE_CALL, - "sub_type": call_type, + self.host.bridge.call_setup( + session["id"], + data_format.serialise({ + "role": session["role"], "sdp": sdp, - }, - security_limit=SECURITY_LIMIT, - profile=client.profile, + }), + client.profile ) - if resp_data.get("cancelled", False): - return False + answer_sdp = await answer_sdp_d - answer_sdp = resp_data["sdp"] parsed_answer = mapping.parse_sdp(answer_sdp) session["peer_metadata"].update(parsed_answer["metadata"]) for media in ("audio", "video"): @@ -352,7 +539,14 @@ # we only send the signal for first content, as it means that the whole # session is accepted answer_sdp = mapping.generate_sdp_from_session(session) - self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile) + self.host.bridge.call_setup( + session["id"], + data_format.serialise({ + "role": session["role"], + "sdp": answer_sdp, + }), + client.profile + ) else: log.warning(f"FIXME: unmanaged action {action}") @@ -397,7 +591,6 @@ extra = data_format.deserialise(extra_s) return self.send_info(client, session_id, info_type, extra) - def send_info( self, client: SatXMPPEntity, @@ -423,7 +616,15 @@ content_name: str, reason_elt: domish.Element, ) -> None: - self.host.bridge.call_ended(session["id"], "", client.profile) + reason, text = self._j.parse_reason_elt(reason_elt) + data = { + "reason": reason + } + if text: + data["text"] = text + self.host.bridge.call_ended( + session["id"], data_format.serialise(data), client.profile + ) @implementer(iwokkel.IDisco)