Mercurial > libervia-backend
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=""): |