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