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.
- pyagorartc/__init__.py +67 -0
- pyagorartc/ap/__init__.py +17 -0
- pyagorartc/ap/client.py +244 -0
- pyagorartc/ap/password.py +14 -0
- pyagorartc/ap/response.py +329 -0
- pyagorartc/const.py +55 -0
- pyagorartc/exceptions.py +61 -0
- pyagorartc/models.py +223 -0
- pyagorartc/py.typed +0 -0
- pyagorartc/rtm/__init__.py +5 -0
- pyagorartc/rtm/client.py +170 -0
- pyagorartc/sdp/__init__.py +26 -0
- pyagorartc/sdp/answer.py +320 -0
- pyagorartc/sdp/candidates.py +103 -0
- pyagorartc/sdp/offer.py +260 -0
- pyagorartc/session/__init__.py +87 -0
- pyagorartc/session/messages.py +570 -0
- pyagorartc/session/recovery.py +166 -0
- pyagorartc/session/session.py +696 -0
- pyagorartc/session/transport.py +130 -0
- pyagorartc-0.2.0.dist-info/METADATA +151 -0
- pyagorartc-0.2.0.dist-info/RECORD +24 -0
- pyagorartc-0.2.0.dist-info/WHEEL +4 -0
- pyagorartc-0.2.0.dist-info/licenses/LICENSE +674 -0
pyagorartc/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""pyagorartc: an async client for Agora RTC signalling.
|
|
2
|
+
|
|
3
|
+
``__all__`` is the supported surface (Constitution §10).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pyagorartc.ap import AgoraAPClient, APResponse
|
|
9
|
+
from pyagorartc.exceptions import (
|
|
10
|
+
APError,
|
|
11
|
+
APRejectedError,
|
|
12
|
+
GatewayConnectError,
|
|
13
|
+
JoinRejectedError,
|
|
14
|
+
JoinTimeoutError,
|
|
15
|
+
PyAgoraRTCError,
|
|
16
|
+
RtmError,
|
|
17
|
+
SdpError,
|
|
18
|
+
SessionClosedError,
|
|
19
|
+
)
|
|
20
|
+
from pyagorartc.models import (
|
|
21
|
+
ChannelCredentials,
|
|
22
|
+
ChannelEncryption,
|
|
23
|
+
CloseReason,
|
|
24
|
+
EdgeAddress,
|
|
25
|
+
IceCandidate,
|
|
26
|
+
ICEServer,
|
|
27
|
+
RemoteStream,
|
|
28
|
+
RtmCredentials,
|
|
29
|
+
SessionOptions,
|
|
30
|
+
TurnCredentialStrategy,
|
|
31
|
+
TurnMode,
|
|
32
|
+
fingerprint,
|
|
33
|
+
)
|
|
34
|
+
from pyagorartc.rtm import RtmRestClient
|
|
35
|
+
from pyagorartc.sdp import extract_inline_candidates, filter_candidates, parse_trickle_fragment
|
|
36
|
+
from pyagorartc.session import AgoraSession
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"APError",
|
|
40
|
+
"APRejectedError",
|
|
41
|
+
"APResponse",
|
|
42
|
+
"AgoraAPClient",
|
|
43
|
+
"AgoraSession",
|
|
44
|
+
"ChannelCredentials",
|
|
45
|
+
"ChannelEncryption",
|
|
46
|
+
"CloseReason",
|
|
47
|
+
"EdgeAddress",
|
|
48
|
+
"GatewayConnectError",
|
|
49
|
+
"ICEServer",
|
|
50
|
+
"IceCandidate",
|
|
51
|
+
"JoinRejectedError",
|
|
52
|
+
"JoinTimeoutError",
|
|
53
|
+
"PyAgoraRTCError",
|
|
54
|
+
"RemoteStream",
|
|
55
|
+
"RtmCredentials",
|
|
56
|
+
"RtmError",
|
|
57
|
+
"RtmRestClient",
|
|
58
|
+
"SdpError",
|
|
59
|
+
"SessionClosedError",
|
|
60
|
+
"SessionOptions",
|
|
61
|
+
"TurnCredentialStrategy",
|
|
62
|
+
"TurnMode",
|
|
63
|
+
"extract_inline_candidates",
|
|
64
|
+
"filter_candidates",
|
|
65
|
+
"fingerprint",
|
|
66
|
+
"parse_trickle_fragment",
|
|
67
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Access-point client: edge discovery and tickets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyagorartc.ap.client import AgoraAPClient, build_request_payload, encode_request
|
|
6
|
+
from pyagorartc.ap.password import derive_password
|
|
7
|
+
from pyagorartc.ap.response import APBlock, APResponse, fingerprints_from_edge
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"APBlock",
|
|
11
|
+
"APResponse",
|
|
12
|
+
"AgoraAPClient",
|
|
13
|
+
"build_request_payload",
|
|
14
|
+
"derive_password",
|
|
15
|
+
"encode_request",
|
|
16
|
+
"fingerprints_from_edge",
|
|
17
|
+
]
|
pyagorartc/ap/client.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""``AgoraAPClient``: the access-point HTTP call (``choose_server``, ``update_ticket``) with host fallback.
|
|
2
|
+
|
|
3
|
+
Every HTTP exchange goes through ``AgoraAPClient._post``, the same seam ``RtmRestClient`` has, so a unit
|
|
4
|
+
test replaces that one method on the instance with a recorder instead of mocking aiohttp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from typing import TYPE_CHECKING, Self
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
|
|
18
|
+
from pyagorartc.ap.response import APResponse
|
|
19
|
+
from pyagorartc.const import (
|
|
20
|
+
AP_HOSTS,
|
|
21
|
+
AP_PATH,
|
|
22
|
+
AP_QUERY,
|
|
23
|
+
AP_TIMEOUT_S,
|
|
24
|
+
AP_URI_CHOOSE_SERVER,
|
|
25
|
+
AP_URI_UPDATE_TICKET,
|
|
26
|
+
DEFAULT_SERVICE_IDS,
|
|
27
|
+
ROLE_HOST,
|
|
28
|
+
SERVICE_GATEWAY,
|
|
29
|
+
)
|
|
30
|
+
from pyagorartc.exceptions import APError, APRejectedError
|
|
31
|
+
from pyagorartc.models import fingerprint
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
35
|
+
from types import TracebackType
|
|
36
|
+
|
|
37
|
+
from pyagorartc.models import ChannelCredentials, EdgeAddress
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_REQUEST_FIELD = "request"
|
|
42
|
+
_HTTP_OK = 200
|
|
43
|
+
_SID_BOUND = 2**31 # shipped: sid is a random int below 2**31, sent as a string
|
|
44
|
+
_OPID_BOUND = 10**12
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_request_payload( # noqa: PLR0913 -- pure: each wire value arrives as an argument
|
|
48
|
+
creds: ChannelCredentials,
|
|
49
|
+
*,
|
|
50
|
+
uri: int,
|
|
51
|
+
service_ids: Sequence[int],
|
|
52
|
+
sid: str,
|
|
53
|
+
opid: int,
|
|
54
|
+
client_ts: int,
|
|
55
|
+
role: int = ROLE_HOST,
|
|
56
|
+
edges: Sequence[EdgeAddress] = (),
|
|
57
|
+
) -> dict[str, object]:
|
|
58
|
+
"""The AP request (protocol.md §1.1); pure, so ids and ``client_ts`` (ms) come from the caller.
|
|
59
|
+
|
|
60
|
+
``detail`` carries ``11``/``22`` (area code) and ``17`` (role), plus ``6`` when the credentials have a
|
|
61
|
+
string uid (Q9). ``edges`` become ``edges_services`` for ``update_ticket`` and are omitted when empty.
|
|
62
|
+
"""
|
|
63
|
+
detail: dict[str, str] = {"11": creds.area_code}
|
|
64
|
+
if role:
|
|
65
|
+
detail["17"] = str(role)
|
|
66
|
+
detail["22"] = creds.area_code
|
|
67
|
+
if creds.string_uid:
|
|
68
|
+
detail["6"] = creds.string_uid
|
|
69
|
+
buffer: dict[str, object] = {
|
|
70
|
+
"cname": creds.channel_name,
|
|
71
|
+
"detail": detail,
|
|
72
|
+
"key": creds.token,
|
|
73
|
+
"service_ids": list(service_ids),
|
|
74
|
+
"uid": creds.uid,
|
|
75
|
+
}
|
|
76
|
+
if edges:
|
|
77
|
+
buffer["edges_services"] = [{"ip": edge.ip, "port": edge.port} for edge in edges]
|
|
78
|
+
return {
|
|
79
|
+
"appid": creds.app_id,
|
|
80
|
+
"client_ts": client_ts,
|
|
81
|
+
"opid": opid,
|
|
82
|
+
"sid": sid,
|
|
83
|
+
"request_bodies": [{"uri": uri, "buffer": buffer}],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def encode_request(payload: Mapping[str, object]) -> str:
|
|
88
|
+
"""The exact text sent in the form's ``request`` field."""
|
|
89
|
+
return json.dumps(payload)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _endpoint_url(host: str, proxy_server: str | None) -> str:
|
|
93
|
+
if proxy_server is None:
|
|
94
|
+
return f"{host}{AP_PATH}{AP_QUERY}"
|
|
95
|
+
return f"https://{proxy_server}/ap/?url={urlsplit(host).netloc}{AP_PATH}{AP_QUERY}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AgoraAPClient:
|
|
99
|
+
"""Edge discovery against Agora's access points, trying each host in order.
|
|
100
|
+
|
|
101
|
+
A borrowed ``session`` is never closed; without one the client creates its own on first use and
|
|
102
|
+
closes it in ``close()`` / ``__aexit__``. TLS is verified unless ``verify_ssl=False`` (D10).
|
|
103
|
+
``id_factory``, when given, supplies the ``sid`` (when not passed) and then the ``opid`` of each
|
|
104
|
+
request; by default they are drawn from ``secrets`` in the SDK's ranges.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
session: aiohttp.ClientSession | None = None,
|
|
110
|
+
*,
|
|
111
|
+
hosts: Sequence[str] = AP_HOSTS,
|
|
112
|
+
verify_ssl: bool = True,
|
|
113
|
+
timeout_s: float = AP_TIMEOUT_S,
|
|
114
|
+
clock: Callable[[], float] = time.time,
|
|
115
|
+
id_factory: Callable[[], int] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self._session = session
|
|
118
|
+
self._owns_session = session is None
|
|
119
|
+
self._hosts = tuple(hosts)
|
|
120
|
+
self._verify_ssl = verify_ssl
|
|
121
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout_s)
|
|
122
|
+
self._clock = clock
|
|
123
|
+
self._id_factory = id_factory
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
return f"AgoraAPClient(hosts={list(self._hosts)!r}, verify_ssl={self._verify_ssl}, owns_session={self._owns_session})"
|
|
127
|
+
|
|
128
|
+
async def __aenter__(self) -> Self:
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
async def __aexit__(
|
|
132
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
133
|
+
) -> None:
|
|
134
|
+
await self.close()
|
|
135
|
+
|
|
136
|
+
async def close(self) -> None:
|
|
137
|
+
"""Close the session this client created, if any; a borrowed session is left open."""
|
|
138
|
+
if self._owns_session and self._session is not None:
|
|
139
|
+
session, self._session = self._session, None
|
|
140
|
+
await session.close()
|
|
141
|
+
|
|
142
|
+
async def choose_server(
|
|
143
|
+
self,
|
|
144
|
+
creds: ChannelCredentials,
|
|
145
|
+
*,
|
|
146
|
+
role: int = ROLE_HOST,
|
|
147
|
+
service_ids: Sequence[int] = DEFAULT_SERVICE_IDS,
|
|
148
|
+
sid: str | None = None,
|
|
149
|
+
proxy_server: str | None = None,
|
|
150
|
+
) -> APResponse:
|
|
151
|
+
"""Ask for gateway (and by default TURN) edges for ``creds`` (URI 22).
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
APRejectedError: An AP answered and every requested service failed.
|
|
155
|
+
APError: No host gave a usable answer; ``status`` is the last host's HTTP status, if any.
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
return await self._request(
|
|
159
|
+
creds, uri=AP_URI_CHOOSE_SERVER, service_ids=service_ids, sid=sid, role=role, proxy_server=proxy_server
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def update_ticket(
|
|
163
|
+
self,
|
|
164
|
+
creds: ChannelCredentials,
|
|
165
|
+
edges: Sequence[EdgeAddress],
|
|
166
|
+
*,
|
|
167
|
+
sid: str | None = None,
|
|
168
|
+
service_ids: Sequence[int] = (SERVICE_GATEWAY,),
|
|
169
|
+
proxy_server: str | None = None,
|
|
170
|
+
) -> APResponse:
|
|
171
|
+
"""Refresh the ticket for known ``edges`` (URI 28); raises as ``choose_server`` does."""
|
|
172
|
+
return await self._request(
|
|
173
|
+
creds, uri=AP_URI_UPDATE_TICKET, service_ids=service_ids, sid=sid, edges=edges, proxy_server=proxy_server
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _next_id(self, bound: int) -> int:
|
|
177
|
+
return self._id_factory() if self._id_factory is not None else secrets.randbelow(bound)
|
|
178
|
+
|
|
179
|
+
def _http(self) -> aiohttp.ClientSession:
|
|
180
|
+
if self._session is None:
|
|
181
|
+
self._session = aiohttp.ClientSession()
|
|
182
|
+
return self._session
|
|
183
|
+
|
|
184
|
+
async def _post(self, url: str, headers: Mapping[str, str], body: Mapping[str, object]) -> tuple[int, object]:
|
|
185
|
+
"""POST ``body`` as the multipart ``request`` field; returns the status and decoded JSON (``None`` if not JSON)."""
|
|
186
|
+
form = aiohttp.FormData()
|
|
187
|
+
form.add_field(_REQUEST_FIELD, encode_request(body), content_type="application/json")
|
|
188
|
+
async with self._http().post(
|
|
189
|
+
url, headers=headers, data=form, timeout=self._timeout, ssl=self._verify_ssl
|
|
190
|
+
) as response:
|
|
191
|
+
text = await response.text(errors="replace")
|
|
192
|
+
try:
|
|
193
|
+
return response.status, json.loads(text) if text else None
|
|
194
|
+
except (ValueError, RecursionError):
|
|
195
|
+
return response.status, None
|
|
196
|
+
|
|
197
|
+
async def _request(
|
|
198
|
+
self,
|
|
199
|
+
creds: ChannelCredentials,
|
|
200
|
+
*,
|
|
201
|
+
uri: int,
|
|
202
|
+
service_ids: Sequence[int],
|
|
203
|
+
sid: str | None,
|
|
204
|
+
proxy_server: str | None,
|
|
205
|
+
role: int = ROLE_HOST,
|
|
206
|
+
edges: Sequence[EdgeAddress] = (),
|
|
207
|
+
) -> APResponse:
|
|
208
|
+
sid = str(self._next_id(_SID_BOUND)) if sid is None else sid
|
|
209
|
+
payload = build_request_payload(
|
|
210
|
+
creds,
|
|
211
|
+
uri=uri,
|
|
212
|
+
service_ids=service_ids,
|
|
213
|
+
sid=sid,
|
|
214
|
+
opid=self._next_id(_OPID_BOUND),
|
|
215
|
+
client_ts=int(self._clock() * 1000),
|
|
216
|
+
role=role,
|
|
217
|
+
edges=edges,
|
|
218
|
+
)
|
|
219
|
+
_LOGGER.debug(
|
|
220
|
+
"AP request uri=%s channel=%s services=%s token=%s",
|
|
221
|
+
uri,
|
|
222
|
+
creds.channel_name,
|
|
223
|
+
list(service_ids),
|
|
224
|
+
fingerprint(creds.token),
|
|
225
|
+
)
|
|
226
|
+
status: int | None = None
|
|
227
|
+
for host in self._hosts:
|
|
228
|
+
url = _endpoint_url(host, proxy_server)
|
|
229
|
+
try:
|
|
230
|
+
status, data = await self._post(url, {}, payload)
|
|
231
|
+
except (aiohttp.ClientError, TimeoutError) as exc:
|
|
232
|
+
status = None
|
|
233
|
+
_LOGGER.debug("AP host %s failed: %s", host, type(exc).__name__)
|
|
234
|
+
continue
|
|
235
|
+
if status != _HTTP_OK or not isinstance(data, dict):
|
|
236
|
+
_LOGGER.debug("AP host %s answered HTTP %s with %s", host, status, type(data).__name__)
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
return APResponse.from_api_response(data, clock=self._clock)
|
|
240
|
+
except APRejectedError:
|
|
241
|
+
raise
|
|
242
|
+
except APError:
|
|
243
|
+
_LOGGER.debug("AP host %s answered without a response_body", host)
|
|
244
|
+
raise APError(f"no access point host answered usefully ({len(self._hosts)} tried)", status=status)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The uid-derived TURN password Agora's edges accept (D12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def derive_password(uid: int | str) -> str:
|
|
9
|
+
"""The TURN credential for ``uid``: the lowercase hex SHA-256 of its decimal string.
|
|
10
|
+
|
|
11
|
+
This is the Web SDK's ``ENCRYPT_PROXY_USERNAME_AND_PSW`` rule (``agoraRTC_N-4.24.3.js:43740-43755``),
|
|
12
|
+
which shipped in both hosts; the matching username is ``str(uid)``.
|
|
13
|
+
"""
|
|
14
|
+
return hashlib.sha256(str(uid).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""``APResponse``: an access-point answer parsed into edges, tickets, ICE servers and the join's ``ap_response``.
|
|
2
|
+
|
|
3
|
+
Merge rules are ``docs/analysis/divergence.md`` §1.1; the wire shape is ``docs/protocol.md`` §1.3-§1.4.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pyagorartc.ap.password import derive_password
|
|
15
|
+
from pyagorartc.const import (
|
|
16
|
+
AP_FLAG_GATEWAY,
|
|
17
|
+
AP_FLAG_TURN,
|
|
18
|
+
EDGE_DOMAIN_SUFFIX,
|
|
19
|
+
GATEWAY_TURN_PORT_OFFSET,
|
|
20
|
+
TURN_PORT,
|
|
21
|
+
TURNS_PORT,
|
|
22
|
+
)
|
|
23
|
+
from pyagorartc.exceptions import APError, APRejectedError
|
|
24
|
+
from pyagorartc.models import EdgeAddress, ICEServer, TurnCredentialStrategy, TurnMode, as_int, fingerprint
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_DETAIL_FINGERPRINTS = "19"
|
|
32
|
+
_DEFAULT_FINGERPRINT_ALGORITHM = "sha-256"
|
|
33
|
+
_DETAIL_TURN_USERNAME = "8"
|
|
34
|
+
_DETAIL_TURN_CREDENTIAL = "4"
|
|
35
|
+
_MISSING_CODE = -1
|
|
36
|
+
|
|
37
|
+
type JsonObject = Mapping[str, object]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_object(value: object) -> JsonObject:
|
|
41
|
+
return value if isinstance(value, Mapping) else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fingerprints_from_edge(edge: EdgeAddress) -> list[dict[str, str]]:
|
|
45
|
+
"""``edge``'s detail-19 fingerprint as gateway-ORTC ``dtlsParameters.fingerprints`` (D26); empty when it has none.
|
|
46
|
+
|
|
47
|
+
Accepts ``sha-256 AA:BB:…`` and a bare ``AA:BB:…`` (read as sha-256), as both shipped hosts parsed it.
|
|
48
|
+
"""
|
|
49
|
+
match (edge.fingerprint or "").split():
|
|
50
|
+
case [value]:
|
|
51
|
+
return [{"algorithm": _DEFAULT_FINGERPRINT_ALGORITHM, "fingerprint": value}]
|
|
52
|
+
case [algorithm, value]:
|
|
53
|
+
return [{"algorithm": algorithm, "fingerprint": value}]
|
|
54
|
+
case []:
|
|
55
|
+
return []
|
|
56
|
+
case parts:
|
|
57
|
+
_LOGGER.debug("Ignoring an AP edge fingerprint of %s words; expected 'algorithm value'", len(parts))
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_edges(raw: object, ticket: str, detail: JsonObject) -> tuple[EdgeAddress, ...]:
|
|
62
|
+
# Fingerprints are matched to edges by index in the list as sent, before filtering (SDK:48698-48704).
|
|
63
|
+
fingerprints = [fp.strip() or None for fp in str(detail.get(_DETAIL_FINGERPRINTS) or "").split(";")]
|
|
64
|
+
edges: list[EdgeAddress] = []
|
|
65
|
+
for index, entry in enumerate(raw if isinstance(raw, list) else []):
|
|
66
|
+
edge = _as_object(entry)
|
|
67
|
+
ip = edge.get("ip")
|
|
68
|
+
port = as_int(edge.get("port"), 0)
|
|
69
|
+
if not isinstance(ip, str) or not ip or port <= 0:
|
|
70
|
+
continue
|
|
71
|
+
edges.append(
|
|
72
|
+
EdgeAddress(
|
|
73
|
+
ip=ip,
|
|
74
|
+
port=port,
|
|
75
|
+
ticket=ticket,
|
|
76
|
+
fingerprint=fingerprints[index] if index < len(fingerprints) else None,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return tuple(edges)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, repr=False)
|
|
83
|
+
class APBlock:
|
|
84
|
+
"""One successful ``response_body[].buffer``, keyed by its ``flag``.
|
|
85
|
+
|
|
86
|
+
``detail`` is the top-level ``detail`` with the buffer's own merged over it (SDK:43726). Edges
|
|
87
|
+
carry the block's ticket and fingerprint but no TURN credentials; ``get_ice_servers`` resolves
|
|
88
|
+
those per ``TurnCredentialStrategy``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
flag: int
|
|
92
|
+
code: int
|
|
93
|
+
uid: int
|
|
94
|
+
cid: int
|
|
95
|
+
cname: str
|
|
96
|
+
ticket: str
|
|
97
|
+
detail: dict[str, object]
|
|
98
|
+
addresses: tuple[EdgeAddress, ...]
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_buffer(cls, buffer: JsonObject, base_detail: JsonObject) -> APBlock:
|
|
102
|
+
"""Parse one buffer; edges missing an ``ip`` or ``port`` are dropped."""
|
|
103
|
+
detail = {**base_detail, **_as_object(buffer.get("detail"))}
|
|
104
|
+
ticket = str(buffer.get("cert") or "")
|
|
105
|
+
return cls(
|
|
106
|
+
flag=as_int(buffer.get("flag"), 0),
|
|
107
|
+
code=as_int(buffer.get("code"), _MISSING_CODE),
|
|
108
|
+
uid=as_int(buffer.get("uid"), 0),
|
|
109
|
+
cid=as_int(buffer.get("cid"), 0),
|
|
110
|
+
cname=str(buffer.get("cname") or ""),
|
|
111
|
+
ticket=ticket,
|
|
112
|
+
detail=detail,
|
|
113
|
+
addresses=_parse_edges(buffer.get("edges_services"), ticket, detail),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return (
|
|
118
|
+
f"APBlock(flag={self.flag}, code={self.code}, uid={self.uid}, cid={self.cid}, cname={self.cname!r}, "
|
|
119
|
+
f"ticket=<{fingerprint(self.ticket)}>, detail_keys={sorted(self.detail)}, addresses={len(self.addresses)})"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _turn_urls(ip: str, mode: TurnMode) -> list[str]:
|
|
124
|
+
urls: list[str] = []
|
|
125
|
+
if mode in {TurnMode.ALL, TurnMode.UDP_ONLY}:
|
|
126
|
+
urls.append(f"turn:{ip}:{TURN_PORT}?transport=udp")
|
|
127
|
+
if mode in {TurnMode.ALL, TurnMode.TCP_ONLY}:
|
|
128
|
+
urls.append(f"turn:{ip}:{TURN_PORT}?transport=tcp")
|
|
129
|
+
if mode in {TurnMode.ALL, TurnMode.TLS_ONLY}:
|
|
130
|
+
urls.append(f"turns:{ip.replace('.', '-')}{EDGE_DOMAIN_SUFFIX}:{TURNS_PORT}?transport=tcp")
|
|
131
|
+
return urls
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _turn_credentials(block: APBlock, strategy: TurnCredentialStrategy, uid: int) -> tuple[str, str]:
|
|
135
|
+
username, credential = str(uid), derive_password(uid)
|
|
136
|
+
# Only the TURN block's detail 8/4 are credentials; the gateway block's detail 8 is its vid.
|
|
137
|
+
if strategy is TurnCredentialStrategy.DETAIL_FIRST and block.flag == AP_FLAG_TURN:
|
|
138
|
+
# Each key falls back on its own, as the PetKit copy shipped (D12).
|
|
139
|
+
username = str(block.detail.get(_DETAIL_TURN_USERNAME) or username)
|
|
140
|
+
credential = str(block.detail.get(_DETAIL_TURN_CREDENTIAL) or credential)
|
|
141
|
+
return username, credential
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, repr=False)
|
|
145
|
+
class APResponse:
|
|
146
|
+
"""A parsed ``choose_server`` / ``update_ticket`` answer.
|
|
147
|
+
|
|
148
|
+
``responses`` holds every successful block by flag, however many there are. The primary block
|
|
149
|
+
is flag 4096 when it succeeded, else the first successful block, and every primary scalar
|
|
150
|
+
(``ticket``, ``uid``, ``cid``, ``cname``, ``detail``, ``addresses``) is read from that one block.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
responses: dict[int, APBlock]
|
|
154
|
+
primary_flag: int
|
|
155
|
+
server_ts: int
|
|
156
|
+
opid: int
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_api_response(cls, data: JsonObject, *, clock: Callable[[], float] = time.time) -> APResponse:
|
|
160
|
+
"""Parse the AP's JSON body; ``clock`` (seconds) stands in for a missing ``enter_ts``.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
APRejectedError: Every buffer carried a non-zero ``code``; ``codes`` maps flag to code.
|
|
164
|
+
APError: The body has no ``response_body`` entries at all.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
body = data.get("response_body")
|
|
168
|
+
if not isinstance(body, list) or not body:
|
|
169
|
+
raise APError("access point response has no response_body")
|
|
170
|
+
base_detail = _as_object(data.get("detail"))
|
|
171
|
+
blocks: dict[int, APBlock] = {}
|
|
172
|
+
failed: dict[int, int] = {}
|
|
173
|
+
for item in body:
|
|
174
|
+
block = APBlock.from_buffer(_as_object(_as_object(item).get("buffer")), base_detail)
|
|
175
|
+
if block.code != 0:
|
|
176
|
+
_LOGGER.debug("AP block flag=%s failed with code=%s; skipped", block.flag, block.code)
|
|
177
|
+
failed[block.flag] = block.code
|
|
178
|
+
continue
|
|
179
|
+
blocks[block.flag] = block
|
|
180
|
+
if not blocks:
|
|
181
|
+
raise APRejectedError(failed)
|
|
182
|
+
enter_ts = data.get("enter_ts")
|
|
183
|
+
return cls(
|
|
184
|
+
responses=blocks,
|
|
185
|
+
primary_flag=AP_FLAG_GATEWAY if AP_FLAG_GATEWAY in blocks else next(iter(blocks)),
|
|
186
|
+
server_ts=int(clock() * 1000) if enter_ts is None else as_int(enter_ts, 0),
|
|
187
|
+
opid=as_int(data.get("opid"), 0),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def primary(self) -> APBlock:
|
|
192
|
+
"""The block every primary scalar comes from."""
|
|
193
|
+
return self.responses[self.primary_flag]
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def code(self) -> int:
|
|
197
|
+
"""The primary block's ``code`` (always 0: failed blocks are not kept)."""
|
|
198
|
+
return self.primary.code
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def ticket(self) -> str:
|
|
202
|
+
"""The primary block's ``cert``."""
|
|
203
|
+
return self.primary.ticket
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def uid(self) -> int:
|
|
207
|
+
"""The uid the AP assigned in the primary block."""
|
|
208
|
+
return self.primary.uid
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def cid(self) -> int:
|
|
212
|
+
"""The primary block's channel id."""
|
|
213
|
+
return self.primary.cid
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def cname(self) -> str:
|
|
217
|
+
"""The primary block's channel name."""
|
|
218
|
+
return self.primary.cname
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def detail(self) -> dict[str, object]:
|
|
222
|
+
"""The primary block's merged ``detail``."""
|
|
223
|
+
return self.primary.detail
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def addresses(self) -> list[EdgeAddress]:
|
|
227
|
+
"""The primary block's edges."""
|
|
228
|
+
return list(self.primary.addresses)
|
|
229
|
+
|
|
230
|
+
def get_gateway_addresses(self) -> list[EdgeAddress]:
|
|
231
|
+
"""The WebSocket gateway edges (flag 4096) in AP order; empty when that block failed."""
|
|
232
|
+
return list(block.addresses) if (block := self.responses.get(AP_FLAG_GATEWAY)) else []
|
|
233
|
+
|
|
234
|
+
def get_turn_addresses(self) -> list[EdgeAddress]:
|
|
235
|
+
"""The TURN edges (flag 4194310) in AP order; empty when that block failed or was not asked for."""
|
|
236
|
+
return list(block.addresses) if (block := self.responses.get(AP_FLAG_TURN)) else []
|
|
237
|
+
|
|
238
|
+
def get_ice_servers(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
use_all_turn_servers: bool = False,
|
|
242
|
+
turn_mode: TurnMode = TurnMode.ALL,
|
|
243
|
+
strategy: TurnCredentialStrategy = TurnCredentialStrategy.UID,
|
|
244
|
+
uid: int | None = None,
|
|
245
|
+
) -> list[ICEServer]:
|
|
246
|
+
"""ICE servers for a viewer: one entry per transport per TURN edge, one url each.
|
|
247
|
+
|
|
248
|
+
UDP and TCP use ``turn:{ip}:3478``; TLS uses ``turns:{a-b-c-d}.edge.agora.io:443``. Without
|
|
249
|
+
TURN edges the primary edges are used, as both hosts shipped. Credentials follow ``strategy``
|
|
250
|
+
(D12), with AP detail 8/4 read only from the TURN block; ``uid`` replaces the block's uid for the
|
|
251
|
+
uid-derived pair.
|
|
252
|
+
"""
|
|
253
|
+
turn = self.responses.get(AP_FLAG_TURN)
|
|
254
|
+
block = turn if turn is not None and turn.addresses else self.primary
|
|
255
|
+
if block is not turn:
|
|
256
|
+
_LOGGER.debug("no TURN edges in the AP response; using flag %s edges", block.flag)
|
|
257
|
+
addresses = block.addresses if use_all_turn_servers else block.addresses[:1]
|
|
258
|
+
username, credential = _turn_credentials(block, strategy, block.uid if uid is None else uid)
|
|
259
|
+
return [
|
|
260
|
+
ICEServer(urls=[url], username=username, credential=credential)
|
|
261
|
+
for address in addresses
|
|
262
|
+
for url in _turn_urls(address.ip, turn_mode)
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
def turn_server_config(self, gateway: EdgeAddress | None, token: str | None) -> dict[str, object]:
|
|
266
|
+
"""The SDK's ``turnServer`` object (protocol.md §1.4); pure, and unused by the session.
|
|
267
|
+
|
|
268
|
+
``servers`` are the TURN edges with uid-derived credentials. ``serversFromGateway`` holds the
|
|
269
|
+
connected gateway on its port + 30 with the channel token as password, only when both are given.
|
|
270
|
+
"""
|
|
271
|
+
servers = [
|
|
272
|
+
{
|
|
273
|
+
"turnServerURL": address.ip,
|
|
274
|
+
"tcpport": address.port,
|
|
275
|
+
"udpport": address.port,
|
|
276
|
+
"username": str(self.uid),
|
|
277
|
+
"password": derive_password(self.uid),
|
|
278
|
+
"forceturn": False,
|
|
279
|
+
"security": True,
|
|
280
|
+
}
|
|
281
|
+
for address in self.get_turn_addresses()
|
|
282
|
+
]
|
|
283
|
+
from_gateway: list[dict[str, object]] = []
|
|
284
|
+
if gateway is not None and token:
|
|
285
|
+
port = gateway.port + GATEWAY_TURN_PORT_OFFSET
|
|
286
|
+
from_gateway.append(
|
|
287
|
+
{
|
|
288
|
+
"username": str(self.uid),
|
|
289
|
+
"password": token,
|
|
290
|
+
"turnServerURL": gateway.ip,
|
|
291
|
+
"tcpport": port,
|
|
292
|
+
"udpport": port,
|
|
293
|
+
"forceturn": False,
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
return {"mode": "manual", "servers": servers, "serversFromGateway": from_gateway}
|
|
297
|
+
|
|
298
|
+
def to_ap_response(self, flag: int | None = None) -> dict[str, object]:
|
|
299
|
+
"""One block (the primary by default) as the join's ``ap_response``: ``cert`` doubled as ``ticket``.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
APError: ``flag`` names a block this response does not hold.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
if flag is None:
|
|
306
|
+
block = self.primary
|
|
307
|
+
elif (found := self.responses.get(flag)) is not None:
|
|
308
|
+
block = found
|
|
309
|
+
else:
|
|
310
|
+
raise APError(f"access point response has no block for flag {flag}")
|
|
311
|
+
return {
|
|
312
|
+
"code": block.code,
|
|
313
|
+
"server_ts": self.server_ts,
|
|
314
|
+
"uid": block.uid,
|
|
315
|
+
"cid": block.cid,
|
|
316
|
+
"cname": block.cname,
|
|
317
|
+
"detail": dict(block.detail),
|
|
318
|
+
"flag": block.flag,
|
|
319
|
+
"opid": self.opid,
|
|
320
|
+
"cert": block.ticket,
|
|
321
|
+
"ticket": block.ticket,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
def __repr__(self) -> str:
|
|
325
|
+
return (
|
|
326
|
+
f"APResponse(primary_flag={self.primary_flag}, flags={list(self.responses)}, uid={self.uid}, "
|
|
327
|
+
f"cid={self.cid}, cname={self.cname!r}, ticket=<{fingerprint(self.ticket)}>, "
|
|
328
|
+
f"server_ts={self.server_ts}, opid={self.opid})"
|
|
329
|
+
)
|