Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0167/__init__.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_xep_0167/__init__.py@d10748475025 |
children | bc60875cb3b8 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia: an XMPP client | |
4 # Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
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/>. | |
18 | |
19 from typing import Optional | |
20 | |
21 from twisted.internet import defer | |
22 from twisted.words.protocols.jabber import jid | |
23 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
24 from twisted.words.xish import domish | |
25 from wokkel import disco, iwokkel | |
26 from zope.interface import implementer | |
27 | |
28 from libervia.backend.core import exceptions | |
29 from libervia.backend.core.constants import Const as C | |
30 from libervia.backend.core.core_types import SatXMPPEntity | |
31 from libervia.backend.core.i18n import D_, _ | |
32 from libervia.backend.core.log import getLogger | |
33 from libervia.backend.tools import xml_tools | |
34 from libervia.backend.tools.common import data_format | |
35 | |
36 from . import mapping | |
37 from ..plugin_xep_0166 import BaseApplicationHandler | |
38 from .constants import ( | |
39 NS_JINGLE_RTP, | |
40 NS_JINGLE_RTP_INFO, | |
41 NS_JINGLE_RTP_AUDIO, | |
42 NS_JINGLE_RTP_VIDEO, | |
43 ) | |
44 | |
45 | |
46 log = getLogger(__name__) | |
47 | |
48 | |
49 PLUGIN_INFO = { | |
50 C.PI_NAME: "Jingle RTP Sessions", | |
51 C.PI_IMPORT_NAME: "XEP-0167", | |
52 C.PI_TYPE: "XEP", | |
53 C.PI_PROTOCOLS: ["XEP-0167"], | |
54 C.PI_DEPENDENCIES: ["XEP-0166"], | |
55 C.PI_MAIN: "XEP_0167", | |
56 C.PI_HANDLER: "yes", | |
57 C.PI_DESCRIPTION: _("""Real-time Transport Protocol (RTP) is used for A/V calls"""), | |
58 } | |
59 | |
60 CONFIRM = D_("{peer} wants to start a call ({call_type}) with you, do you accept?") | |
61 CONFIRM_TITLE = D_("Incoming Call") | |
62 SECURITY_LIMIT = 0 | |
63 | |
64 ALLOWED_ACTIONS = ( | |
65 "active", | |
66 "hold", | |
67 "unhold", | |
68 "mute", | |
69 "unmute", | |
70 "ringing", | |
71 ) | |
72 | |
73 | |
74 class XEP_0167(BaseApplicationHandler): | |
75 def __init__(self, host): | |
76 log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization') | |
77 self.host = host | |
78 # FIXME: to be removed once host is accessible from global var | |
79 mapping.host = host | |
80 self._j = host.plugins["XEP-0166"] | |
81 self._j.register_application(NS_JINGLE_RTP, self) | |
82 host.bridge.add_method( | |
83 "call_start", | |
84 ".plugin", | |
85 in_sign="sss", | |
86 out_sign="s", | |
87 method=self._call_start, | |
88 async_=True, | |
89 ) | |
90 host.bridge.add_method( | |
91 "call_end", | |
92 ".plugin", | |
93 in_sign="sss", | |
94 out_sign="", | |
95 method=self._call_end, | |
96 async_=True, | |
97 ) | |
98 host.bridge.add_method( | |
99 "call_info", | |
100 ".plugin", | |
101 in_sign="ssss", | |
102 out_sign="", | |
103 method=self._call_start, | |
104 ) | |
105 host.bridge.add_signal( | |
106 "call_accepted", ".plugin", signature="sss" | |
107 ) # args: session_id, answer_sdp, profile | |
108 host.bridge.add_signal( | |
109 "call_ended", ".plugin", signature="sss" | |
110 ) # args: session_id, data, profile | |
111 host.bridge.add_signal( | |
112 "call_info", ".plugin", signature="ssss" | |
113 ) # args: session_id, info_type, extra, profile | |
114 | |
115 def get_handler(self, client): | |
116 return XEP_0167_handler() | |
117 | |
118 # bridge methods | |
119 | |
120 def _call_start( | |
121 self, | |
122 entity_s: str, | |
123 call_data_s: str, | |
124 profile_key: str, | |
125 ): | |
126 client = self.host.get_client(profile_key) | |
127 return defer.ensureDeferred( | |
128 self.call_start( | |
129 client, jid.JID(entity_s), data_format.deserialise(call_data_s) | |
130 ) | |
131 ) | |
132 | |
133 async def call_start( | |
134 self, | |
135 client: SatXMPPEntity, | |
136 peer_jid: jid.JID, | |
137 call_data: dict, | |
138 ) -> None: | |
139 """Temporary method to test RTP session""" | |
140 contents = [] | |
141 metadata = call_data.get("metadata") or {} | |
142 | |
143 if "sdp" in call_data: | |
144 sdp_data = mapping.parse_sdp(call_data["sdp"]) | |
145 for media_type in ("audio", "video"): | |
146 try: | |
147 media_data = sdp_data.pop(media_type) | |
148 except KeyError: | |
149 continue | |
150 call_data[media_type] = media_data["application_data"] | |
151 transport_data = media_data["transport_data"] | |
152 try: | |
153 call_data[media_type]["fingerprint"] = transport_data["fingerprint"] | |
154 except KeyError: | |
155 log.warning("fingerprint is missing") | |
156 pass | |
157 try: | |
158 call_data[media_type]["id"] = media_data["id"] | |
159 except KeyError: | |
160 log.warning(f"no media ID found for {media_type}: {media_data}") | |
161 try: | |
162 call_data[media_type]["ice-candidates"] = transport_data["candidates"] | |
163 metadata["ice-ufrag"] = transport_data["ufrag"] | |
164 metadata["ice-pwd"] = transport_data["pwd"] | |
165 except KeyError: | |
166 log.warning("ICE data are missing from SDP") | |
167 continue | |
168 metadata.update(sdp_data.get("metadata", {})) | |
169 | |
170 call_type = ( | |
171 C.META_SUBTYPE_CALL_VIDEO | |
172 if "video" in call_data | |
173 else C.META_SUBTYPE_CALL_AUDIO | |
174 ) | |
175 seen_names = set() | |
176 | |
177 for media in ("audio", "video"): | |
178 media_data = call_data.get(media) | |
179 if media_data is not None: | |
180 content = { | |
181 "app_ns": NS_JINGLE_RTP, | |
182 "senders": "both", | |
183 "transport_type": self._j.TRANSPORT_DATAGRAM, | |
184 "app_kwargs": {"media": media, "media_data": media_data}, | |
185 "transport_data": { | |
186 "local_ice_data": { | |
187 "ufrag": metadata["ice-ufrag"], | |
188 "pwd": metadata["ice-pwd"], | |
189 "candidates": media_data.pop("ice-candidates"), | |
190 "fingerprint": media_data.pop("fingerprint", {}), | |
191 } | |
192 }, | |
193 } | |
194 if "id" in media_data: | |
195 name = media_data.pop("id") | |
196 if name in seen_names: | |
197 raise exceptions.DataError( | |
198 f"Content name (mid) seen multiple times: {name}" | |
199 ) | |
200 content["name"] = name | |
201 contents.append(content) | |
202 if not contents: | |
203 raise exceptions.DataError("no valid media data found: {call_data}") | |
204 return await self._j.initiate( | |
205 client, | |
206 peer_jid, | |
207 contents, | |
208 call_type=call_type, | |
209 metadata=metadata, | |
210 peer_metadata={}, | |
211 ) | |
212 | |
213 def _call_end( | |
214 self, | |
215 session_id: str, | |
216 data_s: str, | |
217 profile_key: str, | |
218 ): | |
219 client = self.host.get_client(profile_key) | |
220 return defer.ensureDeferred( | |
221 self.call_end( | |
222 client, session_id, data_format.deserialise(data_s) | |
223 ) | |
224 ) | |
225 | |
226 async def call_end( | |
227 self, | |
228 client: SatXMPPEntity, | |
229 session_id: str, | |
230 data: dict, | |
231 ) -> None: | |
232 """End a call | |
233 | |
234 @param session_id: Jingle session ID of the call | |
235 @param data: optional extra data, may be used to indicate the reason to end the | |
236 call | |
237 """ | |
238 session = self._j.get_session(client, session_id) | |
239 await self._j.terminate(client, self._j.REASON_SUCCESS, session) | |
240 | |
241 # jingle callbacks | |
242 | |
243 def jingle_session_init( | |
244 self, | |
245 client: SatXMPPEntity, | |
246 session: dict, | |
247 content_name: str, | |
248 media: str, | |
249 media_data: dict, | |
250 ) -> domish.Element: | |
251 if media not in ("audio", "video"): | |
252 raise ValueError('only "audio" and "video" media types are supported') | |
253 content_data = session["contents"][content_name] | |
254 application_data = content_data["application_data"] | |
255 application_data["media"] = media | |
256 application_data["local_data"] = media_data | |
257 desc_elt = mapping.build_description(media, media_data, session) | |
258 self.host.trigger.point( | |
259 "XEP-0167_jingle_session_init", | |
260 client, | |
261 session, | |
262 content_name, | |
263 media, | |
264 media_data, | |
265 desc_elt, | |
266 triggers_no_cancel=True, | |
267 ) | |
268 return desc_elt | |
269 | |
270 async def jingle_request_confirmation( | |
271 self, | |
272 client: SatXMPPEntity, | |
273 action: str, | |
274 session: dict, | |
275 content_name: str, | |
276 desc_elt: domish.Element, | |
277 ) -> bool: | |
278 if content_name != next(iter(session["contents"])): | |
279 # we request confirmation only for the first content, all others are | |
280 # automatically accepted. In practice, that means that the call confirmation | |
281 # is requested only once for audio and video contents. | |
282 return True | |
283 peer_jid = session["peer_jid"] | |
284 | |
285 if any( | |
286 c["desc_elt"].getAttribute("media") == "video" | |
287 for c in session["contents"].values() | |
288 ): | |
289 call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO | |
290 else: | |
291 call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO | |
292 | |
293 sdp = mapping.generate_sdp_from_session(session) | |
294 | |
295 resp_data = await xml_tools.defer_dialog( | |
296 self.host, | |
297 _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type), | |
298 _(CONFIRM_TITLE), | |
299 action_extra={ | |
300 "session_id": session["id"], | |
301 "from_jid": peer_jid.full(), | |
302 "type": C.META_TYPE_CALL, | |
303 "sub_type": call_type, | |
304 "sdp": sdp, | |
305 }, | |
306 security_limit=SECURITY_LIMIT, | |
307 profile=client.profile, | |
308 ) | |
309 | |
310 if resp_data.get("cancelled", False): | |
311 return False | |
312 | |
313 answer_sdp = resp_data["sdp"] | |
314 parsed_answer = mapping.parse_sdp(answer_sdp) | |
315 session["peer_metadata"].update(parsed_answer["metadata"]) | |
316 for media in ("audio", "video"): | |
317 for content in session["contents"].values(): | |
318 if content["desc_elt"].getAttribute("media") == media: | |
319 media_data = parsed_answer[media] | |
320 application_data = content["application_data"] | |
321 application_data["local_data"] = media_data["application_data"] | |
322 transport_data = content["transport_data"] | |
323 local_ice_data = media_data["transport_data"] | |
324 transport_data["local_ice_data"] = local_ice_data | |
325 | |
326 return True | |
327 | |
328 async def jingle_handler(self, client, action, session, content_name, desc_elt): | |
329 content_data = session["contents"][content_name] | |
330 application_data = content_data["application_data"] | |
331 if action == self._j.A_PREPARE_CONFIRMATION: | |
332 session["metadata"] = {} | |
333 session["peer_metadata"] = {} | |
334 try: | |
335 media = application_data["media"] = desc_elt["media"] | |
336 except KeyError: | |
337 raise exceptions.DataError('"media" key is missing in {desc_elt.toXml()}') | |
338 if media not in ("audio", "video"): | |
339 raise exceptions.DataError(f"invalid media: {media!r}") | |
340 application_data["peer_data"] = mapping.parse_description(desc_elt) | |
341 elif action == self._j.A_SESSION_INITIATE: | |
342 application_data["peer_data"] = mapping.parse_description(desc_elt) | |
343 desc_elt = mapping.build_description( | |
344 application_data["media"], application_data["local_data"], session | |
345 ) | |
346 elif action == self._j.A_ACCEPTED_ACK: | |
347 pass | |
348 elif action == self._j.A_PREPARE_INITIATOR: | |
349 application_data["peer_data"] = mapping.parse_description(desc_elt) | |
350 elif action == self._j.A_SESSION_ACCEPT: | |
351 if content_name == next(iter(session["contents"])): | |
352 # we only send the signal for first content, as it means that the whole | |
353 # session is accepted | |
354 answer_sdp = mapping.generate_sdp_from_session(session) | |
355 self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile) | |
356 else: | |
357 log.warning(f"FIXME: unmanaged action {action}") | |
358 | |
359 self.host.trigger.point( | |
360 "XEP-0167_jingle_handler", | |
361 client, | |
362 action, | |
363 session, | |
364 content_name, | |
365 desc_elt, | |
366 triggers_no_cancel=True, | |
367 ) | |
368 return desc_elt | |
369 | |
370 def jingle_session_info( | |
371 self, | |
372 client: SatXMPPEntity, | |
373 action: str, | |
374 session: dict, | |
375 content_name: str, | |
376 jingle_elt: domish.Element, | |
377 ) -> None: | |
378 """Informational messages""" | |
379 for elt in jingle_elt.elements(): | |
380 if elt.uri == NS_JINGLE_RTP_INFO: | |
381 info_type = elt.name | |
382 if info_type not in ALLOWED_ACTIONS: | |
383 log.warning("ignoring unknow info type: {info_type!r}") | |
384 continue | |
385 extra = {} | |
386 if info_type in ("mute", "unmute"): | |
387 name = elt.getAttribute("name") | |
388 if name: | |
389 extra["name"] = name | |
390 log.debug(f"{info_type} call info received (extra: {extra})") | |
391 self.host.bridge.call_info( | |
392 session["id"], info_type, data_format.serialise(extra), client.profile | |
393 ) | |
394 | |
395 def _call_info(self, session_id, info_type, extra_s, profile_key): | |
396 client = self.host.get_client(profile_key) | |
397 extra = data_format.deserialise(extra_s) | |
398 return self.send_info(client, session_id, info_type, extra) | |
399 | |
400 | |
401 def send_info( | |
402 self, | |
403 client: SatXMPPEntity, | |
404 session_id: str, | |
405 info_type: str, | |
406 extra: Optional[dict], | |
407 ) -> None: | |
408 """Send information on the call""" | |
409 if info_type not in ALLOWED_ACTIONS: | |
410 raise ValueError(f"Unkown info type {info_type!r}") | |
411 session = self._j.get_session(client, session_id) | |
412 iq_elt, jingle_elt = self._j.build_session_info(client, session) | |
413 info_elt = jingle_elt.addElement((NS_JINGLE_RTP_INFO, info_type)) | |
414 if extra and info_type in ("mute", "unmute") and "name" in extra: | |
415 info_elt["name"] = extra["name"] | |
416 iq_elt.send() | |
417 | |
418 def jingle_terminate( | |
419 self, | |
420 client: SatXMPPEntity, | |
421 action: str, | |
422 session: dict, | |
423 content_name: str, | |
424 reason_elt: domish.Element, | |
425 ) -> None: | |
426 self.host.bridge.call_ended(session["id"], "", client.profile) | |
427 | |
428 | |
429 @implementer(iwokkel.IDisco) | |
430 class XEP_0167_handler(XMPPHandler): | |
431 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
432 return [ | |
433 disco.DiscoFeature(NS_JINGLE_RTP), | |
434 disco.DiscoFeature(NS_JINGLE_RTP_AUDIO), | |
435 disco.DiscoFeature(NS_JINGLE_RTP_VIDEO), | |
436 ] | |
437 | |
438 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
439 return [] |