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 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
+ ]
@@ -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
+ )