PyAgoraRTC 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ """The gateway WebSocket session: frame builders and parsers, timing policies, and the transport seam."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyagorartc.session.messages import (
6
+ BROWSER_USER_AGENT,
7
+ ErrorInfo,
8
+ FrameType,
9
+ GatewayFrame,
10
+ JoinResult,
11
+ Notification,
12
+ P2PLost,
13
+ P2POk,
14
+ RtpCapabilities,
15
+ UserEvent,
16
+ build_join,
17
+ build_leave,
18
+ build_ping,
19
+ build_renew_token,
20
+ build_set_client_role,
21
+ build_subscribe,
22
+ build_unsubscribe,
23
+ describe_frame,
24
+ encode_frame,
25
+ existing_streams_from_join,
26
+ is_quit,
27
+ lacks_payload_type,
28
+ new_process_id,
29
+ new_request_id,
30
+ parse_error,
31
+ parse_frame,
32
+ parse_join_result,
33
+ parse_notification,
34
+ parse_p2p_lost,
35
+ parse_p2p_ok,
36
+ parse_remote_stream,
37
+ parse_rtp_capability_change,
38
+ parse_user_event,
39
+ )
40
+ from pyagorartc.session.recovery import Keepalive, PeerRecovery, RenewDebounce
41
+ from pyagorartc.session.session import AgoraSession, Spawn
42
+ from pyagorartc.session.transport import GatewayConnection, GatewayTransport, WebsocketsConnection, WebsocketsTransport
43
+
44
+ __all__ = [
45
+ "BROWSER_USER_AGENT",
46
+ "AgoraSession",
47
+ "ErrorInfo",
48
+ "FrameType",
49
+ "GatewayConnection",
50
+ "GatewayFrame",
51
+ "GatewayTransport",
52
+ "JoinResult",
53
+ "Keepalive",
54
+ "Notification",
55
+ "P2PLost",
56
+ "P2POk",
57
+ "PeerRecovery",
58
+ "RenewDebounce",
59
+ "RtpCapabilities",
60
+ "Spawn",
61
+ "UserEvent",
62
+ "WebsocketsConnection",
63
+ "WebsocketsTransport",
64
+ "build_join",
65
+ "build_leave",
66
+ "build_ping",
67
+ "build_renew_token",
68
+ "build_set_client_role",
69
+ "build_subscribe",
70
+ "build_unsubscribe",
71
+ "describe_frame",
72
+ "encode_frame",
73
+ "existing_streams_from_join",
74
+ "is_quit",
75
+ "lacks_payload_type",
76
+ "new_process_id",
77
+ "new_request_id",
78
+ "parse_error",
79
+ "parse_frame",
80
+ "parse_join_result",
81
+ "parse_notification",
82
+ "parse_p2p_lost",
83
+ "parse_p2p_ok",
84
+ "parse_remote_stream",
85
+ "parse_rtp_capability_change",
86
+ "parse_user_event",
87
+ ]
@@ -0,0 +1,570 @@
1
+ """Builders and parsers for every gateway WebSocket frame; the only module that spells a gateway wire key.
2
+
3
+ Pure: no I/O and no clock reads. Timestamps and ids are parameters; ``new_request_id`` and
4
+ ``new_process_id`` are the one place ids are minted. Shapes and provenance: ``docs/protocol.md`` §2 to §5.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import StrEnum
11
+ import json
12
+ import logging
13
+ import secrets
14
+ from typing import TYPE_CHECKING, NamedTuple, TypeIs
15
+
16
+ from pyagorartc.const import SDK_VERSION
17
+ from pyagorartc.exceptions import JoinRejectedError
18
+ from pyagorartc.models import RemoteStream, as_int
19
+ from pyagorartc.sdp import offers_rtx
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping
23
+
24
+ from pyagorartc.models import ChannelCredentials, SessionOptions
25
+
26
+ _LOGGER = logging.getLogger(__name__)
27
+
28
+ type JsonObject = dict[str, object]
29
+
30
+ # shipped (HA-Luba and PetKit); the SDK sends navigator.userAgent.
31
+ BROWSER_USER_AGENT = (
32
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
33
+ )
34
+ DEFAULT_P2P_ID = 1
35
+ STREAM_MODE = "live"
36
+ JOIN_ROLE = "host"
37
+ RESULT_SUCCESS = "success"
38
+ RESULT_FAILED = "failed"
39
+ NOTIFICATION_QUIT = "quit"
40
+ NO_PAYLOAD_TYPE = 0
41
+ _REQUEST_ID_BYTES = 3
42
+
43
+ # shipped (HA-Luba's flag set); nested under userAttributes per the SDK (D7). enableInstantVideo is set per session.
44
+ _USER_ATTRIBUTES: Mapping[str, object] = {
45
+ "enableAudioMetadata": False,
46
+ "enableAudioPts": False,
47
+ "enableNetworkQualityProbe": False,
48
+ "enablePublishedUserList": True,
49
+ "enableUserList": False,
50
+ "maxSubscription": 50,
51
+ "enableUserLicenseCheck": True,
52
+ "enableRTX": True,
53
+ "enableInstantVideo": False,
54
+ "enableDataStream2": False,
55
+ "enableAutFeedback": True,
56
+ "enableUserAutoRebalanceCheck": True,
57
+ "enableXR": True,
58
+ "enableLossbasedBwe": True,
59
+ "enableAutCC": True,
60
+ "enablePreallocPC": True,
61
+ "enablePubTWCC": False,
62
+ "enableSubTWCC": True,
63
+ "enablePubRTX": True,
64
+ "enableSubRTX": True,
65
+ "enableVosFallback": False,
66
+ "enableQualityFallback": False,
67
+ "enableDualStreamFlag": False,
68
+ }
69
+
70
+ # PetKit's markers for "this node in the join payload is a video stream" (the server's key is unverified).
71
+ _VIDEO_CODEC_MARKERS = frozenset({"h264", "h265", "video"})
72
+ # Deeper than any payload the SDK describes; bounds the walk on a hostile or broken frame.
73
+ _MAX_STREAM_SEARCH_DEPTH = 32
74
+
75
+
76
+ class FrameType(StrEnum):
77
+ """Every gateway frame ``_type`` the library builds or recognises (SDK:28390-28474)."""
78
+
79
+ JOIN_V3 = "join_v3"
80
+ REJOIN_V3 = "rejoin_v3"
81
+ LEAVE = "leave"
82
+ PING = "ping"
83
+ PING_BACK = "ping_back"
84
+ SUBSCRIBE = "subscribe"
85
+ UNSUBSCRIBE = "unsubscribe"
86
+ RENEW_TOKEN = "renew_token" # noqa: S105 - a frame name, not a secret
87
+ SET_CLIENT_ROLE = "set_client_role"
88
+ ANSWER = "answer"
89
+ ERROR = "error"
90
+ ON_USER_ONLINE = "on_user_online"
91
+ ON_USER_OFFLINE = "on_user_offline"
92
+ ON_STREAM_FALLBACK_UPDATE = "on_stream_fallback_update"
93
+ ON_PUBLISH_STREAM = "on_publish_stream"
94
+ ON_UPLINK_STATS = "on_uplink_stats"
95
+ ON_P2P_LOST = "on_p2p_lost"
96
+ ON_P2P_OK = "on_p2p_ok"
97
+ ON_REMOVE_STREAM = "on_remove_stream"
98
+ ON_ADD_AUDIO_STREAM = "on_add_audio_stream"
99
+ ON_ADD_VIDEO_STREAM = "on_add_video_stream"
100
+ ON_TOKEN_PRIVILEGE_WILL_EXPIRE = "on_token_privilege_will_expire" # noqa: S105 - a frame name, not a secret
101
+ ON_TOKEN_PRIVILEGE_DID_EXPIRE = "on_token_privilege_did_expire" # noqa: S105 - a frame name, not a secret
102
+ ON_USER_BANNED = "on_user_banned"
103
+ ON_USER_LICENSE_BANNED = "on_user_license_banned"
104
+ ON_NOTIFICATION = "on_notification"
105
+ ON_CRYPT_ERROR = "on_crypt_error"
106
+ MUTE_AUDIO = "mute_audio"
107
+ MUTE_VIDEO = "mute_video"
108
+ UNMUTE_AUDIO = "unmute_audio"
109
+ UNMUTE_VIDEO = "unmute_video"
110
+ RECEIVE_METADATA = "receive_metadata"
111
+ ON_DATA_STREAM = "on_data_stream"
112
+ ON_RTP_CAPABILITY_CHANGE = "on_rtp_capability_change"
113
+ ON_REMOTE_DATASTREAM_UPDATE = "on_remote_datastream_update"
114
+ ON_REMOTE_FULL_DATASTREAM_INFO = "on_remote_full_datastream_info"
115
+ ENABLE_LOCAL_VIDEO = "enable_local_video"
116
+ DISABLE_LOCAL_VIDEO = "disable_local_video"
117
+ ENABLE_LOCAL_AUDIO = "enable_local_audio"
118
+ DISABLE_LOCAL_AUDIO = "disable_local_audio"
119
+ ON_PUBLISHED_USER_LIST = "on_published_user_list"
120
+ ENABLE_MULTI_STREAM = "enable_multi_stream"
121
+ ON_USER_LIST = "on_user_list"
122
+
123
+
124
+ @dataclass(frozen=True, repr=False)
125
+ class GatewayFrame:
126
+ """One decoded gateway frame (protocol.md §2.1).
127
+
128
+ ``type`` is the raw ``_type`` string (compare it with ``FrameType``; unknown names are kept),
129
+ ``message`` is ``_message`` or ``{}`` when absent or not an object, and ``raw`` is the whole
130
+ decoded object. ``repr`` shows only the envelope, since payloads can carry ICE passwords and
131
+ rejoin tokens.
132
+ """
133
+
134
+ id: str | None
135
+ type: str | None
136
+ result: str | None
137
+ message: Mapping[str, object] = field(default_factory=dict)
138
+ raw: Mapping[str, object] = field(default_factory=dict)
139
+
140
+ @property
141
+ def is_response(self) -> bool:
142
+ """A frame with ``_id`` answers a request, even if it also carries ``_type`` (SDK dispatch)."""
143
+ return self.id is not None
144
+
145
+ @property
146
+ def is_event(self) -> bool:
147
+ """A server-initiated event: no ``_id``."""
148
+ return self.id is None
149
+
150
+ @property
151
+ def ok(self) -> bool:
152
+ """Whether this is a response whose ``_result`` is ``success``."""
153
+ return self.is_response and self.result == RESULT_SUCCESS
154
+
155
+ def __repr__(self) -> str:
156
+ return (
157
+ f"GatewayFrame(id={self.id!r}, type={self.type!r}, result={self.result!r}, "
158
+ f"message_keys={sorted(self.message)!r})"
159
+ )
160
+
161
+
162
+ @dataclass(frozen=True, kw_only=True, repr=False)
163
+ class JoinResult:
164
+ """What the session reads from a successful ``join_v3`` response (protocol.md §2.5)."""
165
+
166
+ ortc: Mapping[str, object]
167
+ rejoin_token: str | None
168
+ cid: int | None
169
+ uid: int | None
170
+ vid: int | None
171
+ cname: str | None
172
+ existing_streams: list[RemoteStream]
173
+
174
+ @property
175
+ def offers_rtx(self) -> bool:
176
+ """Whether the gateway ORTC lists an ``rtx`` video codec; the value for ``subscribe.rtx`` (``sdp.offers_rtx``)."""
177
+ return offers_rtx(self.ortc)
178
+
179
+ def __repr__(self) -> str:
180
+ return (
181
+ f"JoinResult(uid={self.uid!r}, cid={self.cid!r}, vid={self.vid!r}, cname={self.cname!r}, "
182
+ f"rejoin_token={'<present>' if self.rejoin_token else None}, "
183
+ f"existing_streams={self.existing_streams!r})"
184
+ )
185
+
186
+
187
+ class UserEvent(NamedTuple):
188
+ """``on_user_online`` / ``on_user_offline``; ``reason`` values are unverified."""
189
+
190
+ uid: int
191
+ reason: str | None
192
+
193
+
194
+ class Notification(NamedTuple):
195
+ """``on_notification``: ``action`` is ``quit``, ``recover``, ``retry``, ``warn``…; ``code`` per protocol.md §5."""
196
+
197
+ action: str | None
198
+ code: int | None
199
+ detail: str | None
200
+
201
+
202
+ class P2PLost(NamedTuple):
203
+ """``on_p2p_lost``."""
204
+
205
+ code: int | None
206
+ error: str | None
207
+
208
+
209
+ class P2POk(NamedTuple):
210
+ """``on_p2p_ok``; ``uid`` should equal the join uid."""
211
+
212
+ uid: int | None
213
+ proxy: bool
214
+
215
+
216
+ class RtpCapabilities(NamedTuple):
217
+ """``on_rtp_capability_change``."""
218
+
219
+ video_codecs: tuple[str, ...]
220
+ extmap_allow_mixed: bool
221
+ web_av1_svc: bool
222
+
223
+
224
+ class ErrorInfo(NamedTuple):
225
+ """The code and text of an ``error`` event or a ``failed`` response."""
226
+
227
+ code: int | None
228
+ message: str | None
229
+
230
+
231
+ def new_request_id() -> str:
232
+ """A fresh 6-character ``_id`` (the SDK's ``qO(6,"")``; shipped ``secrets.token_hex(3)``)."""
233
+ return secrets.token_hex(_REQUEST_ID_BYTES)
234
+
235
+
236
+ def new_process_id() -> str:
237
+ """A fresh ``process_id`` in the SDK's ``process-<8>-<4>-<4>-<4>-<12>`` shape."""
238
+ groups = (secrets.token_hex(n) for n in (4, 2, 2, 2, 6))
239
+ return "process-" + "-".join(groups)
240
+
241
+
242
+ def encode_frame(frame: Mapping[str, object]) -> str:
243
+ """The text frame to send."""
244
+ return json.dumps(frame)
245
+
246
+
247
+ def build_join( # noqa: PLR0913 - every per-session join input is explicit so the builder stays pure
248
+ creds: ChannelCredentials,
249
+ ortc: Mapping[str, object],
250
+ ap_response: Mapping[str, object],
251
+ *,
252
+ options: SessionOptions,
253
+ session_id: str,
254
+ process_id: str,
255
+ client_ts_ms: int,
256
+ request_id: str,
257
+ p2p_id: int = DEFAULT_P2P_ID,
258
+ ) -> JsonObject:
259
+ """The ``join_v3`` request (D7, D20).
260
+
261
+ Channel encryption is never sent: the SDK RSA-wraps the secret with a key it embeds (D20, Q17), so
262
+ ``creds.encryption`` only logs a WARNING.
263
+
264
+ Args:
265
+ creds: The channel to join; ``license`` and ``string_uid`` are sent only when set.
266
+ ortc: The client ORTC from ``sdp.offer_to_ortc`` with candidates already merged (D11).
267
+ ap_response: The flag-4096 AP block in its join form (``ticket`` set, ``server_ts``).
268
+ options: ``client_codec``, ``instant_video`` and ``extra_join_attributes`` (merged into
269
+ ``userAttributes`` last, so they override the shipped flags) are read.
270
+ session_id: The viewer's session id.
271
+ process_id: From ``new_process_id``; stable for the session.
272
+ client_ts_ms: Wall-clock milliseconds, sent as ``join_ts``.
273
+ request_id: The frame ``_id``; the join response echoes it.
274
+ p2p_id: The SDK's peer-connection counter; 1 for the first and only connection.
275
+
276
+ """
277
+ user_attributes = {
278
+ **_USER_ATTRIBUTES,
279
+ "enableInstantVideo": options.instant_video,
280
+ "enablePreallocPC": options.prealloc_pc,
281
+ **options.extra_join_attributes,
282
+ }
283
+ message: JsonObject = {
284
+ "p2p_id": p2p_id,
285
+ "session_id": session_id,
286
+ "app_id": creds.app_id,
287
+ "channel_key": creds.token,
288
+ "channel_name": creds.channel_name,
289
+ "sdk_version": SDK_VERSION,
290
+ "browser": BROWSER_USER_AGENT,
291
+ "process_id": process_id,
292
+ "mode": STREAM_MODE,
293
+ "codec": options.client_codec,
294
+ "role": JOIN_ROLE,
295
+ "has_changed_gateway": False,
296
+ "ap_response": dict(ap_response),
297
+ "extend": "",
298
+ "details": {"6": creds.string_uid} if creds.string_uid else {},
299
+ "features": {"rejoin": True},
300
+ "attributes": {"userAttributes": user_attributes},
301
+ "join_ts": client_ts_ms,
302
+ "ortc": ortc,
303
+ }
304
+ if creds.license:
305
+ message["license"] = creds.license
306
+ if creds.string_uid:
307
+ message["string_uid"] = creds.string_uid
308
+ if creds.encryption is not None:
309
+ _LOGGER.warning(
310
+ "Channel encryption (%s) is not supported; joining without aes_* fields (D20)", creds.encryption.mode
311
+ )
312
+ return _request(FrameType.JOIN_V3, request_id, message)
313
+
314
+
315
+ def build_subscribe(
316
+ stream: RemoteStream,
317
+ *,
318
+ codec: str,
319
+ rtx: bool,
320
+ request_id: str,
321
+ p2p_id: int = DEFAULT_P2P_ID,
322
+ twcc: bool = True,
323
+ ) -> JsonObject:
324
+ """A ``subscribe`` for one remote video stream.
325
+
326
+ ``codec`` must be the join's ``client_codec`` (the SDK sends ``spec.codec`` in both); ``rtx`` is
327
+ ``JoinResult.offers_rtx``, because the gateway relays RTX only on payload types it offered.
328
+ """
329
+ return _request(
330
+ FrameType.SUBSCRIBE,
331
+ request_id,
332
+ {
333
+ "stream_id": stream.uid,
334
+ "stream_type": "video",
335
+ "mode": STREAM_MODE,
336
+ "codec": codec,
337
+ "p2p_id": p2p_id,
338
+ "twcc": twcc,
339
+ "rtx": rtx,
340
+ "extend": "",
341
+ "ssrcId": stream.ssrc,
342
+ },
343
+ )
344
+
345
+
346
+ def build_unsubscribe(stream_id: int, *, request_id: str, p2p_id: int = DEFAULT_P2P_ID) -> JsonObject:
347
+ """An ``unsubscribe`` for the stream published by ``stream_id`` (the publisher's uid)."""
348
+ return _request(FrameType.UNSUBSCRIBE, request_id, {"p2p_id": p2p_id, "ortc": [], "stream_id": stream_id})
349
+
350
+
351
+ def build_ping(request_id: str) -> JsonObject:
352
+ """A ``ping``; it carries no ``_message``."""
353
+ return {"_id": request_id, "_type": FrameType.PING.value}
354
+
355
+
356
+ def build_leave(request_id: str) -> JsonObject:
357
+ """A ``leave``; send only after a successful join, then close the socket."""
358
+ return {"_id": request_id, "_type": FrameType.LEAVE.value}
359
+
360
+
361
+ def build_renew_token(token: str, *, request_id: str) -> JsonObject:
362
+ """A ``renew_token`` carrying ``token`` (the provider's new token or the join token, D8)."""
363
+ return _request(FrameType.RENEW_TOKEN, request_id, {"token": token})
364
+
365
+
366
+ def build_set_client_role(role: str, level: int, *, client_ts_ms: int, request_id: str) -> JsonObject:
367
+ """A ``set_client_role``; sent only when ``SessionOptions.send_set_client_role`` is on (D6)."""
368
+ return _request(FrameType.SET_CLIENT_ROLE, request_id, {"role": role, "level": level, "client_ts": client_ts_ms})
369
+
370
+
371
+ def parse_frame(text: str) -> GatewayFrame | None:
372
+ """Decode one text frame, or ``None`` when it is not a JSON object.
373
+
374
+ Unknown ``_type`` values and extra keys are kept, never rejected (Constitution §7); the caller
375
+ logs a ``None`` or an unhandled type once at DEBUG and moves on.
376
+ """
377
+ try:
378
+ decoded = json.loads(text)
379
+ except (ValueError, RecursionError):
380
+ return None
381
+ if not isinstance(decoded, dict):
382
+ return None
383
+ return _frame_from(decoded)
384
+
385
+
386
+ def _frame_from(decoded: Mapping[str, object]) -> GatewayFrame:
387
+ message = decoded.get("_message")
388
+ return GatewayFrame(
389
+ id=_as_str(decoded.get("_id")),
390
+ type=_as_str(decoded.get("_type")),
391
+ result=_as_str(decoded.get("_result")),
392
+ message=message if isinstance(message, dict) else {},
393
+ raw=decoded,
394
+ )
395
+
396
+
397
+ def parse_join_result(frame: GatewayFrame) -> JoinResult:
398
+ """Read a ``join_v3`` response.
399
+
400
+ Raises:
401
+ JoinRejectedError: The frame is not a success response (``code`` from ``error_code`` or
402
+ ``code``, as the SDK reads it), or the success carries no ``ortc`` object.
403
+
404
+ """
405
+ if not frame.ok:
406
+ if frame.result == RESULT_FAILED:
407
+ error = parse_error(frame)
408
+ raise JoinRejectedError(error.code, error.message or "join failed")
409
+ raise JoinRejectedError(None, f"join response has result {frame.result!r}")
410
+ message = frame.message
411
+ ortc = message.get("ortc")
412
+ if not isinstance(ortc, dict) or not ortc:
413
+ raise JoinRejectedError(None, "join response has no 'ortc'")
414
+ return JoinResult(
415
+ ortc=ortc,
416
+ rejoin_token=_as_str(message.get("rejoin_token")),
417
+ cid=as_int(message.get("cid")),
418
+ uid=as_int(message.get("uid")),
419
+ vid=as_int(message.get("vid")),
420
+ cname=_as_str(message.get("cname")),
421
+ existing_streams=existing_streams_from_join(message),
422
+ )
423
+
424
+
425
+ def describe_frame(frame: GatewayFrame | Mapping[str, object]) -> str:
426
+ """A log-safe one-line outline of ``frame`` (decoded, or a built frame about to be sent): type, id, keys only."""
427
+ if not isinstance(frame, GatewayFrame):
428
+ frame = _frame_from(frame)
429
+ return f"type={frame.type} id={frame.id} message_keys={sorted(frame.message)}"
430
+
431
+
432
+ def existing_streams_from_join(message: Mapping[str, object]) -> list[RemoteStream]:
433
+ """Video streams already published when we joined, found anywhere in the join payload.
434
+
435
+ PetKit's walk (``_find_existing_video_streams``): any object with an int ``uid`` and ``ssrcId``
436
+ plus a video marker, at most 32 levels deep. Deduplicated by ``(uid, ssrc)`` in payload order. The
437
+ server's key is unverified.
438
+ """
439
+ found: dict[tuple[int, int], RemoteStream] = {}
440
+ _collect_video_streams(message, found)
441
+ return list(found.values())
442
+
443
+
444
+ def parse_remote_stream(message: Mapping[str, object]) -> RemoteStream | None:
445
+ """An ``on_add_video_stream`` payload, or ``None`` without an int ``uid`` and ``ssrcId``.
446
+
447
+ The event type implies video, so no ``video`` flag is required (HA-Luba). ``codec`` is lower-cased.
448
+ A ``pt`` of 0 is logged at WARNING: the gateway found no offered codec it can relay the stream as.
449
+ """
450
+ uid = as_int(message.get("uid"))
451
+ ssrc = as_int(message.get("ssrcId"))
452
+ if uid is None or ssrc is None:
453
+ return None
454
+ stream = _stream_from(message, uid, ssrc)
455
+ if lacks_payload_type(stream):
456
+ _LOGGER.warning(
457
+ "Gateway negotiated no video payload type for uid %s (pt=0, codec %s): the offer carried no codec "
458
+ "this stream can be relayed as; an H265 publisher needs a viewer with HEVC decode",
459
+ uid,
460
+ stream.codec,
461
+ )
462
+ return stream
463
+
464
+
465
+ def lacks_payload_type(stream: RemoteStream) -> bool:
466
+ """Whether the gateway announced the stream with ``pt`` 0, so it will decode to nothing."""
467
+ return stream.payload_type == NO_PAYLOAD_TYPE
468
+
469
+
470
+ def parse_user_event(message: Mapping[str, object]) -> UserEvent | None:
471
+ """An ``on_user_online`` / ``on_user_offline`` payload, or ``None`` without a uid."""
472
+ if (uid := as_int(message.get("uid"))) is None:
473
+ return None
474
+ return UserEvent(uid, _as_str(message.get("reason")))
475
+
476
+
477
+ def parse_notification(message: Mapping[str, object]) -> Notification:
478
+ """An ``on_notification`` payload."""
479
+ return Notification(_as_str(message.get("action")), as_int(message.get("code")), _as_str(message.get("detail")))
480
+
481
+
482
+ def is_quit(notification: Notification) -> bool:
483
+ """Whether the gateway has quit this session (``action: quit``, e.g. code 2003 repeat join)."""
484
+ return notification.action == NOTIFICATION_QUIT
485
+
486
+
487
+ def parse_p2p_lost(frame: GatewayFrame) -> P2PLost:
488
+ """An ``on_p2p_lost`` event, read from ``_message`` (shipped code read the top level, protocol.md §5).
489
+
490
+ Each field falls back to the top level only when ``_message`` lacks it, until a capture settles it.
491
+ """
492
+ code = as_int(frame.message.get("error_code"))
493
+ error = _as_str(frame.message.get("error_str"))
494
+ return P2PLost(
495
+ code if code is not None else as_int(frame.raw.get("error_code")),
496
+ error if error is not None else _as_str(frame.raw.get("error_str")),
497
+ )
498
+
499
+
500
+ def parse_p2p_ok(message: Mapping[str, object]) -> P2POk:
501
+ """An ``on_p2p_ok`` payload."""
502
+ return P2POk(as_int(message.get("uid")), message.get("proxy") is True)
503
+
504
+
505
+ def parse_rtp_capability_change(message: Mapping[str, object]) -> RtpCapabilities:
506
+ """An ``on_rtp_capability_change`` payload; non-string codec entries are dropped."""
507
+ codecs = message.get("video_codec")
508
+ return RtpCapabilities(
509
+ tuple(c for c in codecs if isinstance(c, str)) if isinstance(codecs, list) else (),
510
+ message.get("extmap_allow_mixed") is True,
511
+ message.get("web_av1_svc") is True,
512
+ )
513
+
514
+
515
+ def parse_error(frame: GatewayFrame) -> ErrorInfo:
516
+ """The error carried by an ``error`` event (``error``) or a failed response (``error_code || code``, ``error_str``)."""
517
+ message = frame.message
518
+ code = as_int(message.get("error_code")) or as_int(message.get("code"))
519
+ return ErrorInfo(code, _as_str(message.get("error_str")) or _as_str(message.get("error")))
520
+
521
+
522
+ def _request(frame_type: FrameType, request_id: str, message: JsonObject) -> JsonObject:
523
+ return {"_id": request_id, "_type": frame_type.value, "_message": message}
524
+
525
+
526
+ def _collect_video_streams(node: object, found: dict[tuple[int, int], RemoteStream], depth: int = 0) -> None:
527
+ if depth > _MAX_STREAM_SEARCH_DEPTH:
528
+ _LOGGER.debug("Join payload nests deeper than %s levels; not searched further", _MAX_STREAM_SEARCH_DEPTH)
529
+ return
530
+ if isinstance(node, dict):
531
+ if stream := _existing_video_stream(node):
532
+ found.setdefault((stream.uid, stream.ssrc), stream)
533
+ for value in node.values():
534
+ _collect_video_streams(value, found, depth + 1)
535
+ elif isinstance(node, list):
536
+ for item in node:
537
+ _collect_video_streams(item, found, depth + 1)
538
+
539
+
540
+ def _existing_video_stream(node: Mapping[str, object]) -> RemoteStream | None:
541
+ has_video_marker = (
542
+ node.get("video") is True
543
+ or node.get("stream_type") == "video"
544
+ or node.get("codec") in _VIDEO_CODEC_MARKERS
545
+ or node.get("rtxSsrcId") is not None
546
+ )
547
+ uid, ssrc = node.get("uid"), node.get("ssrcId")
548
+ if not has_video_marker or not _is_int(uid) or not _is_int(ssrc):
549
+ return None
550
+ return _stream_from(node, uid, ssrc)
551
+
552
+
553
+ def _stream_from(message: Mapping[str, object], uid: int, ssrc: int) -> RemoteStream:
554
+ codec = _as_str(message.get("codec"))
555
+ return RemoteStream(
556
+ uid=uid,
557
+ ssrc=ssrc,
558
+ rtx_ssrc=as_int(message.get("rtxSsrcId")),
559
+ codec=codec.lower() if codec else None,
560
+ payload_type=as_int(message.get("pt")),
561
+ cname=_as_str(message.get("cname")),
562
+ )
563
+
564
+
565
+ def _is_int(value: object) -> TypeIs[int]:
566
+ return isinstance(value, int) and not isinstance(value, bool)
567
+
568
+
569
+ def _as_str(value: object) -> str | None:
570
+ return value if isinstance(value, str) else None