Mercurial > libervia-backend
comparison sat/plugins/plugin_xep_0167/__init__.py @ 4056:1c4f4aa36d98
plugin XEP-0167: Jingle RTP Sessions implementation:
rel 420
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 29 May 2023 13:38:10 +0200 |
parents | |
children | adb9dc9c8114 |
comparison
equal
deleted
inserted
replaced
4055:38819c69aa39 | 4056:1c4f4aa36d98 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia plugin for managing pipes (experimental) | |
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 sat.core import exceptions | |
29 from sat.core.constants import Const as C | |
30 from sat.core.core_types import SatXMPPEntity | |
31 from sat.core.i18n import D_, _ | |
32 from sat.core.log import getLogger | |
33 from sat.tools import xml_tools | |
34 from sat.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_info", | |
92 ".plugin", | |
93 in_sign="ssss", | |
94 out_sign="", | |
95 method=self._call_start, | |
96 ) | |
97 host.bridge.add_signal( | |
98 "call_accepted", ".plugin", signature="sss" | |
99 ) # args: session_id, answer_sdp, profile | |
100 host.bridge.add_signal( | |
101 "call_info", ".plugin", signature="ssss" | |
102 ) # args: session_id, info_type, extra, profile | |
103 | |
104 def get_handler(self, client): | |
105 return XEP_0167_handler() | |
106 | |
107 # bridge methods | |
108 | |
109 def _call_start( | |
110 self, | |
111 entity_s: str, | |
112 call_data_s: str, | |
113 profile_key: str, | |
114 ): | |
115 client = self.host.get_client(profile_key) | |
116 return defer.ensureDeferred( | |
117 self.call_start( | |
118 client, jid.JID(entity_s), data_format.deserialise(call_data_s) | |
119 ) | |
120 ) | |
121 | |
122 async def call_start( | |
123 self, | |
124 client: SatXMPPEntity, | |
125 peer_jid: jid.JID, | |
126 call_data: dict, | |
127 media: str = "video", | |
128 ) -> None: | |
129 """Temporary method to test RTP session""" | |
130 contents = [] | |
131 metadata = call_data.get("metadata") or {} | |
132 | |
133 if "sdp" in call_data: | |
134 sdp_data = mapping.parse_sdp(call_data["sdp"]) | |
135 for media_type in ("audio", "video"): | |
136 try: | |
137 media_data = sdp_data.pop(media_type) | |
138 except KeyError: | |
139 continue | |
140 call_data[media_type] = media_data["application_data"] | |
141 transport_data = media_data["transport_data"] | |
142 try: | |
143 call_data[media_type]["fingerprint"] = transport_data["fingerprint"] | |
144 except KeyError: | |
145 log.warning("fingerprint is missing") | |
146 pass | |
147 try: | |
148 call_data[media_type]["id"] = media_data["id"] | |
149 except KeyError: | |
150 log.warning(f"no media ID found for {media_type}: {media_data}") | |
151 try: | |
152 call_data[media_type]["ice-candidates"] = transport_data["candidates"] | |
153 metadata["ice-ufrag"] = transport_data["ufrag"] | |
154 metadata["ice-pwd"] = transport_data["pwd"] | |
155 except KeyError: | |
156 log.warning("ICE data are missing from SDP") | |
157 continue | |
158 metadata.update(sdp_data.get("metadata", {})) | |
159 | |
160 call_type = ( | |
161 C.META_SUBTYPE_CALL_VIDEO | |
162 if "video" in call_data | |
163 else C.META_SUBTYPE_CALL_AUDIO | |
164 ) | |
165 seen_names = set() | |
166 | |
167 for media in ("audio", "video"): | |
168 media_data = call_data.get(media) | |
169 if media_data is not None: | |
170 content = { | |
171 "app_ns": NS_JINGLE_RTP, | |
172 "senders": "both", | |
173 "transport_type": self._j.TRANSPORT_DATAGRAM, | |
174 "app_kwargs": {"media": media, "media_data": media_data}, | |
175 "transport_data": { | |
176 "local_ice_data": { | |
177 "ufrag": metadata["ice-ufrag"], | |
178 "pwd": metadata["ice-pwd"], | |
179 "candidates": media_data.pop("ice-candidates"), | |
180 "fingerprint": media_data.pop("fingerprint", {}), | |
181 } | |
182 }, | |
183 } | |
184 if "id" in media_data: | |
185 name = media_data.pop("id") | |
186 if name in seen_names: | |
187 raise exceptions.DataError( | |
188 f"Content name (mid) seen multiple times: {name}" | |
189 ) | |
190 content["name"] = name | |
191 contents.append(content) | |
192 if not contents: | |
193 raise exceptions.DataError("no valid media data found: {call_data}") | |
194 return await self._j.initiate( | |
195 client, | |
196 peer_jid, | |
197 contents, | |
198 call_type=call_type, | |
199 metadata=metadata, | |
200 peer_metadata={}, | |
201 ) | |
202 | |
203 # jingle callbacks | |
204 | |
205 def jingle_session_init( | |
206 self, | |
207 client: SatXMPPEntity, | |
208 session: dict, | |
209 content_name: str, | |
210 media: str, | |
211 media_data: dict, | |
212 ) -> domish.Element: | |
213 if media not in ("audio", "video"): | |
214 raise ValueError('only "audio" and "video" media types are supported') | |
215 content_data = session["contents"][content_name] | |
216 application_data = content_data["application_data"] | |
217 application_data["media"] = media | |
218 application_data["local_data"] = media_data | |
219 desc_elt = mapping.build_description(media, media_data, session) | |
220 self.host.trigger.point( | |
221 "XEP-0167_jingle_session_init", | |
222 client, | |
223 session, | |
224 content_name, | |
225 media, | |
226 media_data, | |
227 desc_elt, | |
228 triggers_no_cancel=True, | |
229 ) | |
230 return desc_elt | |
231 | |
232 async def jingle_request_confirmation( | |
233 self, | |
234 client: SatXMPPEntity, | |
235 action: str, | |
236 session: dict, | |
237 content_name: str, | |
238 desc_elt: domish.Element, | |
239 ) -> bool: | |
240 if content_name != next(iter(session["contents"])): | |
241 # we request confirmation only for the first content, all others are | |
242 # automatically accepted. In practice, that means that the call confirmation | |
243 # is requested only once for audio and video contents. | |
244 return True | |
245 peer_jid = session["peer_jid"] | |
246 | |
247 if any( | |
248 c["desc_elt"].getAttribute("media") == "video" | |
249 for c in session["contents"].values() | |
250 ): | |
251 call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO | |
252 else: | |
253 call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO | |
254 | |
255 sdp = mapping.generate_sdp_from_session(session) | |
256 | |
257 resp_data = await xml_tools.defer_dialog( | |
258 self.host, | |
259 _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type), | |
260 _(CONFIRM_TITLE), | |
261 action_extra={ | |
262 "session_id": session["id"], | |
263 "from_jid": peer_jid.full(), | |
264 "type": C.META_TYPE_CALL, | |
265 "sub_type": call_type, | |
266 "sdp": sdp, | |
267 }, | |
268 security_limit=SECURITY_LIMIT, | |
269 profile=client.profile, | |
270 ) | |
271 | |
272 if resp_data.get("cancelled", False): | |
273 return False | |
274 | |
275 answer_sdp = resp_data["sdp"] | |
276 parsed_answer = mapping.parse_sdp(answer_sdp) | |
277 session["peer_metadata"].update(parsed_answer["metadata"]) | |
278 for media in ("audio", "video"): | |
279 for content in session["contents"].values(): | |
280 if content["desc_elt"].getAttribute("media") == media: | |
281 media_data = parsed_answer[media] | |
282 application_data = content["application_data"] | |
283 application_data["local_data"] = media_data["application_data"] | |
284 transport_data = content["transport_data"] | |
285 local_ice_data = media_data["transport_data"] | |
286 transport_data["local_ice_data"] = local_ice_data | |
287 | |
288 return True | |
289 | |
290 async def jingle_handler(self, client, action, session, content_name, desc_elt): | |
291 content_data = session["contents"][content_name] | |
292 application_data = content_data["application_data"] | |
293 if action == self._j.A_PREPARE_CONFIRMATION: | |
294 session["metadata"] = {} | |
295 session["peer_metadata"] = {} | |
296 try: | |
297 media = application_data["media"] = desc_elt["media"] | |
298 except KeyError: | |
299 raise exceptions.DataError('"media" key is missing in {desc_elt.toXml()}') | |
300 if media not in ("audio", "video"): | |
301 raise exceptions.DataError(f"invalid media: {media!r}") | |
302 application_data["peer_data"] = mapping.parse_description(desc_elt) | |
303 elif action == self._j.A_SESSION_INITIATE: | |
304 application_data["peer_data"] = mapping.parse_description(desc_elt) | |
305 desc_elt = mapping.build_description( | |
306 application_data["media"], application_data["local_data"], session | |
307 ) | |
308 elif action == self._j.A_ACCEPTED_ACK: | |
309 pass | |
310 elif action == self._j.A_PREPARE_INITIATOR: | |
311 application_data["peer_data"] = mapping.parse_description(desc_elt) | |
312 elif action == self._j.A_SESSION_ACCEPT: | |
313 if content_name == next(iter(session["contents"])): | |
314 # we only send the signal for first content, as it means that the whole | |
315 # session is accepted | |
316 answer_sdp = mapping.generate_sdp_from_session(session) | |
317 self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile) | |
318 else: | |
319 log.warning(f"FIXME: unmanaged action {action}") | |
320 | |
321 self.host.trigger.point( | |
322 "XEP-0167_jingle_handler", | |
323 client, | |
324 action, | |
325 session, | |
326 content_name, | |
327 desc_elt, | |
328 triggers_no_cancel=True, | |
329 ) | |
330 return desc_elt | |
331 | |
332 def jingle_session_info( | |
333 self, | |
334 client: SatXMPPEntity, | |
335 action: str, | |
336 session: dict, | |
337 content_name: str, | |
338 jingle_elt: domish.Element, | |
339 ) -> None: | |
340 """Informational messages""" | |
341 for elt in jingle_elt.elements(): | |
342 if elt.uri == NS_JINGLE_RTP_INFO: | |
343 info_type = elt.name | |
344 if info_type not in ALLOWED_ACTIONS: | |
345 log.warning("ignoring unknow info type: {info_type!r}") | |
346 continue | |
347 extra = {} | |
348 if info_type in ("mute", "unmute"): | |
349 name = elt.getAttribute("name") | |
350 if name: | |
351 extra["name"] = name | |
352 log.debug(f"{info_type} call info received (extra: {extra})") | |
353 self.host.bridge.call_info( | |
354 session["id"], info_type, data_format.serialise(extra), client.profile | |
355 ) | |
356 | |
357 def _call_info(self, session_id, info_type, extra_s, profile_key): | |
358 client = self.host.get_client(profile_key) | |
359 extra = data_format.deserialise(extra_s) | |
360 return self.send_info(client, session_id, info_type, extra) | |
361 | |
362 | |
363 def send_info( | |
364 self, | |
365 client: SatXMPPEntity, | |
366 session_id: str, | |
367 info_type: str, | |
368 extra: Optional[dict], | |
369 ) -> None: | |
370 """Send information on the call""" | |
371 if info_type not in ALLOWED_ACTIONS: | |
372 raise ValueError(f"Unkown info type {info_type!r}") | |
373 session = self._j.get_session(client, session_id) | |
374 iq_elt, jingle_elt = self._j.build_session_info(client, session) | |
375 info_elt = jingle_elt.addElement((NS_JINGLE_RTP_INFO, info_type)) | |
376 if extra and info_type in ("mute", "unmute") and "name" in extra: | |
377 info_elt["name"] = extra["name"] | |
378 iq_elt.send() | |
379 | |
380 def jingle_terminate( | |
381 self, | |
382 client: SatXMPPEntity, | |
383 action: str, | |
384 session: dict, | |
385 content_name: str, | |
386 reason_elt: domish.Element, | |
387 ) -> None: | |
388 pass | |
389 | |
390 | |
391 @implementer(iwokkel.IDisco) | |
392 class XEP_0167_handler(XMPPHandler): | |
393 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
394 return [ | |
395 disco.DiscoFeature(NS_JINGLE_RTP), | |
396 disco.DiscoFeature(NS_JINGLE_RTP_AUDIO), | |
397 disco.DiscoFeature(NS_JINGLE_RTP_VIDEO), | |
398 ] | |
399 | |
400 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
401 return [] |