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/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
|
pyagorartc/exceptions.py
ADDED
|
@@ -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
|
pyagorartc/rtm/client.py
ADDED
|
@@ -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
|
+
]
|