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 []