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/const.py ADDED
@@ -0,0 +1,55 @@
1
+ """Hosts, protocol constants and timers; the only place a URL, service id or duration is spelled out.
2
+
3
+ Sources are noted per value: ``sdk`` means observed in Agora's Web SDK 4.24.x, ``shipped``
4
+ means the value the Mammotion or PetKit integration ran with in production.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ SDK_VERSION = "4.24.3" # sdk: sent as sdk_version in join_v3
10
+
11
+ AP_HOSTS: tuple[str, ...] = ( # sdk: primary, then backup
12
+ "https://webrtc2-ap-web-1.agora.io",
13
+ "https://webrtc2-ap-web-2.agora.io",
14
+ "https://webrtc2-ap-web-3.agora.io",
15
+ "https://webrtc2-ap-web-4.agora.io",
16
+ )
17
+ AP_PATH = "/api/v2/transpond/webrtc" # sdk
18
+
19
+ RTM_HOSTS: tuple[str, ...] = ("https://api.agora.io", "https://api.sd-rtn.com") # shipped (PetKit)
20
+ RTM_PEER_MESSAGES_PATH = "/dev/v2/project/{app_id}/rtm/users/{user_id}/peer_messages" # shipped (PetKit)
21
+
22
+ # AP request uris and service ids (docs/protocol.md §1)
23
+ AP_URI_CHOOSE_SERVER = 22
24
+ AP_URI_UPDATE_TICKET = 28
25
+ SERVICE_GATEWAY = 11
26
+ SERVICE_TURN = 26
27
+ DEFAULT_SERVICE_IDS: tuple[int, ...] = (SERVICE_GATEWAY, SERVICE_TURN)
28
+ AP_FLAG_GATEWAY = 4096 # the response block whose scalars (ticket, uid, cid) are primary
29
+ AP_FLAG_TURN = 4194310
30
+ AP_QUERY = "?v=2" # sdk
31
+ EDGE_DOMAIN_SUFFIX = ".edge.agora.io" # sdk: wss://<ip-dashed>.edge.agora.io:<port>
32
+ TURN_PORT = 3478 # sdk
33
+ TURNS_PORT = 443 # sdk
34
+ DEFAULT_AREA_CODE = "CN,GLOBAL" # sdk
35
+ ROLE_HOST = 1 # shipped: AP detail 17, the client role
36
+
37
+ GATEWAY_TURN_PORT_OFFSET = 30 # sdk: serversFromGateway uses the gateway port + 30
38
+
39
+ # Timers, in seconds
40
+ AP_TIMEOUT_S = 10.0
41
+ GATEWAY_CONNECT_TIMEOUT_S = 10.0
42
+ JOIN_TIMEOUT_S = 15.0
43
+ GATEWAY_SEND_TIMEOUT_S = 5.0 # bounds the join and leave sends on a stalled socket
44
+ PING_INTERVAL_S = 3.0 # sdk
45
+ RENEW_TOKEN_DEBOUNCE_S = 30.0 # shipped (Mammotion); D8
46
+ KEEPALIVE_INTERVAL_S = 3.0 # shipped (Mammotion 4G FPV); D15
47
+ PEER_REJOIN_DEBOUNCE_S = 2.0 # shipped; D14
48
+ PEER_RECOVER_COOLDOWN_S = 15.0 # shipped
49
+ PEER_RECOVER_MAX_ATTEMPTS = 5 # shipped
50
+ PEER_RECOVER_RESET_S = 600.0 # shipped
51
+ DECLARED_SSRC_TIMEOUT_S = 15.0 # shipped (PetKit): answer without a remote SSRC after this
52
+ RTM_TIMEOUT_S = 10.0
53
+
54
+ DEFAULT_CLIENT_CODEC = "vp8" # shipped (Mammotion); PetKit uses h264
55
+ DEFAULT_ORTC_DTLS_ROLE = "server" # shipped (HA-Luba); D4
@@ -0,0 +1,61 @@
1
+ """The exception hierarchy; nothing else in the package defines an exception type.
2
+
3
+ Each type names a distinct recovery (``docs/architecture.md`` §2). Messages describe the
4
+ condition; no exception ever carries a token, ticket, credential or key.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class PyAgoraRTCError(Exception):
11
+ """Base class for every error raised by this library."""
12
+
13
+
14
+ class APError(PyAgoraRTCError):
15
+ """Edge discovery failed: every access point host was tried and none answered usefully."""
16
+
17
+ def __init__(self, message: str, *, status: int | None = None) -> None:
18
+ super().__init__(message)
19
+ self.status = status
20
+
21
+
22
+ class APRejectedError(APError):
23
+ """The access point answered, but every requested service came back with a non-zero code."""
24
+
25
+ def __init__(self, codes: dict[int, int]) -> None:
26
+ super().__init__(f"access point rejected every service: {codes}")
27
+ self.codes = codes
28
+
29
+
30
+ class SdpError(PyAgoraRTCError):
31
+ """An SDP offer could not be turned into ORTC, or an answer could not be built from the gateway ORTC."""
32
+
33
+
34
+ class GatewayConnectError(PyAgoraRTCError):
35
+ """The gateway WebSocket could not be opened on any edge."""
36
+
37
+
38
+ class JoinRejectedError(PyAgoraRTCError):
39
+ """The gateway answered the join with a failure result."""
40
+
41
+ def __init__(self, code: int | None, message: str) -> None:
42
+ super().__init__(f"join rejected (code={code}): {message}")
43
+ self.code = code
44
+ self.message = message
45
+
46
+
47
+ class JoinTimeoutError(PyAgoraRTCError):
48
+ """The gateway did not answer the join within the configured timeout."""
49
+
50
+
51
+ class SessionClosedError(PyAgoraRTCError):
52
+ """An operation was attempted on a session that has already ended."""
53
+
54
+
55
+ class RtmError(PyAgoraRTCError):
56
+ """An RTM peer message could not be delivered on any endpoint."""
57
+
58
+ def __init__(self, message: str, *, status: int | None = None, code: str | None = None) -> None:
59
+ super().__init__(message)
60
+ self.status = status
61
+ self.code = code
pyagorartc/models.py ADDED
@@ -0,0 +1,223 @@
1
+ """Host-neutral value types shared by every layer (D2, D19).
2
+
3
+ Secrets (tokens, keys, credentials) are redacted in ``repr``; ``fingerprint`` is the only
4
+ form that may reach a log line.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum, IntEnum
11
+ import hashlib
12
+ import re
13
+ from types import MappingProxyType
14
+ from typing import TYPE_CHECKING, overload
15
+
16
+ from pyagorartc.const import (
17
+ DEFAULT_AREA_CODE,
18
+ DEFAULT_CLIENT_CODEC,
19
+ DEFAULT_ORTC_DTLS_ROLE,
20
+ GATEWAY_CONNECT_TIMEOUT_S,
21
+ JOIN_TIMEOUT_S,
22
+ RENEW_TOKEN_DEBOUNCE_S,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping
27
+
28
+ _FINGERPRINT_CHARS = 12
29
+ _INTEGER_TEXT = re.compile(r"[+-]?[0-9]+")
30
+
31
+
32
+ def fingerprint(secret: str) -> str:
33
+ """A short, non-reversible handle for a secret, safe for log lines."""
34
+ return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:_FINGERPRINT_CHARS]
35
+
36
+
37
+ @overload
38
+ def as_int(value: object) -> int | None: ...
39
+ @overload
40
+ def as_int(value: object, default: int) -> int: ...
41
+ def as_int(value: object, default: int | None = None) -> int | None:
42
+ """``value`` as a wire integer: an int (not a bool) or ASCII digits with an optional sign, else ``default``.
43
+
44
+ The one coercion rule for every wire integer, so the AP and gateway parsers agree on ``"12"`` and ``True``.
45
+ """
46
+ if isinstance(value, bool):
47
+ return default
48
+ if isinstance(value, int):
49
+ return value
50
+ if isinstance(value, str) and _INTEGER_TEXT.fullmatch(text := value.strip()):
51
+ return int(text)
52
+ return default
53
+
54
+
55
+ @dataclass(frozen=True, kw_only=True, repr=False)
56
+ class ChannelEncryption:
57
+ """Agora channel encryption parameters; modelled but never sent in the join (D20, Q17).
58
+
59
+ ``mode`` is the SDK's name (``aes-256-gcm2``); ``salt`` is the raw bytes, already
60
+ base64-decoded by the host. Setting it only makes the join log a WARNING.
61
+ """
62
+
63
+ mode: str
64
+ secret: str
65
+ salt: bytes | None = None
66
+
67
+ def __repr__(self) -> str:
68
+ return f"ChannelEncryption(mode={self.mode!r}, secret=<redacted>, salt={'<present>' if self.salt else None})"
69
+
70
+
71
+ @dataclass(frozen=True, kw_only=True, repr=False)
72
+ class ChannelCredentials:
73
+ """Everything needed to join one Agora channel, whichever vendor minted it.
74
+
75
+ Mapping: Mammotion ``appid/channelName/token/uid/areaCode/license``; PetKit
76
+ ``AGORA_APP_ID/channel_id/rtc_token/uid``. Token refresh is a callback on the session,
77
+ so this stays immutable.
78
+ """
79
+
80
+ app_id: str
81
+ channel_name: str
82
+ token: str
83
+ uid: int
84
+ string_uid: str | None = None
85
+ area_code: str = DEFAULT_AREA_CODE
86
+ license: str | None = None
87
+ encryption: ChannelEncryption | None = None
88
+
89
+ def __repr__(self) -> str:
90
+ return (
91
+ f"ChannelCredentials(app_id={fingerprint(self.app_id)!s}, channel_name={self.channel_name!r}, "
92
+ f"token=<{fingerprint(self.token)}>, uid={self.uid!r}, string_uid={self.string_uid!r}, "
93
+ f"area_code={self.area_code!r}, license={'<present>' if self.license else None}, "
94
+ f"encryption={self.encryption!r})"
95
+ )
96
+
97
+
98
+ @dataclass(frozen=True, kw_only=True, repr=False)
99
+ class RtmCredentials:
100
+ """Credentials for Agora RTM peer messages (D18)."""
101
+
102
+ app_id: str
103
+ user_id: str
104
+ peer_user_id: str
105
+ token: str
106
+
107
+ def __repr__(self) -> str:
108
+ return (
109
+ f"RtmCredentials(app_id={fingerprint(self.app_id)}, user_id={self.user_id!r}, "
110
+ f"peer_user_id={self.peer_user_id!r}, token=<{fingerprint(self.token)}>)"
111
+ )
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class IceCandidate:
116
+ """One ICE candidate as a viewer reports it (``candidate:`` line without the ``a=`` prefix)."""
117
+
118
+ candidate: str
119
+ sdp_mid: str | None = None
120
+ sdp_mline_index: int | None = None
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class EdgeAddress:
125
+ """One gateway or TURN edge from an access-point response."""
126
+
127
+ ip: str
128
+ port: int
129
+ username: str | None = None
130
+ credentials: str | None = None
131
+ ticket: str | None = None
132
+ fingerprint: str | None = None
133
+
134
+ def __repr__(self) -> str:
135
+ return (
136
+ f"EdgeAddress(ip={self.ip!r}, port={self.port!r}, username={self.username!r}, "
137
+ f"credentials={'<redacted>' if self.credentials else None}, ticket={'<present>' if self.ticket else None}, "
138
+ f"fingerprint={self.fingerprint!r})"
139
+ )
140
+
141
+
142
+ @dataclass(frozen=True, repr=False)
143
+ class ICEServer:
144
+ """An ICE server entry in the shape a WebRTC consumer expects (``urls``, ``username``, ``credential``)."""
145
+
146
+ urls: list[str]
147
+ username: str | None = None
148
+ credential: str | None = None
149
+
150
+ def __repr__(self) -> str:
151
+ return f"ICEServer(urls={self.urls!r}, username={self.username!r}, credential={'<redacted>' if self.credential else None})"
152
+
153
+
154
+ class TurnMode(IntEnum):
155
+ """Which TURN transports ``get_ice_servers`` emits (the SDK's ``new_turn_mode``)."""
156
+
157
+ ALL = 1
158
+ UDP_ONLY = 2
159
+ TCP_ONLY = 3
160
+ TLS_ONLY = 4
161
+
162
+
163
+ class TurnCredentialStrategy(Enum):
164
+ """Where TURN username/credential come from (D12)."""
165
+
166
+ UID = "uid" # username = str(uid), credential = derive_password(uid)
167
+ DETAIL_FIRST = "detail_first" # AP detail 8/4 when present, else UID
168
+
169
+
170
+ class CloseReason(Enum):
171
+ """Why a session ended; delivered once to ``on_closed`` (D14)."""
172
+
173
+ CLOSED_BY_HOST = "closed_by_host"
174
+ GATEWAY_QUIT = "gateway_quit"
175
+ P2P_LOST = "p2p_lost"
176
+ SOCKET_CLOSED = "socket_closed"
177
+ DEADLINE = "deadline"
178
+ JOIN_FAILED = "join_failed"
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class RemoteStream:
183
+ """A publisher's video stream as announced by the gateway (``on_add_video_stream``)."""
184
+
185
+ uid: int
186
+ ssrc: int
187
+ rtx_ssrc: int | None = None
188
+ codec: str | None = None
189
+ payload_type: int | None = None
190
+ cname: str | None = None
191
+
192
+
193
+ @dataclass(frozen=True, kw_only=True)
194
+ class SessionOptions:
195
+ """Per-session knobs; defaults preserve the Mammotion behaviour that shipped (architecture §4).
196
+
197
+ ``extra_join_attributes`` merges into the join's ``userAttributes`` last; it is stored read-only and left
198
+ out of the hash, so the options stay hashable. ``end_on_p2p_lost``
199
+ is off because the Mammotion integration deliberately ignored ``on_p2p_lost`` (D22);
200
+ PetKit ends the session on it. ``prealloc_pc`` and ``renew_debounce_s`` exist so a host can
201
+ answer Q10 and Q11 without patching.
202
+ """
203
+
204
+ client_codec: str = DEFAULT_CLIENT_CODEC
205
+ target_uid: int | None = None
206
+ send_set_client_role: bool = False
207
+ ortc_dtls_role: str | None = DEFAULT_ORTC_DTLS_ROLE
208
+ instant_video: bool = False
209
+ declare_remote_video_ssrc: bool = False
210
+ disable_audio: bool = False
211
+ subscribe_retry_attempts: int = 0
212
+ subscribe_retry_delay_s: float = 0.0
213
+ strip_mid_extension: bool = True
214
+ prealloc_pc: bool = True
215
+ end_on_p2p_lost: bool = False
216
+ renew_debounce_s: float = RENEW_TOKEN_DEBOUNCE_S
217
+ join_timeout_s: float = JOIN_TIMEOUT_S
218
+ connect_timeout_s: float = GATEWAY_CONNECT_TIMEOUT_S
219
+ verify_ssl: bool = True
220
+ extra_join_attributes: Mapping[str, object] = field(default_factory=lambda: MappingProxyType({}), hash=False)
221
+
222
+ def __post_init__(self) -> None:
223
+ object.__setattr__(self, "extra_join_attributes", MappingProxyType(dict(self.extra_join_attributes)))
pyagorartc/py.typed ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ """Agora RTM peer messages over REST."""
2
+
3
+ from pyagorartc.rtm.client import DEFAULT_ACCEPTED_CODES, RtmRestClient
4
+
5
+ __all__ = ["DEFAULT_ACCEPTED_CODES", "RtmRestClient"]
@@ -0,0 +1,170 @@
1
+ """Agora RTM peer messages over the REST API: request shape, endpoint rotation and ack semantics (D18)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import json
8
+ import logging
9
+ from typing import TYPE_CHECKING, Self
10
+ from urllib.parse import quote
11
+
12
+ import aiohttp
13
+
14
+ from pyagorartc.const import RTM_HOSTS, RTM_PEER_MESSAGES_PATH, RTM_TIMEOUT_S
15
+ from pyagorartc.exceptions import RtmError
16
+ from pyagorartc.models import fingerprint
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping, Sequence
20
+ from types import TracebackType
21
+
22
+ from pyagorartc.models import RtmCredentials
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+ DEFAULT_ACCEPTED_CODES: frozenset[str] = frozenset({"message_sent", "message_delivered"})
27
+ _HTTP_OK = 200
28
+ _HTTP_NOT_FOUND = 404
29
+ _HTTP_TOO_MANY_REQUESTS = 429
30
+ _HTTP_SERVER_ERROR = 500
31
+
32
+
33
+ def _is_rotating_status(status: int) -> bool:
34
+ return status in {_HTTP_NOT_FOUND, _HTTP_TOO_MANY_REQUESTS} or status >= _HTTP_SERVER_ERROR
35
+
36
+
37
+ class RtmRestClient:
38
+ """Sends RTM peer messages from ``creds.user_id`` to ``creds.peer_user_id``.
39
+
40
+ The message vocabulary (what goes in ``payload``, how often) belongs to the host. A
41
+ borrowed ``session`` is never closed; one the client creates is closed by ``close()``.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ creds: RtmCredentials,
47
+ session: aiohttp.ClientSession | None = None,
48
+ *,
49
+ hosts: Sequence[str] = RTM_HOSTS,
50
+ verify_ssl: bool = True,
51
+ timeout_s: float = RTM_TIMEOUT_S,
52
+ ) -> None:
53
+ self._creds = creds
54
+ self._session = session
55
+ self._owns_session = session is None
56
+ self._hosts = tuple(hosts)
57
+ self._verify_ssl = verify_ssl
58
+ self._timeout = aiohttp.ClientTimeout(total=timeout_s)
59
+ self._preferred_host: str | None = None
60
+ self._send_lock = asyncio.Lock()
61
+
62
+ def __repr__(self) -> str:
63
+ return f"RtmRestClient(creds={self._creds!r}, hosts={self._hosts!r}, preferred_host={self._preferred_host!r})"
64
+
65
+ async def __aenter__(self) -> Self:
66
+ return self
67
+
68
+ async def __aexit__(
69
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
70
+ ) -> None:
71
+ await self.close()
72
+
73
+ def update_token(self, token: str) -> None:
74
+ """Use ``token`` from the next request on; RTM tokens rotate while a client lives."""
75
+ self._creds = dataclasses.replace(self._creds, token=token)
76
+ _LOGGER.info("RTM token updated (fp=%s)", fingerprint(token))
77
+
78
+ async def close(self) -> None:
79
+ """Close the HTTP session if this client created it."""
80
+ if self._owns_session and self._session is not None:
81
+ session, self._session = self._session, None
82
+ await session.close()
83
+
84
+ async def send_peer_message(
85
+ self,
86
+ payload: Mapping[str, object],
87
+ *,
88
+ wait_for_ack: bool = True,
89
+ accepted_codes: frozenset[str] = DEFAULT_ACCEPTED_CODES,
90
+ ) -> str:
91
+ """Send ``payload`` (serialised to a compact JSON string) to the peer and return the ack code.
92
+
93
+ A 404, 429, 5xx, connection error or timeout moves to the next host; the host that
94
+ last succeeded is tried first on later calls.
95
+
96
+ Raises:
97
+ RtmError: every host failed (``status`` is the last HTTP status, or ``None`` if the
98
+ last host was unreachable), the server refused the request with another status,
99
+ or it answered 200 with a result or ``code`` outside ``accepted_codes`` (compared case-insensitively).
100
+ """
101
+ creds = self._creds
102
+ path = RTM_PEER_MESSAGES_PATH.format(app_id=creds.app_id, user_id=quote(creds.user_id, safe=""))
103
+ query = "?wait_for_ack=true" if wait_for_ack else ""
104
+ headers = {
105
+ "Content-Type": "application/json",
106
+ "x-agora-token": creds.token,
107
+ "x-agora-uid": creds.user_id,
108
+ "Authorization": f"agora token={creds.token}",
109
+ }
110
+ body = {
111
+ "destination": creds.peer_user_id,
112
+ "enable_offline_messaging": False,
113
+ "enable_historical_messaging": False,
114
+ "payload": json.dumps(payload, separators=(",", ":")),
115
+ }
116
+ accepted = frozenset(code.lower() for code in accepted_codes)
117
+ last_status: int | None = None
118
+ async with self._send_lock:
119
+ for host in self._iter_endpoints():
120
+ url = f"{host}{path}{query}"
121
+ try:
122
+ status, data = await self._post(url, headers, body)
123
+ except (aiohttp.ClientError, TimeoutError) as exc:
124
+ _LOGGER.debug("RTM POST %s failed: %s", url, type(exc).__name__)
125
+ last_status = None
126
+ continue
127
+ if _is_rotating_status(status):
128
+ _LOGGER.debug("RTM POST %s -> %s, trying next host", url, status)
129
+ last_status = status
130
+ continue
131
+ return self._accept(url, status, data, accepted, host)
132
+ raise RtmError("RTM peer message failed on every host", status=last_status)
133
+
134
+ def _accept(self, url: str, status: int, data: object, accepted_codes: frozenset[str], host: str) -> str:
135
+ if status != _HTTP_OK:
136
+ raise RtmError(f"RTM peer message refused with HTTP {status}", status=status)
137
+ fields = data if isinstance(data, dict) else {}
138
+ result = str(fields.get("result", "")).lower()
139
+ code = str(fields.get("code", "")).lower()
140
+ _LOGGER.debug("RTM POST %s -> %s result=%s code=%s", url, status, result, code)
141
+ if result != "success" or code not in accepted_codes:
142
+ raise RtmError(
143
+ f"RTM peer message not accepted: result={result} code={code}", status=status, code=code or None
144
+ )
145
+ self._preferred_host = host
146
+ return code
147
+
148
+ def _iter_endpoints(self) -> list[str]:
149
+ """The hosts in order, the last one that succeeded first."""
150
+ hosts = list(self._hosts)
151
+ if self._preferred_host in hosts:
152
+ hosts.remove(self._preferred_host)
153
+ hosts.insert(0, self._preferred_host)
154
+ return hosts
155
+
156
+ def _http(self) -> aiohttp.ClientSession:
157
+ if self._session is None:
158
+ self._session = aiohttp.ClientSession()
159
+ return self._session
160
+
161
+ async def _post(self, url: str, headers: Mapping[str, str], body: Mapping[str, object]) -> tuple[int, object]:
162
+ """POST one request; returns the status and the decoded JSON body (``None`` if not JSON)."""
163
+ async with self._http().post(
164
+ url, headers=headers, json=body, timeout=self._timeout, ssl=self._verify_ssl
165
+ ) as response:
166
+ text = await response.text(errors="replace")
167
+ try:
168
+ return response.status, json.loads(text) if text else None
169
+ except (ValueError, RecursionError):
170
+ return response.status, None
@@ -0,0 +1,26 @@
1
+ """Pure SDP ↔ ORTC conversion (docs/architecture.md §1)."""
2
+
3
+ from pyagorartc.sdp.answer import STRIPPED_EXTENSIONS, answer_from_ortc, offers_rtx, setup_for_role, validate_answer
4
+ from pyagorartc.sdp.candidates import (
5
+ candidates_to_ortc,
6
+ extract_inline_candidates,
7
+ filter_candidates,
8
+ parse_trickle_fragment,
9
+ )
10
+ from pyagorartc.sdp.offer import can_send, negotiated_caps, offer_to_ortc, parse_offer
11
+
12
+ __all__ = [
13
+ "STRIPPED_EXTENSIONS",
14
+ "answer_from_ortc",
15
+ "can_send",
16
+ "candidates_to_ortc",
17
+ "extract_inline_candidates",
18
+ "filter_candidates",
19
+ "negotiated_caps",
20
+ "offer_to_ortc",
21
+ "offers_rtx",
22
+ "parse_offer",
23
+ "parse_trickle_fragment",
24
+ "setup_for_role",
25
+ "validate_answer",
26
+ ]