voltkit-rtc-server 0.1.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.
- voltkit_rtc/server/__init__.py +114 -0
- voltkit_rtc/server/_redaction.py +333 -0
- voltkit_rtc/server/_version.py +3 -0
- voltkit_rtc/server/client.py +846 -0
- voltkit_rtc/server/errors.py +278 -0
- voltkit_rtc/server/py.typed +0 -0
- voltkit_rtc/server/transport.py +208 -0
- voltkit_rtc/server/types.py +448 -0
- voltkit_rtc/server/webhooks.py +184 -0
- voltkit_rtc_server-0.1.0.dist-info/METADATA +877 -0
- voltkit_rtc_server-0.1.0.dist-info/RECORD +13 -0
- voltkit_rtc_server-0.1.0.dist-info/WHEEL +4 -0
- voltkit_rtc_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The REST client: rooms, chat, broadcast, usage and billing.
|
|
3
|
+
|
|
4
|
+
Your backend does two things: decide who may do what, and hand the client a
|
|
5
|
+
short-lived room token. It is never in the media path -- there is no stream to
|
|
6
|
+
proxy, no worker to run, nothing to scale with viewers.
|
|
7
|
+
|
|
8
|
+
The API secret must never leave your server. It is tenant-wide: anything
|
|
9
|
+
holding it can create rooms, mint host tokens for any identity, and end
|
|
10
|
+
anyone's broadcast. Clients get a room token instead -- one room, one
|
|
11
|
+
identity, one role, ten minutes. It is attached only to requests for the
|
|
12
|
+
controller: the public rates come from another host, and are fetched
|
|
13
|
+
without it.
|
|
14
|
+
|
|
15
|
+
The same endpoints, bodies, retry policy and errors as the Node SDK
|
|
16
|
+
(``@voltkit-rtc/server``), with snake_case names; replies are the controller's
|
|
17
|
+
JSON as plain dicts and lists, camelCase keys included.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import http.client
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import random
|
|
28
|
+
import socket
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
from typing import Any, Callable, Optional, cast
|
|
32
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
33
|
+
|
|
34
|
+
from ._redaction import KeyRedactor, check_api_key, hide_url_credentials
|
|
35
|
+
from ._version import __version__
|
|
36
|
+
from .errors import StreamError
|
|
37
|
+
from .transport import HttpClientTransport, Transport, TransportError, TransportRequest
|
|
38
|
+
from .types import (
|
|
39
|
+
BroadcastStream,
|
|
40
|
+
BroadcastStreamWithPublish,
|
|
41
|
+
ChatInfo,
|
|
42
|
+
ChatMessage,
|
|
43
|
+
ChatToken,
|
|
44
|
+
DailyUsage,
|
|
45
|
+
Participant,
|
|
46
|
+
PlaybackPolicy,
|
|
47
|
+
ProjectBalance,
|
|
48
|
+
Role,
|
|
49
|
+
RoomList,
|
|
50
|
+
RoomToken,
|
|
51
|
+
RoomView,
|
|
52
|
+
RoomWithHostToken,
|
|
53
|
+
RtcRates,
|
|
54
|
+
StreamAction,
|
|
55
|
+
StreamToken,
|
|
56
|
+
UsageSummary,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = ["DEFAULT_RETRIES", "DEFAULT_TIMEOUT", "MAX_TIMEOUT", "RATES_PATH", "USER_AGENT", "StreamClient"]
|
|
60
|
+
|
|
61
|
+
#: Where the website API serves the public rates.
|
|
62
|
+
RATES_PATH = "/api/v1/billing/rtc/rates/current"
|
|
63
|
+
#: Sent with every request, to the controller and to the rates host.
|
|
64
|
+
USER_AGENT = "voltkit-rtc-python/%s" % __version__
|
|
65
|
+
#: Seconds per attempt, headers and body.
|
|
66
|
+
DEFAULT_TIMEOUT = 10.0
|
|
67
|
+
#: Extra attempts after a transient failure on a call that is safe to repeat.
|
|
68
|
+
DEFAULT_RETRIES = 2
|
|
69
|
+
#: The longest deadline held, in seconds: 2^31-1 ms, about 24.8 days (the longest a Node timer waits).
|
|
70
|
+
MAX_TIMEOUT = 2147483.647
|
|
71
|
+
|
|
72
|
+
_log = logging.getLogger("voltkit_rtc.server")
|
|
73
|
+
|
|
74
|
+
_Json = Any
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Secrets:
|
|
78
|
+
"""
|
|
79
|
+
The key, the URLs as given (credentials and all: where requests go) and
|
|
80
|
+
the key's redactor, on one attribute whose repr says nothing. vars(client)
|
|
81
|
+
and a debugger show ``_secrets=<secrets>``; __slots__ keeps vars() off the
|
|
82
|
+
object itself.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = ("api_key", "base_url", "rates_base_url", "redact_key")
|
|
86
|
+
|
|
87
|
+
def __init__(self, api_key: str, base_url: str, rates_base_url: Optional[str]) -> None:
|
|
88
|
+
self.api_key = api_key
|
|
89
|
+
self.base_url = base_url
|
|
90
|
+
self.rates_base_url = rates_base_url
|
|
91
|
+
self.redact_key = KeyRedactor(api_key)
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return "<secrets>"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StreamClient:
|
|
98
|
+
"""
|
|
99
|
+
A client for the tenant REST API. Make one when your server starts and
|
|
100
|
+
share it: it holds no per-call state and is safe to use from any thread.
|
|
101
|
+
|
|
102
|
+
``base_url`` is the controller, e.g. ``https://api.voltkit.in`` (the API
|
|
103
|
+
base URL on your project's Credentials tab); ``api_key`` your project's
|
|
104
|
+
API secret, server-side only. ``rates_base_url`` is the website API's
|
|
105
|
+
origin, needed only by :meth:`get_rates` and never sent the key.
|
|
106
|
+
``timeout`` is the deadline for each attempt in seconds, headers and body;
|
|
107
|
+
``retries`` how many more attempts a call that is safe to repeat gets
|
|
108
|
+
after a transient failure. ``transport`` replaces the HTTP layer (a proxy,
|
|
109
|
+
instrumentation, a test double); ``sleep`` and ``jitter`` are the backoff's
|
|
110
|
+
clock and randomness, for tests.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
base_url: str,
|
|
116
|
+
api_key: str,
|
|
117
|
+
*,
|
|
118
|
+
rates_base_url: Optional[str] = None,
|
|
119
|
+
timeout: Optional[float] = DEFAULT_TIMEOUT,
|
|
120
|
+
retries: Optional[int] = DEFAULT_RETRIES,
|
|
121
|
+
transport: Optional[Transport] = None,
|
|
122
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
123
|
+
jitter: Optional[Callable[[], float]] = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
origin = _check_origin("base_url", base_url)
|
|
126
|
+
if origin is None:
|
|
127
|
+
raise TypeError("base_url is required")
|
|
128
|
+
sent = check_api_key(api_key)
|
|
129
|
+
# Empty counts as unset, so get_rates says what is missing rather than
|
|
130
|
+
# requesting a relative URL.
|
|
131
|
+
rates = _check_origin("rates_base_url", rates_base_url)
|
|
132
|
+
self._secrets = _Secrets(sent, origin, rates)
|
|
133
|
+
#: ``base_url`` as error messages show it: credentials as [redacted].
|
|
134
|
+
self.base_url = hide_url_credentials(origin)
|
|
135
|
+
#: ``rates_base_url`` the same way, or None.
|
|
136
|
+
self.rates_base_url = hide_url_credentials(rates) if rates is not None else None
|
|
137
|
+
self.timeout = _check_timeout(timeout)
|
|
138
|
+
self.retries = _check_retries(retries)
|
|
139
|
+
self.transport: Transport = transport if transport is not None else HttpClientTransport()
|
|
140
|
+
self._sleep = sleep if sleep is not None else time.sleep
|
|
141
|
+
self._jitter = jitter if jitter is not None else random.random
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_env(
|
|
145
|
+
cls,
|
|
146
|
+
*,
|
|
147
|
+
environ: Optional[Mapping[str, str]] = None,
|
|
148
|
+
rates_base_url: Optional[str] = None,
|
|
149
|
+
timeout: Optional[float] = DEFAULT_TIMEOUT,
|
|
150
|
+
retries: Optional[int] = DEFAULT_RETRIES,
|
|
151
|
+
transport: Optional[Transport] = None,
|
|
152
|
+
) -> StreamClient:
|
|
153
|
+
"""
|
|
154
|
+
A client from ``VOLTKIT_RTC_URL`` and ``VOLTKIT_RTC_SECRET``, and
|
|
155
|
+
``VOLTKIT_API_URL`` for the rates when it is set. The plain constructor
|
|
156
|
+
reads nothing from the environment; this one says so in its name.
|
|
157
|
+
"""
|
|
158
|
+
env = os.environ if environ is None else environ
|
|
159
|
+
base_url = env.get("VOLTKIT_RTC_URL")
|
|
160
|
+
api_key = env.get("VOLTKIT_RTC_SECRET")
|
|
161
|
+
if not base_url:
|
|
162
|
+
raise TypeError("StreamClient.from_env needs VOLTKIT_RTC_URL in the environment: the API base URL")
|
|
163
|
+
if not api_key:
|
|
164
|
+
raise TypeError("StreamClient.from_env needs VOLTKIT_RTC_SECRET in the environment: the API secret")
|
|
165
|
+
if rates_base_url is None:
|
|
166
|
+
rates_base_url = env.get("VOLTKIT_API_URL") or None
|
|
167
|
+
return cls(
|
|
168
|
+
base_url,
|
|
169
|
+
api_key,
|
|
170
|
+
rates_base_url=rates_base_url,
|
|
171
|
+
timeout=timeout,
|
|
172
|
+
retries=retries,
|
|
173
|
+
transport=transport,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# --- the client never shows its secrets ---------------------------------------
|
|
177
|
+
|
|
178
|
+
def __repr__(self) -> str:
|
|
179
|
+
return "StreamClient(base_url=%r, rates_base_url=%r, timeout=%r, retries=%r)" % (
|
|
180
|
+
self.base_url,
|
|
181
|
+
self.rates_base_url,
|
|
182
|
+
self.timeout,
|
|
183
|
+
self.retries,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
__str__ = __repr__
|
|
187
|
+
|
|
188
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
189
|
+
# pickle, copy and deepcopy would carry the key into a file or a
|
|
190
|
+
# queue; a client is made where its key is, not moved.
|
|
191
|
+
raise TypeError("StreamClient cannot be pickled or copied: it holds the API key")
|
|
192
|
+
|
|
193
|
+
# --- rooms -----------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def create_room(
|
|
196
|
+
self, host_identity: str, *, room_id: Optional[str] = None, metadata: _Json = None
|
|
197
|
+
) -> RoomWithHostToken:
|
|
198
|
+
"""
|
|
199
|
+
Create a room. Fails with ``room-exists`` if the id is taken; the server
|
|
200
|
+
makes an id up when ``room_id`` is left out.
|
|
201
|
+
|
|
202
|
+
For "go live", prefer :meth:`ensure_room`: a host reconnecting after a
|
|
203
|
+
dropped call hits the existing-room path, and this turns that into two
|
|
204
|
+
round trips. Never retried: a lost reply would turn a success into a
|
|
205
|
+
spurious ``room-exists``.
|
|
206
|
+
"""
|
|
207
|
+
value, _ = self._request(
|
|
208
|
+
"POST",
|
|
209
|
+
"/v1/rooms",
|
|
210
|
+
body=_compact({"roomId": room_id, "hostIdentity": host_identity, "metadata": metadata}),
|
|
211
|
+
safe_to_repeat=False,
|
|
212
|
+
)
|
|
213
|
+
return cast(RoomWithHostToken, dict(value if isinstance(value, dict) else {}, existed=False))
|
|
214
|
+
|
|
215
|
+
def ensure_room(self, room_id: str, host_identity: str, *, metadata: _Json = None) -> RoomWithHostToken:
|
|
216
|
+
"""
|
|
217
|
+
Create the room, or return it with a fresh host token if it already
|
|
218
|
+
exists. One round trip whether or not the host has been here before.
|
|
219
|
+
``existed`` in the reply says which happened; ``metadata`` is applied
|
|
220
|
+
only when the room is created.
|
|
221
|
+
"""
|
|
222
|
+
value, status = self._request(
|
|
223
|
+
"PUT",
|
|
224
|
+
"/v1/rooms/%s" % _segment("roomId", room_id),
|
|
225
|
+
body=_compact({"hostIdentity": host_identity, "metadata": metadata}),
|
|
226
|
+
safe_to_repeat=True,
|
|
227
|
+
)
|
|
228
|
+
return cast(RoomWithHostToken, dict(value if isinstance(value, dict) else {}, existed=status == 200))
|
|
229
|
+
|
|
230
|
+
def create_token(self, room_id: str, identity: str, role: Role) -> RoomToken:
|
|
231
|
+
"""
|
|
232
|
+
Authorise one of your users. YOUR backend decides the role -- it is
|
|
233
|
+
signed into the token and the client cannot change it.
|
|
234
|
+
"""
|
|
235
|
+
value, _ = self._request(
|
|
236
|
+
"POST",
|
|
237
|
+
"/v1/rooms/%s/tokens" % _segment("roomId", room_id),
|
|
238
|
+
body={"identity": identity, "role": role},
|
|
239
|
+
# Minting twice is harmless: tokens are independent and short-lived.
|
|
240
|
+
safe_to_repeat=True,
|
|
241
|
+
)
|
|
242
|
+
return cast(RoomToken, value)
|
|
243
|
+
|
|
244
|
+
def get_room(self, room_id: str) -> RoomView:
|
|
245
|
+
"""The authoritative live view: ``{"room", "participants", "producers"}``, as sent."""
|
|
246
|
+
value, _ = self._request("GET", "/v1/rooms/%s" % _segment("roomId", room_id), safe_to_repeat=True)
|
|
247
|
+
return cast(RoomView, value)
|
|
248
|
+
|
|
249
|
+
def list_rooms(self) -> RoomList:
|
|
250
|
+
"""Every live room for your project, as ``{"rooms": [...]}`` (an object, not a list)."""
|
|
251
|
+
value, _ = self._request("GET", "/v1/rooms", safe_to_repeat=True)
|
|
252
|
+
return cast(RoomList, value)
|
|
253
|
+
|
|
254
|
+
def list_participants(self, room_id: str) -> list[Participant]:
|
|
255
|
+
"""Who is in the room now. A point-in-time read, not a presence feed."""
|
|
256
|
+
value, _ = self._request(
|
|
257
|
+
"GET", "/v1/rooms/%s/participants" % _segment("roomId", room_id), safe_to_repeat=True
|
|
258
|
+
)
|
|
259
|
+
return cast(list[Participant], _unwrap_list(value, "participants"))
|
|
260
|
+
|
|
261
|
+
def delete_room(self, room_id: str) -> None:
|
|
262
|
+
"""End a room. Everyone is disconnected and the room is gone."""
|
|
263
|
+
self._request(
|
|
264
|
+
"DELETE",
|
|
265
|
+
"/v1/rooms/%s" % _segment("roomId", room_id),
|
|
266
|
+
safe_to_repeat=True,
|
|
267
|
+
# A retry that finds the room gone means the lost attempt ended it.
|
|
268
|
+
gone_is_done=True,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# --- chat -------------------------------------------------------------------
|
|
272
|
+
#
|
|
273
|
+
# A channel shares its name with the room of the same name: the people in
|
|
274
|
+
# room "show-42" and the people only in chat "show-42" are one conversation.
|
|
275
|
+
# A channel with no room behind it is chat on its own.
|
|
276
|
+
|
|
277
|
+
def create_chat_token(self, channel: str, identity: str, *, role: Optional[Role] = None) -> ChatToken:
|
|
278
|
+
"""
|
|
279
|
+
Admit one of your users to a channel's chat, with no media rights. For
|
|
280
|
+
someone who is not in the room -- an audience watching a broadcast,
|
|
281
|
+
say. People in the room already have chat on the socket they hold.
|
|
282
|
+
``role`` defaults to viewer on the server.
|
|
283
|
+
"""
|
|
284
|
+
value, _ = self._request(
|
|
285
|
+
"POST",
|
|
286
|
+
"/v1/chat/%s/tokens" % _segment("channel", channel),
|
|
287
|
+
body=_compact({"identity": identity, "role": role}),
|
|
288
|
+
safe_to_repeat=True,
|
|
289
|
+
)
|
|
290
|
+
return cast(ChatToken, value)
|
|
291
|
+
|
|
292
|
+
def get_chat_info(self, channel: str) -> ChatInfo:
|
|
293
|
+
"""How many identities are connected to a channel, in the room or only in its chat."""
|
|
294
|
+
value, _ = self._request("GET", "/v1/chat/%s" % _segment("channel", channel), safe_to_repeat=True)
|
|
295
|
+
return cast(ChatInfo, value)
|
|
296
|
+
|
|
297
|
+
def list_messages(
|
|
298
|
+
self, channel: str, *, limit: Optional[int] = None, before: Optional[str] = None
|
|
299
|
+
) -> list[ChatMessage]:
|
|
300
|
+
"""
|
|
301
|
+
The newest ``limit`` messages (50 by default, at most 100), oldest
|
|
302
|
+
first, direct messages included: your backend owns the conversation
|
|
303
|
+
and may need to moderate it. ``before`` is the id of the oldest message
|
|
304
|
+
you hold, to page back.
|
|
305
|
+
"""
|
|
306
|
+
query: list[tuple[str, str]] = []
|
|
307
|
+
if limit:
|
|
308
|
+
query.append(("limit", str(limit)))
|
|
309
|
+
if before:
|
|
310
|
+
query.append(("before", before))
|
|
311
|
+
suffix = "?" + urlencode(query) if query else ""
|
|
312
|
+
value, _ = self._request(
|
|
313
|
+
"GET", "/v1/chat/%s/messages%s" % (_segment("channel", channel), suffix), safe_to_repeat=True
|
|
314
|
+
)
|
|
315
|
+
return cast(list[ChatMessage], _unwrap_list(value, "messages"))
|
|
316
|
+
|
|
317
|
+
def send_message(
|
|
318
|
+
self,
|
|
319
|
+
channel: str,
|
|
320
|
+
*,
|
|
321
|
+
text: Optional[str] = None,
|
|
322
|
+
type: Optional[str] = None,
|
|
323
|
+
from_: Optional[str] = None,
|
|
324
|
+
to: Optional[str] = None,
|
|
325
|
+
metadata: _Json = None,
|
|
326
|
+
ephemeral: Optional[bool] = None,
|
|
327
|
+
) -> ChatMessage:
|
|
328
|
+
"""
|
|
329
|
+
Speak from your backend: a system notice, a gift announcement, a
|
|
330
|
+
warning. Skips the per-socket rate limit and the mute list, both of
|
|
331
|
+
which exist to restrain clients. ``type`` defaults to "text" and
|
|
332
|
+
``from_`` (the wire's ``from``) to "system"; ``to`` makes it a direct
|
|
333
|
+
message; ``ephemeral`` delivers without storing. Never retried: a lost
|
|
334
|
+
reply would become the same message twice.
|
|
335
|
+
"""
|
|
336
|
+
value, _ = self._request(
|
|
337
|
+
"POST",
|
|
338
|
+
"/v1/chat/%s/messages" % _segment("channel", channel),
|
|
339
|
+
body=_compact(
|
|
340
|
+
{"text": text, "type": type, "from": from_, "to": to, "metadata": metadata, "ephemeral": ephemeral}
|
|
341
|
+
),
|
|
342
|
+
safe_to_repeat=False,
|
|
343
|
+
)
|
|
344
|
+
return cast(ChatMessage, value)
|
|
345
|
+
|
|
346
|
+
def delete_message(self, channel: str, message_id: str) -> None:
|
|
347
|
+
"""Remove a message. Everyone who could see it is told to drop it."""
|
|
348
|
+
self._request(
|
|
349
|
+
"DELETE",
|
|
350
|
+
"/v1/chat/%s/messages/%s" % (_segment("channel", channel), _segment("messageId", message_id)),
|
|
351
|
+
safe_to_repeat=True,
|
|
352
|
+
gone_is_done=True,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def list_chat_mutes(self, channel: str) -> list[str]:
|
|
356
|
+
"""Identities currently muted in a channel's chat."""
|
|
357
|
+
value, _ = self._request("GET", "/v1/chat/%s/mutes" % _segment("channel", channel), safe_to_repeat=True)
|
|
358
|
+
return cast(list[str], _unwrap_list(value, "muted"))
|
|
359
|
+
|
|
360
|
+
def mute_chat(self, channel: str, identity: str) -> None:
|
|
361
|
+
"""
|
|
362
|
+
Silence an identity in a channel's chat. Held for the channel, not the
|
|
363
|
+
connection, so reconnecting does not undo it. They can still read.
|
|
364
|
+
"""
|
|
365
|
+
self._request(
|
|
366
|
+
"PUT",
|
|
367
|
+
"/v1/chat/%s/mutes/%s" % (_segment("channel", channel), _segment("identity", identity)),
|
|
368
|
+
safe_to_repeat=True,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def unmute_chat(self, channel: str, identity: str) -> None:
|
|
372
|
+
"""Lift a mute."""
|
|
373
|
+
self._request(
|
|
374
|
+
"DELETE",
|
|
375
|
+
"/v1/chat/%s/mutes/%s" % (_segment("channel", channel), _segment("identity", identity)),
|
|
376
|
+
safe_to_repeat=True,
|
|
377
|
+
gone_is_done=True,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# --- broadcast --------------------------------------------------------------
|
|
381
|
+
#
|
|
382
|
+
# One publisher, any number of viewers, over LL-HLS and WebRTC. Answers
|
|
383
|
+
# `broadcast-disabled` (404) on a deployment without it, VoltKit cloud today
|
|
384
|
+
# included.
|
|
385
|
+
|
|
386
|
+
def create_stream(
|
|
387
|
+
self, stream_id: str, *, playback: Optional[PlaybackPolicy] = None, metadata: _Json = None
|
|
388
|
+
) -> BroadcastStreamWithPublish:
|
|
389
|
+
"""
|
|
390
|
+
Create a stream. The reply's ``publish.whipUrl`` is complete -- hand it
|
|
391
|
+
to the encoder as it is. This is the ONLY call that returns a publish
|
|
392
|
+
token; reading or listing a stream never does. ``playback`` is
|
|
393
|
+
"public" (the default) or "token". Never retried, for the reason
|
|
394
|
+
:meth:`create_room` is not.
|
|
395
|
+
"""
|
|
396
|
+
value, _ = self._request(
|
|
397
|
+
"POST",
|
|
398
|
+
"/v1/streams",
|
|
399
|
+
body=_compact({"streamId": stream_id, "playback": playback, "metadata": metadata}),
|
|
400
|
+
safe_to_repeat=False,
|
|
401
|
+
)
|
|
402
|
+
return cast(BroadcastStreamWithPublish, value)
|
|
403
|
+
|
|
404
|
+
def get_stream(self, stream_id: str) -> BroadcastStream:
|
|
405
|
+
"""A stream's state, viewers and playback URLs; never its publish token."""
|
|
406
|
+
value, _ = self._request("GET", "/v1/streams/%s" % _segment("streamId", stream_id), safe_to_repeat=True)
|
|
407
|
+
return cast(BroadcastStream, value)
|
|
408
|
+
|
|
409
|
+
def list_streams(self) -> list[BroadcastStream]:
|
|
410
|
+
"""Every stream of your project."""
|
|
411
|
+
value, _ = self._request("GET", "/v1/streams", safe_to_repeat=True)
|
|
412
|
+
return cast(list[BroadcastStream], _unwrap_list(value, "streams"))
|
|
413
|
+
|
|
414
|
+
def update_stream(
|
|
415
|
+
self, stream_id: str, *, playback: Optional[PlaybackPolicy] = None, metadata: _Json = None
|
|
416
|
+
) -> BroadcastStream:
|
|
417
|
+
"""Change who may watch, or the stream's metadata."""
|
|
418
|
+
value, _ = self._request(
|
|
419
|
+
"PATCH",
|
|
420
|
+
"/v1/streams/%s" % _segment("streamId", stream_id),
|
|
421
|
+
body=_compact({"playback": playback, "metadata": metadata}),
|
|
422
|
+
safe_to_repeat=True,
|
|
423
|
+
)
|
|
424
|
+
return cast(BroadcastStream, value)
|
|
425
|
+
|
|
426
|
+
def delete_stream(self, stream_id: str) -> None:
|
|
427
|
+
"""Delete a stream. Its publish and playback URLs stop working at once."""
|
|
428
|
+
self._request(
|
|
429
|
+
"DELETE",
|
|
430
|
+
"/v1/streams/%s" % _segment("streamId", stream_id),
|
|
431
|
+
safe_to_repeat=True,
|
|
432
|
+
gone_is_done=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def create_stream_token(
|
|
436
|
+
self,
|
|
437
|
+
stream_id: str,
|
|
438
|
+
*,
|
|
439
|
+
action: Optional[StreamAction] = None,
|
|
440
|
+
identity: Optional[str] = None,
|
|
441
|
+
ttl_seconds: Optional[int] = None,
|
|
442
|
+
) -> StreamToken:
|
|
443
|
+
"""
|
|
444
|
+
Mint a playback token for a viewer of a token-protected stream, or a
|
|
445
|
+
fresh publish token for an encoder whose URL has expired. The reply
|
|
446
|
+
carries the URLs the token belongs in. ``action`` is "play" (the
|
|
447
|
+
default) or "publish"; ``ttl_seconds`` is capped at 24 hours by the
|
|
448
|
+
service.
|
|
449
|
+
"""
|
|
450
|
+
value, _ = self._request(
|
|
451
|
+
"POST",
|
|
452
|
+
"/v1/streams/%s/tokens" % _segment("streamId", stream_id),
|
|
453
|
+
body=_compact({"action": action, "identity": identity, "ttlSeconds": ttl_seconds}),
|
|
454
|
+
safe_to_repeat=True,
|
|
455
|
+
)
|
|
456
|
+
return cast(StreamToken, value)
|
|
457
|
+
|
|
458
|
+
# --- usage ------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
def get_usage(self) -> UsageSummary:
|
|
461
|
+
"""Your allowance, and usage for today, this month and lifetime."""
|
|
462
|
+
value, _ = self._request("GET", "/v1/usage", safe_to_repeat=True)
|
|
463
|
+
return cast(UsageSummary, value)
|
|
464
|
+
|
|
465
|
+
def get_usage_series(self, days: int = 30) -> list[DailyUsage]:
|
|
466
|
+
"""
|
|
467
|
+
One entry per UTC day, oldest first, ending today. Days with no usage
|
|
468
|
+
are present and zero, so a chart drawn from it has no gaps. Up to 90
|
|
469
|
+
days.
|
|
470
|
+
"""
|
|
471
|
+
value, _ = self._request("GET", "/v1/usage/series?days=%s" % quote(str(days), safe=""), safe_to_repeat=True)
|
|
472
|
+
return cast(list[DailyUsage], _unwrap_list(value, "series"))
|
|
473
|
+
|
|
474
|
+
# --- billing ----------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
def get_balance(self) -> ProjectBalance:
|
|
477
|
+
"""
|
|
478
|
+
What your project can spend right now, and whether a newcomer would be
|
|
479
|
+
admitted. Read only: recharges happen on the website, never through
|
|
480
|
+
this API. Amounts are cu (1 credit = 6,000,000 cu) as decimal strings;
|
|
481
|
+
do arithmetic with int().
|
|
482
|
+
"""
|
|
483
|
+
value, _ = self._request("GET", "/v1/billing/balance", safe_to_repeat=True)
|
|
484
|
+
return cast(ProjectBalance, value)
|
|
485
|
+
|
|
486
|
+
def get_rates(self) -> RtcRates:
|
|
487
|
+
"""
|
|
488
|
+
The public price list. It comes from the website API
|
|
489
|
+
(``rates_base_url``), where prices are set, rather than the controller
|
|
490
|
+
-- and carries no credential, since it is public and on another host.
|
|
491
|
+
"""
|
|
492
|
+
if not self._secrets.rates_base_url:
|
|
493
|
+
raise StreamError(
|
|
494
|
+
"rates-unconfigured",
|
|
495
|
+
0,
|
|
496
|
+
"getRates needs ratesBaseUrl: the website API origin that serves the public rates, "
|
|
497
|
+
"e.g. https://api.example.com",
|
|
498
|
+
False,
|
|
499
|
+
)
|
|
500
|
+
value, status = self._request(
|
|
501
|
+
"GET", RATES_PATH, safe_to_repeat=True, public_origin=self._secrets.rates_base_url
|
|
502
|
+
)
|
|
503
|
+
data = value.get("data") if isinstance(value, dict) else None
|
|
504
|
+
if not isinstance(data, dict):
|
|
505
|
+
# Not the API's { success, data } envelope: rates_base_url most
|
|
506
|
+
# likely names some other service, and asking again gets the same
|
|
507
|
+
# answer.
|
|
508
|
+
raise StreamError(
|
|
509
|
+
"http-%d" % status,
|
|
510
|
+
status,
|
|
511
|
+
"GET %s: the reply carries no rates; is ratesBaseUrl the website API origin?" % RATES_PATH,
|
|
512
|
+
False,
|
|
513
|
+
)
|
|
514
|
+
return cast(RtcRates, data)
|
|
515
|
+
|
|
516
|
+
# --- transport -------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
def _request(
|
|
519
|
+
self,
|
|
520
|
+
method: str,
|
|
521
|
+
path: str,
|
|
522
|
+
*,
|
|
523
|
+
safe_to_repeat: bool,
|
|
524
|
+
body: Optional[dict[str, Any]] = None,
|
|
525
|
+
gone_is_done: bool = False,
|
|
526
|
+
public_origin: Optional[str] = None,
|
|
527
|
+
) -> tuple[Any, int]:
|
|
528
|
+
"""
|
|
529
|
+
One call, retried while it is transient and harmless to repeat.
|
|
530
|
+
|
|
531
|
+
Whether repeating is harmless is the caller's decision rather than the
|
|
532
|
+
verb's: retrying a create would turn a lost response into a spurious
|
|
533
|
+
"already exists"; retrying a token mint costs nothing. ``gone_is_done``
|
|
534
|
+
marks a delete: when an attempt failed in a way that may have reached
|
|
535
|
+
the server (a network failure or a 5xx) and a retry then answers 404,
|
|
536
|
+
only the earlier reply was lost, and the call is done.
|
|
537
|
+
``public_origin`` sends the request to another origin with no API key:
|
|
538
|
+
the website API's public rates.
|
|
539
|
+
"""
|
|
540
|
+
# Once, before anything is sent: a body JSON cannot carry is the
|
|
541
|
+
# caller's to fix, and no attempt would go differently.
|
|
542
|
+
payload = None if body is None else _serialize(method, path, body)
|
|
543
|
+
attempts = self.retries + 1 if safe_to_repeat else 1
|
|
544
|
+
last: Optional[StreamError] = None
|
|
545
|
+
# Whether an earlier attempt may have reached the server and done the
|
|
546
|
+
# work, its reply lost on the way back. A 429 is a refusal: that
|
|
547
|
+
# attempt did nothing.
|
|
548
|
+
may_have_landed = False
|
|
549
|
+
for attempt in range(1, attempts + 1):
|
|
550
|
+
if attempt > 1:
|
|
551
|
+
# Exponential with jitter: a fleet-wide blip should not produce
|
|
552
|
+
# a synchronised retry from every backend at once.
|
|
553
|
+
self._sleep(0.2 * 2 ** (attempt - 2) + self._jitter() * 0.1)
|
|
554
|
+
started = time.monotonic()
|
|
555
|
+
try:
|
|
556
|
+
value, status = self._once(method, path, payload, public_origin)
|
|
557
|
+
except StreamError as err:
|
|
558
|
+
_log.debug(
|
|
559
|
+
"%s %s: %s after %s (attempt %d of %d)",
|
|
560
|
+
method,
|
|
561
|
+
path,
|
|
562
|
+
err.code,
|
|
563
|
+
_since(started),
|
|
564
|
+
attempt,
|
|
565
|
+
attempts,
|
|
566
|
+
)
|
|
567
|
+
if gone_is_done and may_have_landed and _is_gone(err):
|
|
568
|
+
# The earlier attempt deleted it; this one finds it gone.
|
|
569
|
+
return None, err.status
|
|
570
|
+
if not err.retryable:
|
|
571
|
+
raise
|
|
572
|
+
last = err
|
|
573
|
+
if err.status != 429:
|
|
574
|
+
may_have_landed = True
|
|
575
|
+
continue
|
|
576
|
+
_log.debug(
|
|
577
|
+
"%s %s: %d after %s (attempt %d of %d)", method, path, status, _since(started), attempt, attempts
|
|
578
|
+
)
|
|
579
|
+
return value, status
|
|
580
|
+
assert last is not None
|
|
581
|
+
raise last
|
|
582
|
+
|
|
583
|
+
def _once(
|
|
584
|
+
self, method: str, path: str, payload: Optional[bytes], public_origin: Optional[str]
|
|
585
|
+
) -> tuple[Any, int]:
|
|
586
|
+
"""One attempt. ``payload`` is the body as JSON, made once by _request."""
|
|
587
|
+
# The key is attached here and only here, and only for the controller.
|
|
588
|
+
with_key = public_origin is None
|
|
589
|
+
headers: dict[str, str] = {}
|
|
590
|
+
if with_key:
|
|
591
|
+
headers["X-API-Key"] = self._secrets.api_key
|
|
592
|
+
if payload is not None:
|
|
593
|
+
headers["Content-Type"] = "application/json"
|
|
594
|
+
headers["User-Agent"] = USER_AGENT
|
|
595
|
+
origin = self._secrets.base_url if public_origin is None else public_origin
|
|
596
|
+
request = TransportRequest(
|
|
597
|
+
method=method,
|
|
598
|
+
url=origin + path,
|
|
599
|
+
headers=headers,
|
|
600
|
+
body=payload,
|
|
601
|
+
timeout=self.timeout,
|
|
602
|
+
# Never follow a redirect with the key: following one would hand
|
|
603
|
+
# X-API-Key to whatever host the redirect names. The public rates
|
|
604
|
+
# carry no credential and may follow one (a website API behind a
|
|
605
|
+
# www. or https redirect).
|
|
606
|
+
follow_redirects=not with_key,
|
|
607
|
+
)
|
|
608
|
+
try:
|
|
609
|
+
reply = self.transport.send(request)
|
|
610
|
+
except (TimeoutError, socket.timeout):
|
|
611
|
+
raise StreamError(
|
|
612
|
+
"network", 0, "%s %s: timed out after %s" % (method, path, _ms(self.timeout)), True
|
|
613
|
+
) from None
|
|
614
|
+
except (OSError, http.client.HTTPException, TransportError) as err:
|
|
615
|
+
# The transport's own words may quote the URL, credentials and
|
|
616
|
+
# all, or a header value it refused: neither may reach a message
|
|
617
|
+
# that will end up in someone's logs. The cause is dropped for the
|
|
618
|
+
# same reason.
|
|
619
|
+
raise StreamError(
|
|
620
|
+
"network", 0, "%s %s: %s" % (method, path, self._redact(_words_of(err))), True
|
|
621
|
+
) from None
|
|
622
|
+
|
|
623
|
+
if with_key and 300 <= reply.status < 400:
|
|
624
|
+
# The 3xx itself is the answer: it is not retried (the same
|
|
625
|
+
# base_url redirects the same way) and it is not followed. The
|
|
626
|
+
# target is named so the fix is obvious; it is cut for a log line
|
|
627
|
+
# after the key is taken out, so the cut cannot leave part of it.
|
|
628
|
+
location = _header_of(reply.headers, "location")
|
|
629
|
+
to = " to %s" % self._redact(location)[:200] if location else ""
|
|
630
|
+
raise StreamError(
|
|
631
|
+
"redirected",
|
|
632
|
+
reply.status,
|
|
633
|
+
"%s %s: the server answered %d%s; a request carrying the API key is never redirected. "
|
|
634
|
+
"Point baseUrl at the controller itself (its exact scheme and host)"
|
|
635
|
+
% (method, path, reply.status, to),
|
|
636
|
+
False,
|
|
637
|
+
)
|
|
638
|
+
return _parse_reply(method, path, reply.status, reply.body.decode("utf-8-sig", "replace"), self._redact)
|
|
639
|
+
|
|
640
|
+
def _redact(self, text: str) -> str:
|
|
641
|
+
"""``text`` with the API key, and the credentials of any URL in it, replaced by [redacted]."""
|
|
642
|
+
return hide_url_credentials(self._secrets.redact_key(text))
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# --- checks when the client is made --------------------------------------------
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _check_origin(name: str, url: object) -> Optional[str]:
|
|
649
|
+
"""``url`` without one trailing slash; None when it is unset (None, "" or "/")."""
|
|
650
|
+
if url is None or url == "":
|
|
651
|
+
return None
|
|
652
|
+
if not isinstance(url, str):
|
|
653
|
+
raise TypeError("%s must be a str, got %s" % (name, type(url).__name__))
|
|
654
|
+
origin = url[:-1] if url.endswith("/") else url
|
|
655
|
+
if not origin:
|
|
656
|
+
return None
|
|
657
|
+
parts = urlsplit(origin)
|
|
658
|
+
if not parts.scheme or not parts.netloc:
|
|
659
|
+
# Not quoted: a URL may hold credentials, and a scheme-less one is not
|
|
660
|
+
# one the redaction can read.
|
|
661
|
+
raise ValueError(
|
|
662
|
+
"%s must be an absolute URL such as https://api.voltkit.in: it names no scheme or host" % name
|
|
663
|
+
)
|
|
664
|
+
return origin
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _check_timeout(timeout: object) -> float:
|
|
668
|
+
"""``timeout`` in seconds as a deadline can hold it, or an error now."""
|
|
669
|
+
if timeout is None:
|
|
670
|
+
return DEFAULT_TIMEOUT
|
|
671
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
672
|
+
raise TypeError("timeout must be a number of seconds, got %r" % (timeout,))
|
|
673
|
+
value = float(timeout)
|
|
674
|
+
# NaN fails every comparison, so it is named.
|
|
675
|
+
if math.isnan(value) or value <= 0:
|
|
676
|
+
raise ValueError("timeout must be a number of seconds above 0, got %r" % (timeout,))
|
|
677
|
+
return min(value, MAX_TIMEOUT)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _check_retries(retries: object) -> int:
|
|
681
|
+
"""``retries`` as given, or an error now, instead of a failure far from the mistake."""
|
|
682
|
+
if retries is None:
|
|
683
|
+
return DEFAULT_RETRIES
|
|
684
|
+
if isinstance(retries, bool) or not isinstance(retries, (int, float)):
|
|
685
|
+
raise TypeError("retries must be a number, got %r" % (retries,))
|
|
686
|
+
# is_integer is False for NaN and the infinities as well as fractions.
|
|
687
|
+
if (isinstance(retries, float) and not retries.is_integer()) or retries < 0:
|
|
688
|
+
raise ValueError("retries must be a whole number of 0 or more, got %r" % (retries,))
|
|
689
|
+
return int(retries)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# --- requests ------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _segment(name: str, value: str) -> str:
|
|
696
|
+
"""
|
|
697
|
+
An id, as one path segment. "", "." and ".." are refused: a URL reads them
|
|
698
|
+
as steps through the path, spelt %2e or not, so no encoding keeps them in
|
|
699
|
+
their segment -- delete_room("..") would send DELETE /v1/, with the key.
|
|
700
|
+
Every other id is percent-encoded as JavaScript's encodeURIComponent does
|
|
701
|
+
(quote() keeps A-Z a-z 0-9 - _ . ~; these five are the others it leaves
|
|
702
|
+
alone), so logs read the same whichever SDK wrote them. The names in the
|
|
703
|
+
message are the API's (roomId, messageId), the same in every VoltKit SDK.
|
|
704
|
+
"""
|
|
705
|
+
if not isinstance(value, str):
|
|
706
|
+
raise TypeError("%s must be a str, got %s" % (name, type(value).__name__))
|
|
707
|
+
if value in ("", ".", ".."):
|
|
708
|
+
raise StreamError(
|
|
709
|
+
"bad-request",
|
|
710
|
+
0,
|
|
711
|
+
'%s %s: an id may not be "", "." or "..", which a URL resolves to another endpoint; nothing was sent'
|
|
712
|
+
% (name, json.dumps(value)),
|
|
713
|
+
False,
|
|
714
|
+
)
|
|
715
|
+
return quote(value, safe="!*'()")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _compact(body: dict[str, Any]) -> dict[str, Any]:
|
|
719
|
+
# JSON.stringify drops undefined fields; an optional argument left at None
|
|
720
|
+
# is likewise not sent, rather than sent as null for the server to reject.
|
|
721
|
+
return {key: value for key, value in body.items() if value is not None}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _serialize(method: str, path: str, body: dict[str, Any]) -> bytes:
|
|
725
|
+
"""
|
|
726
|
+
The body as JSON, or a TypeError naming the call. A structure that refers
|
|
727
|
+
to itself, a set, a datetime, or a NaN in ``metadata`` used to fail inside
|
|
728
|
+
the request, as a retryable network error; no retry could change it.
|
|
729
|
+
"""
|
|
730
|
+
try:
|
|
731
|
+
return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
732
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as err:
|
|
733
|
+
raise TypeError("%s %s: the body cannot be sent as JSON: %s" % (method, path, err)) from err
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _ms(seconds: float) -> str:
|
|
737
|
+
ms = round(seconds * 1000, 6)
|
|
738
|
+
if float(ms).is_integer():
|
|
739
|
+
return "%dms" % int(ms)
|
|
740
|
+
return ("%.6f" % ms).rstrip("0").rstrip(".") + "ms"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _since(started: float) -> str:
|
|
744
|
+
return "%dms" % int((time.monotonic() - started) * 1000)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _words_of(err: BaseException) -> str:
|
|
748
|
+
text = str(err).strip()
|
|
749
|
+
name = type(err).__name__
|
|
750
|
+
return "%s: %s" % (name, text) if text else name
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _header_of(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
754
|
+
for key, value in headers.items():
|
|
755
|
+
if key.lower() == name:
|
|
756
|
+
return value or None
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# --- replies -------------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _parse_reply(method: str, path: str, status: int, text: str, redact: Callable[[str], str]) -> tuple[Any, int]:
|
|
764
|
+
"""
|
|
765
|
+
A whole reply, read: the value on a 2xx, a StreamError otherwise. The
|
|
766
|
+
server's words go through ``redact`` before they reach a message, and
|
|
767
|
+
before they are cut short, so a cut cannot leave part of the key behind.
|
|
768
|
+
"""
|
|
769
|
+
if not 200 <= status < 300:
|
|
770
|
+
reply_code, reply_message = _error_reply(text)
|
|
771
|
+
# Redacted too: a server answering {"error": "Invalid API key: <key>"}
|
|
772
|
+
# put the key in the code. An ordinary code holds no key and passes
|
|
773
|
+
# unchanged.
|
|
774
|
+
code = redact(reply_code) if reply_code is not None else "http-%d" % status
|
|
775
|
+
message = redact(reply_message) if reply_message is not None else redact(text)[:300]
|
|
776
|
+
# 5xx and 429 may pass; a 4xx is a decision the server made and will
|
|
777
|
+
# make again. billing-unavailable is the 503 that will not: it lasts
|
|
778
|
+
# until an operator activates a price list, or wires wallet billing in.
|
|
779
|
+
retryable = (status >= 500 or status == 429) and code != "billing-unavailable"
|
|
780
|
+
raise StreamError(code, status, "%s %s: %s" % (method, path, message), retryable)
|
|
781
|
+
if not text:
|
|
782
|
+
return None, status
|
|
783
|
+
try:
|
|
784
|
+
return _loads(text), status
|
|
785
|
+
except ValueError:
|
|
786
|
+
# A success that is not JSON came from something other than the API
|
|
787
|
+
# -- a web page, a proxy -- and a retry would fetch the same thing.
|
|
788
|
+
raise StreamError(
|
|
789
|
+
"http-%d" % status,
|
|
790
|
+
status,
|
|
791
|
+
"%s %s: the reply is not JSON: %s" % (method, path, redact(text)[:100]),
|
|
792
|
+
False,
|
|
793
|
+
) from None
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _error_reply(text: str) -> tuple[Optional[str], Optional[str]]:
|
|
797
|
+
"""
|
|
798
|
+
The code and message of an error reply. The controller answers
|
|
799
|
+
``{"error", "message"}``; the website API
|
|
800
|
+
``{"success": false, "error": {"code", "message"}}``. Anything else -- a
|
|
801
|
+
proxy's HTML page -- has neither.
|
|
802
|
+
"""
|
|
803
|
+
try:
|
|
804
|
+
body = _loads(text)
|
|
805
|
+
except ValueError:
|
|
806
|
+
return None, None
|
|
807
|
+
if not isinstance(body, dict):
|
|
808
|
+
return None, None
|
|
809
|
+
error = body.get("error")
|
|
810
|
+
if isinstance(error, dict):
|
|
811
|
+
code, message = error.get("code"), error.get("message")
|
|
812
|
+
elif isinstance(error, list):
|
|
813
|
+
code, message = None, None
|
|
814
|
+
else:
|
|
815
|
+
code, message = error, body.get("message")
|
|
816
|
+
return _non_empty(code), _non_empty(message)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _non_empty(value: Any) -> Optional[str]:
|
|
820
|
+
return value if isinstance(value, str) and value else None
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _reject_constant(name: str) -> Any:
|
|
824
|
+
# NaN and Infinity are not JSON; JavaScript's JSON.parse refuses them too.
|
|
825
|
+
raise ValueError("not JSON: %s" % name)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _loads(text: str) -> Any:
|
|
829
|
+
return json.loads(text, parse_constant=_reject_constant)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _unwrap_list(value: Any, key: str) -> list[Any]:
|
|
833
|
+
# The API wraps every list in an object ({ participants }, { messages },
|
|
834
|
+
# ...); the method is documented as the list itself.
|
|
835
|
+
if isinstance(value, dict):
|
|
836
|
+
items = value.get(key)
|
|
837
|
+
return items if isinstance(items, list) else []
|
|
838
|
+
return []
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _is_gone(err: StreamError) -> bool:
|
|
842
|
+
"""
|
|
843
|
+
A 404 saying the thing is not there. Not one saying chat or broadcast is
|
|
844
|
+
switched off on this deployment: that deleted nothing.
|
|
845
|
+
"""
|
|
846
|
+
return err.status == 404 and err.code not in ("chat-disabled", "broadcast-disabled")
|