comparison 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
comparison
equal deleted inserted replaced
4111:a8ac5e1e5848 4112:bc60875cb3b8
15 15
16 # You should have received a copy of the GNU Affero General Public License 16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. 17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 18
19 from typing import Optional 19 from typing import Optional
20 20 import uuid
21
22 from twisted.internet import reactor
21 from twisted.internet import defer 23 from twisted.internet import defer
22 from twisted.words.protocols.jabber import jid 24 from twisted.words.protocols.jabber import jid
23 from twisted.words.protocols.jabber.xmlstream import XMPPHandler 25 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
24 from twisted.words.xish import domish 26 from twisted.words.xish import domish
25 from wokkel import disco, iwokkel 27 from wokkel import disco, iwokkel
35 37
36 from . import mapping 38 from . import mapping
37 from ..plugin_xep_0166 import BaseApplicationHandler 39 from ..plugin_xep_0166 import BaseApplicationHandler
38 from .constants import ( 40 from .constants import (
39 NS_JINGLE_RTP, 41 NS_JINGLE_RTP,
42 NS_JINGLE_RTP_AUDIO,
40 NS_JINGLE_RTP_INFO, 43 NS_JINGLE_RTP_INFO,
41 NS_JINGLE_RTP_AUDIO,
42 NS_JINGLE_RTP_VIDEO, 44 NS_JINGLE_RTP_VIDEO,
43 ) 45 )
44 46
45 47
46 log = getLogger(__name__) 48 log = getLogger(__name__)
86 out_sign="s", 88 out_sign="s",
87 method=self._call_start, 89 method=self._call_start,
88 async_=True, 90 async_=True,
89 ) 91 )
90 host.bridge.add_method( 92 host.bridge.add_method(
93 "call_answer_sdp",
94 ".plugin",
95 in_sign="sss",
96 out_sign="",
97 method=self._call_answer_sdp,
98 async_=True,
99 )
100 host.bridge.add_method(
101 "call_info",
102 ".plugin",
103 in_sign="ssss",
104 out_sign="",
105 method=self._call_info,
106 )
107 host.bridge.add_method(
91 "call_end", 108 "call_end",
92 ".plugin", 109 ".plugin",
93 in_sign="sss", 110 in_sign="sss",
94 out_sign="", 111 out_sign="",
95 method=self._call_end, 112 method=self._call_end,
96 async_=True, 113 async_=True,
97 ) 114 )
98 host.bridge.add_method( 115
99 "call_info", 116 # args: session_id, serialised setup data (dict with keys "role" and "sdp"),
100 ".plugin", 117 # profile
101 in_sign="ssss",
102 out_sign="",
103 method=self._call_start,
104 )
105 host.bridge.add_signal( 118 host.bridge.add_signal(
106 "call_accepted", ".plugin", signature="sss" 119 "call_setup", ".plugin", signature="sss"
107 ) # args: session_id, answer_sdp, profile 120 )
121
122 # args: session_id, data, profile
108 host.bridge.add_signal( 123 host.bridge.add_signal(
109 "call_ended", ".plugin", signature="sss" 124 "call_ended", ".plugin", signature="sss"
110 ) # args: session_id, data, profile 125 )
126
127 # args: session_id, info_type, extra, profile
111 host.bridge.add_signal( 128 host.bridge.add_signal(
112 "call_info", ".plugin", signature="ssss" 129 "call_info", ".plugin", signature="ssss"
113 ) # args: session_id, info_type, extra, profile 130 )
114 131
115 def get_handler(self, client): 132 def get_handler(self, client):
116 return XEP_0167_handler() 133 return XEP_0167_handler()
117 134
118 # bridge methods 135 # bridge methods
133 async def call_start( 150 async def call_start(
134 self, 151 self,
135 client: SatXMPPEntity, 152 client: SatXMPPEntity,
136 peer_jid: jid.JID, 153 peer_jid: jid.JID,
137 call_data: dict, 154 call_data: dict,
138 ) -> None: 155 ) -> str:
139 """Temporary method to test RTP session""" 156 """Initiate a call session with the given peer.
157
158 @param peer_jid: JID of the peer to initiate a call session with.
159 @param call_data: Dictionary containing data for the call. Must include SDP information.
160 The dict can have the following keys:
161 - sdp (str): SDP data for the call.
162 - metadata (dict): Additional metadata for the call (optional).
163 Each media type ("audio" and "video") in the SDP should have:
164 - application_data (dict): Data about the media.
165 - fingerprint (str): Security fingerprint data (optional).
166 - id (str): Identifier for the media (optional).
167 - ice-candidates: ICE candidates for media transport.
168 - And other transport specific data.
169
170 @return: Session ID (SID) for the initiated call session.
171
172 @raises exceptions.DataError: If media data is invalid or duplicate content name
173 (mid) is found.
174 """
140 contents = [] 175 contents = []
141 metadata = call_data.get("metadata") or {} 176 metadata = call_data.get("metadata") or {}
142 177
143 if "sdp" in call_data: 178 if "sdp" in call_data:
144 sdp_data = mapping.parse_sdp(call_data["sdp"]) 179 sdp_data = mapping.parse_sdp(call_data["sdp"])
199 ) 234 )
200 content["name"] = name 235 content["name"] = name
201 contents.append(content) 236 contents.append(content)
202 if not contents: 237 if not contents:
203 raise exceptions.DataError("no valid media data found: {call_data}") 238 raise exceptions.DataError("no valid media data found: {call_data}")
204 return await self._j.initiate( 239 sid = str(uuid.uuid4())
205 client, 240 defer.ensureDeferred(
206 peer_jid, 241 self._j.initiate(
207 contents, 242 client,
208 call_type=call_type, 243 peer_jid,
209 metadata=metadata, 244 contents,
210 peer_metadata={}, 245 sid=sid,
211 ) 246 call_type=call_type,
247 metadata=metadata,
248 peer_metadata={},
249 )
250 )
251 return sid
252
253 def _call_answer_sdp(
254 self,
255 session_id: str,
256 answer_sdp: str,
257 profile: str
258 ) -> None:
259 client = self.host.get_client(profile)
260 session = self._j.get_session(client, session_id)
261 try:
262 answer_sdp_d = session.pop("answer_sdp_d")
263 except KeyError:
264 raise exceptions.NotFound(
265 f"No answer SDP expected for session {session_id!r}"
266 )
267 answer_sdp_d.callback(answer_sdp)
212 268
213 def _call_end( 269 def _call_end(
214 self, 270 self,
215 session_id: str, 271 session_id: str,
216 data_s: str, 272 data_s: str,
237 """ 293 """
238 session = self._j.get_session(client, session_id) 294 session = self._j.get_session(client, session_id)
239 await self._j.terminate(client, self._j.REASON_SUCCESS, session) 295 await self._j.terminate(client, self._j.REASON_SUCCESS, session)
240 296
241 # jingle callbacks 297 # jingle callbacks
298
299 async def confirm_incoming_call(
300 self,
301 client: SatXMPPEntity,
302 session: dict,
303 call_type: str
304 ) -> bool:
305 """Prompt the user for a call confirmation.
306
307 @param client: The client entity.
308 @param session: The Jingle session.
309 @param media_type: Type of media (audio or video).
310
311 @return: True if the call has been accepted
312 """
313 peer_jid = session["peer_jid"]
314
315 session["call_type"] = call_type
316 cancellable_deferred = session.setdefault("cancellable_deferred", [])
317
318 dialog_d = xml_tools.defer_dialog(
319 self.host,
320 _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type),
321 _(CONFIRM_TITLE),
322 action_extra={
323 "session_id": session["id"],
324 "from_jid": peer_jid.full(),
325 "type": C.META_TYPE_CALL,
326 "sub_type": call_type,
327 },
328 security_limit=SECURITY_LIMIT,
329 profile=client.profile,
330 )
331
332 cancellable_deferred.append(dialog_d)
333
334 resp_data = await dialog_d
335
336 accepted = not resp_data.get("cancelled", False)
337
338 if accepted:
339 session["call_accepted"] = True
340
341 return accepted
342
343 async def jingle_preflight(
344 self,
345 client: SatXMPPEntity,
346 session: dict,
347 description_elt: domish.Element
348 ) -> None:
349 """Perform preflight checks for an incoming call session.
350
351 Check if the calls is audio only or audio/video, then, prompts the user for
352 confirmation.
353
354 @param client: The client instance.
355 @param session: Jingle session.
356 @param description_elt: The description element. It's parent attribute is used to
357 determine check siblings to see if it's an audio only or audio/video call.
358
359 @raises exceptions.CancelError: If the user doesn't accept the incoming call.
360 """
361 if session.get("call_accepted", False):
362 # the call is already accepted, nothing to do
363 return
364
365 parent_elt = description_elt.parent
366 assert parent_elt is not None
367
368 assert description_elt.parent is not None
369 for desc_elt in parent_elt.elements(NS_JINGLE_RTP, "description"):
370 if desc_elt.getAttribute("media") == "video":
371 call_type = C.META_SUBTYPE_CALL_VIDEO
372 break
373 else:
374 call_type = C.META_SUBTYPE_CALL_AUDIO
375
376 try:
377 accepted = await self.confirm_incoming_call(client, session, call_type)
378 except defer.CancelledError as e:
379 # raised when call is retracted before user has answered or rejected
380 self.host.bridge.call_ended(
381 session["id"],
382 data_format.serialise({"reason": "retracted"}),
383 client.profile
384 )
385 raise e
386
387 if not accepted:
388 raise exceptions.CancelError("User declined the incoming call.")
389
390 async def jingle_preflight_cancel(
391 self,
392 client: SatXMPPEntity,
393 session: dict,
394 cancel_error: exceptions.CancelError
395 ) -> None:
396 """The call has been rejected"""
397 # call_ended is use to send the signal only once even if there are audio and video
398 # contents
399 call_ended = session.get("call_ended", False)
400 if call_ended:
401 return
402 data = {
403 "reason": getattr(cancel_error, "reason", "cancelled")
404 }
405 text = getattr(cancel_error, "text", None)
406 if text:
407 data["text"] = text
408 self.host.bridge.call_ended(
409 session["id"],
410 data_format.serialise(data),
411 client.profile
412 )
413 session["call_ended"] = True
414
242 415
243 def jingle_session_init( 416 def jingle_session_init(
244 self, 417 self,
245 client: SatXMPPEntity, 418 client: SatXMPPEntity,
246 session: dict, 419 session: dict,
273 action: str, 446 action: str,
274 session: dict, 447 session: dict,
275 content_name: str, 448 content_name: str,
276 desc_elt: domish.Element, 449 desc_elt: domish.Element,
277 ) -> bool: 450 ) -> bool:
451 """Requests confirmation from the user for a Jingle session's incoming call.
452
453 This method checks the content type of the Jingle session (audio or video)
454 based on the session's contents. Confirmation is requested only for the first
455 content; subsequent contents are automatically accepted. This means, in practice,
456 that the call confirmation is prompted only once for both audio and video contents.
457
458 @param client: The client instance.
459 @param action: The action type associated with the Jingle session.
460 @param session: Jingle session.
461 @param content_name: Name of the content being checked.
462 @param desc_elt: The description element associated with the content.
463
464 @return: True if the call is accepted by the user, False otherwise.
465 """
278 if content_name != next(iter(session["contents"])): 466 if content_name != next(iter(session["contents"])):
279 # we request confirmation only for the first content, all others are 467 # we request confirmation only for the first content, all others are
280 # automatically accepted. In practice, that means that the call confirmation 468 # automatically accepted. In practice, that means that the call confirmation
281 # is requested only once for audio and video contents. 469 # is requested only once for audio and video contents.
282 return True 470 return True
283 peer_jid = session["peer_jid"] 471
284 472 if not session.get("call_accepted", False):
285 if any( 473 if any(
286 c["desc_elt"].getAttribute("media") == "video" 474 c["desc_elt"].getAttribute("media") == "video"
287 for c in session["contents"].values() 475 for c in session["contents"].values()
288 ): 476 ):
289 call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO 477 call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO
290 else: 478 else:
291 call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO 479 call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO
480
481 accepted = await self.confirm_incoming_call(client, session, call_type)
482 if not accepted:
483 return False
292 484
293 sdp = mapping.generate_sdp_from_session(session) 485 sdp = mapping.generate_sdp_from_session(session)
294 486 session["answer_sdp_d"] = answer_sdp_d = defer.Deferred()
295 resp_data = await xml_tools.defer_dialog( 487 # we should have the answer long before 2 min
296 self.host, 488 answer_sdp_d.addTimeout(2 * 60, reactor)
297 _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type), 489
298 _(CONFIRM_TITLE), 490 self.host.bridge.call_setup(
299 action_extra={ 491 session["id"],
300 "session_id": session["id"], 492 data_format.serialise({
301 "from_jid": peer_jid.full(), 493 "role": session["role"],
302 "type": C.META_TYPE_CALL,
303 "sub_type": call_type,
304 "sdp": sdp, 494 "sdp": sdp,
305 }, 495 }),
306 security_limit=SECURITY_LIMIT, 496 client.profile
307 profile=client.profile, 497 )
308 ) 498
309 499 answer_sdp = await answer_sdp_d
310 if resp_data.get("cancelled", False): 500
311 return False
312
313 answer_sdp = resp_data["sdp"]
314 parsed_answer = mapping.parse_sdp(answer_sdp) 501 parsed_answer = mapping.parse_sdp(answer_sdp)
315 session["peer_metadata"].update(parsed_answer["metadata"]) 502 session["peer_metadata"].update(parsed_answer["metadata"])
316 for media in ("audio", "video"): 503 for media in ("audio", "video"):
317 for content in session["contents"].values(): 504 for content in session["contents"].values():
318 if content["desc_elt"].getAttribute("media") == media: 505 if content["desc_elt"].getAttribute("media") == media:
350 elif action == self._j.A_SESSION_ACCEPT: 537 elif action == self._j.A_SESSION_ACCEPT:
351 if content_name == next(iter(session["contents"])): 538 if content_name == next(iter(session["contents"])):
352 # we only send the signal for first content, as it means that the whole 539 # we only send the signal for first content, as it means that the whole
353 # session is accepted 540 # session is accepted
354 answer_sdp = mapping.generate_sdp_from_session(session) 541 answer_sdp = mapping.generate_sdp_from_session(session)
355 self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile) 542 self.host.bridge.call_setup(
543 session["id"],
544 data_format.serialise({
545 "role": session["role"],
546 "sdp": answer_sdp,
547 }),
548 client.profile
549 )
356 else: 550 else:
357 log.warning(f"FIXME: unmanaged action {action}") 551 log.warning(f"FIXME: unmanaged action {action}")
358 552
359 self.host.trigger.point( 553 self.host.trigger.point(
360 "XEP-0167_jingle_handler", 554 "XEP-0167_jingle_handler",
395 def _call_info(self, session_id, info_type, extra_s, profile_key): 589 def _call_info(self, session_id, info_type, extra_s, profile_key):
396 client = self.host.get_client(profile_key) 590 client = self.host.get_client(profile_key)
397 extra = data_format.deserialise(extra_s) 591 extra = data_format.deserialise(extra_s)
398 return self.send_info(client, session_id, info_type, extra) 592 return self.send_info(client, session_id, info_type, extra)
399 593
400
401 def send_info( 594 def send_info(
402 self, 595 self,
403 client: SatXMPPEntity, 596 client: SatXMPPEntity,
404 session_id: str, 597 session_id: str,
405 info_type: str, 598 info_type: str,
421 action: str, 614 action: str,
422 session: dict, 615 session: dict,
423 content_name: str, 616 content_name: str,
424 reason_elt: domish.Element, 617 reason_elt: domish.Element,
425 ) -> None: 618 ) -> None:
426 self.host.bridge.call_ended(session["id"], "", client.profile) 619 reason, text = self._j.parse_reason_elt(reason_elt)
620 data = {
621 "reason": reason
622 }
623 if text:
624 data["text"] = text
625 self.host.bridge.call_ended(
626 session["id"], data_format.serialise(data), client.profile
627 )
427 628
428 629
429 @implementer(iwokkel.IDisco) 630 @implementer(iwokkel.IDisco)
430 class XEP_0167_handler(XMPPHandler): 631 class XEP_0167_handler(XMPPHandler):
431 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): 632 def getDiscoInfo(self, requestor, target, nodeIdentifier=""):