sockudo-http-python 2.0.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.
- sockudo_http/__init__.py +1 -0
- sockudo_http_python/__init__.py +61 -0
- sockudo_http_python/client.py +1822 -0
- sockudo_http_python-2.0.0.dist-info/METADATA +191 -0
- sockudo_http_python-2.0.0.dist-info/RECORD +8 -0
- sockudo_http_python-2.0.0.dist-info/WHEEL +5 -0
- sockudo_http_python-2.0.0.dist-info/licenses/LICENSE +21 -0
- sockudo_http_python-2.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import (
|
|
13
|
+
Any,
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
List,
|
|
17
|
+
Mapping,
|
|
18
|
+
Optional,
|
|
19
|
+
Sequence,
|
|
20
|
+
Tuple,
|
|
21
|
+
Union,
|
|
22
|
+
)
|
|
23
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from nacl import exceptions as nacl_exceptions
|
|
27
|
+
from nacl import secret as nacl_secret
|
|
28
|
+
from nacl import utils as nacl_utils
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
JsonDict = Dict[str, Any]
|
|
32
|
+
JsonMarshaller = Callable[[Any], str]
|
|
33
|
+
ENCRYPTED_CHANNEL_PREFIX = "private-encrypted-"
|
|
34
|
+
|
|
35
|
+
RESERVED_QUERY_KEYS = {
|
|
36
|
+
"auth_key",
|
|
37
|
+
"auth_timestamp",
|
|
38
|
+
"auth_version",
|
|
39
|
+
"auth_signature",
|
|
40
|
+
"body_md5",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SockudoError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Status(str, Enum):
|
|
49
|
+
SUCCESS = "success"
|
|
50
|
+
CLIENT_ERROR = "client_error"
|
|
51
|
+
AUTHENTICATION_ERROR = "authentication_error"
|
|
52
|
+
SERVER_ERROR = "server_error"
|
|
53
|
+
NETWORK_ERROR = "network_error"
|
|
54
|
+
|
|
55
|
+
def should_retry(self) -> bool:
|
|
56
|
+
return self in {Status.SERVER_ERROR, Status.NETWORK_ERROR}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Validity(str, Enum):
|
|
60
|
+
VALID = "valid"
|
|
61
|
+
INVALID = "invalid"
|
|
62
|
+
SIGNED_WITH_WRONG_KEY = "signed_with_wrong_key"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Result:
|
|
67
|
+
status: Status
|
|
68
|
+
message: str
|
|
69
|
+
status_code: Optional[int] = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ok(self) -> bool:
|
|
73
|
+
return self.status is Status.SUCCESS
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def from_response(response: httpx.Response) -> "Result":
|
|
77
|
+
if 200 <= response.status_code < 300:
|
|
78
|
+
status = Status.SUCCESS
|
|
79
|
+
elif response.status_code in {401, 403}:
|
|
80
|
+
status = Status.AUTHENTICATION_ERROR
|
|
81
|
+
elif 400 <= response.status_code < 500:
|
|
82
|
+
status = Status.CLIENT_ERROR
|
|
83
|
+
else:
|
|
84
|
+
status = Status.SERVER_ERROR
|
|
85
|
+
return Result(
|
|
86
|
+
status=status, message=response.text, status_code=response.status_code
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def from_exception(exc: Exception) -> "Result":
|
|
91
|
+
return Result(status=Status.NETWORK_ERROR, message=str(exc), status_code=None)
|
|
92
|
+
|
|
93
|
+
def json(self) -> Any:
|
|
94
|
+
return json.loads(self.message) if self.message else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class SockudoOptions:
|
|
99
|
+
host: str = "127.0.0.1"
|
|
100
|
+
port: int = 6001
|
|
101
|
+
use_tls: bool = False
|
|
102
|
+
timeout: float = 4.0
|
|
103
|
+
max_retries: int = 3
|
|
104
|
+
retry_base_delay: float = 0.1
|
|
105
|
+
auto_idempotency: bool = False
|
|
106
|
+
http2: bool = True
|
|
107
|
+
verify_tls: Union[bool, str] = True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Config:
|
|
112
|
+
app_id: str
|
|
113
|
+
key: str
|
|
114
|
+
secret: str
|
|
115
|
+
host: str = "127.0.0.1"
|
|
116
|
+
port: int = 6001
|
|
117
|
+
use_tls: bool = False
|
|
118
|
+
timeout: float = 4.0
|
|
119
|
+
max_retries: int = 3
|
|
120
|
+
retry_base_delay: float = 0.1
|
|
121
|
+
auto_idempotency: bool = False
|
|
122
|
+
http2: bool = True
|
|
123
|
+
verify_tls: Union[bool, str] = True
|
|
124
|
+
encryption_master_key_base64: Optional[str] = None
|
|
125
|
+
|
|
126
|
+
def options(self) -> SockudoOptions:
|
|
127
|
+
return SockudoOptions(
|
|
128
|
+
host=self.host,
|
|
129
|
+
port=self.port,
|
|
130
|
+
use_tls=self.use_tls,
|
|
131
|
+
timeout=self.timeout,
|
|
132
|
+
max_retries=self.max_retries,
|
|
133
|
+
retry_base_delay=self.retry_base_delay,
|
|
134
|
+
auto_idempotency=self.auto_idempotency,
|
|
135
|
+
http2=self.http2,
|
|
136
|
+
verify_tls=self.verify_tls,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class MessageExtras:
|
|
142
|
+
headers: Optional[Dict[str, Any]] = None
|
|
143
|
+
ephemeral: Optional[bool] = None
|
|
144
|
+
idempotency_key: Optional[str] = None
|
|
145
|
+
echo: Optional[bool] = None
|
|
146
|
+
tags: Optional[Dict[str, str]] = None
|
|
147
|
+
|
|
148
|
+
def to_payload(self) -> JsonDict:
|
|
149
|
+
payload: JsonDict = {}
|
|
150
|
+
if self.headers is not None:
|
|
151
|
+
payload["headers"] = self.headers
|
|
152
|
+
if self.ephemeral is not None:
|
|
153
|
+
payload["ephemeral"] = self.ephemeral
|
|
154
|
+
if self.idempotency_key is not None:
|
|
155
|
+
payload["idempotency_key"] = self.idempotency_key
|
|
156
|
+
if self.echo is not None:
|
|
157
|
+
payload["echo"] = self.echo
|
|
158
|
+
if self.tags is not None:
|
|
159
|
+
payload["tags"] = self.tags
|
|
160
|
+
return payload
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class TriggerOptions:
|
|
165
|
+
socket_id: Optional[str] = None
|
|
166
|
+
info: Optional[Union[str, Sequence[str]]] = None
|
|
167
|
+
idempotency_key: Optional[Union[str, bool]] = None
|
|
168
|
+
extras: Optional[MessageExtras] = None
|
|
169
|
+
tags: Optional[Dict[str, str]] = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class Event:
|
|
174
|
+
channel: Optional[str]
|
|
175
|
+
name: str
|
|
176
|
+
data: Any
|
|
177
|
+
channels: Optional[Sequence[str]] = None
|
|
178
|
+
socket_id: Optional[str] = None
|
|
179
|
+
info: Optional[Union[str, Sequence[str]]] = None
|
|
180
|
+
idempotency_key: Optional[Union[str, bool]] = None
|
|
181
|
+
extras: Optional[MessageExtras] = None
|
|
182
|
+
tags: Optional[Dict[str, str]] = None
|
|
183
|
+
|
|
184
|
+
def to_payload(self, marshaller: JsonMarshaller) -> JsonDict:
|
|
185
|
+
payload: JsonDict = {
|
|
186
|
+
"name": self.name,
|
|
187
|
+
"data": _encode_data(self.data, marshaller),
|
|
188
|
+
}
|
|
189
|
+
if self.channels is not None:
|
|
190
|
+
payload["channels"] = list(self.channels)
|
|
191
|
+
elif self.channel is not None:
|
|
192
|
+
payload["channel"] = self.channel
|
|
193
|
+
else:
|
|
194
|
+
raise SockudoError("Event requires channel or channels")
|
|
195
|
+
_apply_common_event_fields(
|
|
196
|
+
payload,
|
|
197
|
+
self.socket_id,
|
|
198
|
+
self.info,
|
|
199
|
+
self.idempotency_key,
|
|
200
|
+
self.extras,
|
|
201
|
+
self.tags,
|
|
202
|
+
)
|
|
203
|
+
return payload
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class PresenceUser:
|
|
208
|
+
user_id: str
|
|
209
|
+
user_info: Optional[Mapping[str, Any]] = None
|
|
210
|
+
|
|
211
|
+
def channel_data(self, marshaller: JsonMarshaller) -> str:
|
|
212
|
+
return marshaller(
|
|
213
|
+
{"user_id": self.user_id, "user_info": dict(self.user_info or {})}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class AuthData:
|
|
219
|
+
auth: str
|
|
220
|
+
channel_data: Optional[str] = None
|
|
221
|
+
shared_secret: Optional[str] = None
|
|
222
|
+
|
|
223
|
+
def to_json(self) -> str:
|
|
224
|
+
payload: JsonDict = {"auth": self.auth}
|
|
225
|
+
if self.channel_data is not None:
|
|
226
|
+
payload["channel_data"] = self.channel_data
|
|
227
|
+
if self.shared_secret is not None:
|
|
228
|
+
payload["shared_secret"] = self.shared_secret
|
|
229
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class ChannelsParams:
|
|
234
|
+
filter_by_prefix: Optional[str] = None
|
|
235
|
+
info: Optional[Union[str, Sequence[str]]] = None
|
|
236
|
+
|
|
237
|
+
def to_query(self) -> Dict[str, str]:
|
|
238
|
+
return _clean_query(
|
|
239
|
+
{"filter_by_prefix": self.filter_by_prefix, "info": _join_info(self.info)}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass
|
|
244
|
+
class ChannelParams:
|
|
245
|
+
info: Optional[Union[str, Sequence[str]]] = None
|
|
246
|
+
|
|
247
|
+
def to_query(self) -> Dict[str, str]:
|
|
248
|
+
return _clean_query({"info": _join_info(self.info)})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class HistoryParams:
|
|
253
|
+
limit: Optional[int] = None
|
|
254
|
+
direction: Optional[str] = None
|
|
255
|
+
cursor: Optional[str] = None
|
|
256
|
+
start_serial: Optional[int] = None
|
|
257
|
+
end_serial: Optional[int] = None
|
|
258
|
+
start_time_ms: Optional[int] = None
|
|
259
|
+
end_time_ms: Optional[int] = None
|
|
260
|
+
|
|
261
|
+
def to_query(self) -> Dict[str, str]:
|
|
262
|
+
return _clean_query(self.__dict__)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class PresenceHistoryParams(HistoryParams):
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class PresenceSnapshotParams:
|
|
272
|
+
at_serial: Optional[int] = None
|
|
273
|
+
at_time_ms: Optional[int] = None
|
|
274
|
+
|
|
275
|
+
def to_query(self) -> Dict[str, str]:
|
|
276
|
+
return _clean_query(self.__dict__)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class MessageVersionsParams:
|
|
281
|
+
limit: Optional[int] = None
|
|
282
|
+
direction: Optional[str] = None
|
|
283
|
+
cursor: Optional[str] = None
|
|
284
|
+
|
|
285
|
+
def to_query(self) -> Dict[str, str]:
|
|
286
|
+
return _clean_query(self.__dict__)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class PushCursorParams:
|
|
291
|
+
limit: Optional[int] = None
|
|
292
|
+
cursor: Optional[str] = None
|
|
293
|
+
|
|
294
|
+
def to_query(self) -> Dict[str, str]:
|
|
295
|
+
return _clean_query(self.__dict__)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class PushSubscriptionParams(PushCursorParams):
|
|
300
|
+
channel: Optional[str] = None
|
|
301
|
+
device_id: Optional[str] = None
|
|
302
|
+
|
|
303
|
+
def to_query(self) -> Dict[str, str]:
|
|
304
|
+
return _clean_query(
|
|
305
|
+
{
|
|
306
|
+
"limit": self.limit,
|
|
307
|
+
"cursor": self.cursor,
|
|
308
|
+
"channel": self.channel,
|
|
309
|
+
"deviceId": self.device_id,
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass
|
|
315
|
+
class MessageMutation:
|
|
316
|
+
name: Optional[str] = None
|
|
317
|
+
data: Optional[Any] = None
|
|
318
|
+
extras: Optional[Mapping[str, Any]] = None
|
|
319
|
+
clear_fields: Optional[Sequence[str]] = None
|
|
320
|
+
client_id: Optional[str] = None
|
|
321
|
+
socket_id: Optional[str] = None
|
|
322
|
+
description: Optional[str] = None
|
|
323
|
+
metadata: Optional[Mapping[str, Any]] = None
|
|
324
|
+
|
|
325
|
+
def to_payload(self, marshaller: JsonMarshaller) -> JsonDict:
|
|
326
|
+
payload = _clean_raw(
|
|
327
|
+
{
|
|
328
|
+
"name": self.name,
|
|
329
|
+
"data": _encode_data(self.data, marshaller)
|
|
330
|
+
if self.data is not None
|
|
331
|
+
else None,
|
|
332
|
+
"extras": dict(self.extras) if self.extras is not None else None,
|
|
333
|
+
"clear_fields": list(self.clear_fields)
|
|
334
|
+
if self.clear_fields is not None
|
|
335
|
+
else None,
|
|
336
|
+
"client_id": self.client_id,
|
|
337
|
+
"socket_id": self.socket_id,
|
|
338
|
+
"description": self.description,
|
|
339
|
+
"metadata": dict(self.metadata) if self.metadata is not None else None,
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
if not payload:
|
|
343
|
+
raise SockudoError("Message mutation requires at least one field")
|
|
344
|
+
return payload
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass
|
|
348
|
+
class PublishAnnotationRequest:
|
|
349
|
+
type: str
|
|
350
|
+
name: Optional[str] = None
|
|
351
|
+
client_id: Optional[str] = None
|
|
352
|
+
socket_id: Optional[str] = None
|
|
353
|
+
count: Optional[int] = None
|
|
354
|
+
data: Optional[Any] = None
|
|
355
|
+
encoding: Optional[str] = None
|
|
356
|
+
|
|
357
|
+
def to_payload(self) -> JsonDict:
|
|
358
|
+
return _clean_raw(
|
|
359
|
+
{
|
|
360
|
+
"type": self.type,
|
|
361
|
+
"name": self.name,
|
|
362
|
+
"clientId": self.client_id,
|
|
363
|
+
"socketId": self.socket_id,
|
|
364
|
+
"count": self.count,
|
|
365
|
+
"data": self.data,
|
|
366
|
+
"encoding": self.encoding,
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class AnnotationEventsParams:
|
|
373
|
+
type: Optional[str] = None
|
|
374
|
+
from_serial: Optional[str] = None
|
|
375
|
+
limit: Optional[int] = None
|
|
376
|
+
socket_id: Optional[str] = None
|
|
377
|
+
|
|
378
|
+
def to_query(self) -> Dict[str, str]:
|
|
379
|
+
return _clean_query(self.__dict__)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@dataclass(frozen=True)
|
|
383
|
+
class WebhookEvent:
|
|
384
|
+
name: str
|
|
385
|
+
channel: Optional[str] = None
|
|
386
|
+
event: Optional[str] = None
|
|
387
|
+
data: Optional[Any] = None
|
|
388
|
+
socket_id: Optional[str] = None
|
|
389
|
+
user_id: Optional[str] = None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class Webhook:
|
|
394
|
+
time_ms: Optional[int]
|
|
395
|
+
events: Tuple[WebhookEvent, ...] = field(default_factory=tuple)
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def parse(body: Union[str, bytes]) -> "Webhook":
|
|
399
|
+
text = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
400
|
+
raw = json.loads(text)
|
|
401
|
+
events = tuple(WebhookEvent(**event) for event in raw.get("events", []))
|
|
402
|
+
return Webhook(time_ms=raw.get("time_ms"), events=events)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def body_md5(body: str) -> str:
|
|
406
|
+
return hashlib.md5(body.encode("utf-8")).hexdigest()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def sign(input_value: str, secret: str) -> str:
|
|
410
|
+
return hmac.new(
|
|
411
|
+
secret.encode("utf-8"), input_value.encode("utf-8"), hashlib.sha256
|
|
412
|
+
).hexdigest()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class _BaseSockudo:
|
|
416
|
+
def __init__(
|
|
417
|
+
self,
|
|
418
|
+
app_id: Union[str, Config],
|
|
419
|
+
key: Optional[str] = None,
|
|
420
|
+
secret: Optional[str] = None,
|
|
421
|
+
encryption_master_key_base64: Optional[str] = None,
|
|
422
|
+
options: Optional[SockudoOptions] = None,
|
|
423
|
+
) -> None:
|
|
424
|
+
if isinstance(app_id, Config):
|
|
425
|
+
config = app_id
|
|
426
|
+
app_id = config.app_id
|
|
427
|
+
key = config.key
|
|
428
|
+
secret = config.secret
|
|
429
|
+
encryption_master_key_base64 = (
|
|
430
|
+
encryption_master_key_base64 or config.encryption_master_key_base64
|
|
431
|
+
)
|
|
432
|
+
options = options or config.options()
|
|
433
|
+
if not app_id or not key or not secret:
|
|
434
|
+
raise SockudoError("app_id, key, and secret are required")
|
|
435
|
+
self.app_id = app_id
|
|
436
|
+
self.key = key
|
|
437
|
+
self.secret = secret
|
|
438
|
+
self.options = options or SockudoOptions()
|
|
439
|
+
self.encryption_master_key_base64 = encryption_master_key_base64
|
|
440
|
+
self._encryption_master_key: Optional[bytes] = None
|
|
441
|
+
self._marshaller: JsonMarshaller = _default_marshaller
|
|
442
|
+
self._idempotency_base = uuid.uuid4().hex
|
|
443
|
+
self._idempotency_serial = 0
|
|
444
|
+
if encryption_master_key_base64 is not None:
|
|
445
|
+
try:
|
|
446
|
+
decoded = base64.b64decode(encryption_master_key_base64, validate=True)
|
|
447
|
+
except (binascii.Error, ValueError) as exc:
|
|
448
|
+
raise SockudoError(
|
|
449
|
+
"encryption_master_key_base64 must be valid base64"
|
|
450
|
+
) from exc
|
|
451
|
+
if len(decoded) != 32:
|
|
452
|
+
raise SockudoError(
|
|
453
|
+
"encryption_master_key_base64 must decode to 32 bytes"
|
|
454
|
+
)
|
|
455
|
+
self._encryption_master_key = decoded
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def from_url(cls, url: str) -> "_BaseSockudo":
|
|
459
|
+
parsed = urlparse(url)
|
|
460
|
+
if not parsed.username or not parsed.password:
|
|
461
|
+
raise SockudoError("URL must include key and secret credentials")
|
|
462
|
+
path_parts = [part for part in parsed.path.split("/") if part]
|
|
463
|
+
if len(path_parts) != 2 or path_parts[0] != "apps":
|
|
464
|
+
raise SockudoError("URL path must be /apps/{app_id}")
|
|
465
|
+
options = SockudoOptions(
|
|
466
|
+
host=parsed.hostname or "127.0.0.1",
|
|
467
|
+
port=parsed.port or (443 if parsed.scheme == "https" else 80),
|
|
468
|
+
use_tls=parsed.scheme == "https",
|
|
469
|
+
)
|
|
470
|
+
return cls(path_parts[1], parsed.username, parsed.password, options=options) # type: ignore[return-value]
|
|
471
|
+
|
|
472
|
+
def set_host(self, host: str) -> None:
|
|
473
|
+
self.options.host = host
|
|
474
|
+
|
|
475
|
+
def set_port(self, port: int) -> None:
|
|
476
|
+
self.options.port = port
|
|
477
|
+
|
|
478
|
+
def set_encrypted(self, encrypted: bool) -> None:
|
|
479
|
+
self.options.use_tls = encrypted
|
|
480
|
+
|
|
481
|
+
def set_auto_idempotency_key(self, auto: bool) -> None:
|
|
482
|
+
self.options.auto_idempotency = auto
|
|
483
|
+
|
|
484
|
+
def set_data_marshaller(self, marshaller: JsonMarshaller) -> None:
|
|
485
|
+
self._marshaller = marshaller
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def base_url(self) -> str:
|
|
489
|
+
scheme = "https" if self.options.use_tls else "http"
|
|
490
|
+
return f"{scheme}://{self.options.host}:{self.options.port}"
|
|
491
|
+
|
|
492
|
+
def signed_uri(
|
|
493
|
+
self,
|
|
494
|
+
method: str,
|
|
495
|
+
path: str,
|
|
496
|
+
body: Optional[str] = None,
|
|
497
|
+
parameters: Optional[Mapping[str, Any]] = None,
|
|
498
|
+
) -> str:
|
|
499
|
+
params = _clean_query(parameters or {})
|
|
500
|
+
reserved = RESERVED_QUERY_KEYS.intersection(params)
|
|
501
|
+
if reserved:
|
|
502
|
+
raise SockudoError(
|
|
503
|
+
f"Reserved signing parameters are not allowed: {sorted(reserved)}"
|
|
504
|
+
)
|
|
505
|
+
params.update(
|
|
506
|
+
{
|
|
507
|
+
"auth_key": self.key,
|
|
508
|
+
"auth_timestamp": str(int(time.time())),
|
|
509
|
+
"auth_version": "1.0",
|
|
510
|
+
}
|
|
511
|
+
)
|
|
512
|
+
if body is not None:
|
|
513
|
+
params["body_md5"] = body_md5(body)
|
|
514
|
+
signature_string = _signature_string(method.upper(), path, params)
|
|
515
|
+
params["auth_signature"] = sign(signature_string, self.secret)
|
|
516
|
+
return f"{self.base_url}{path}?{urlencode(params)}"
|
|
517
|
+
|
|
518
|
+
def authenticate(
|
|
519
|
+
self, socket_id: str, channel: str, presence_user: Optional[PresenceUser] = None
|
|
520
|
+
) -> str:
|
|
521
|
+
channel_data = (
|
|
522
|
+
presence_user.channel_data(self._marshaller)
|
|
523
|
+
if presence_user is not None
|
|
524
|
+
else None
|
|
525
|
+
)
|
|
526
|
+
string_to_sign = (
|
|
527
|
+
f"{socket_id}:{channel}"
|
|
528
|
+
if channel_data is None
|
|
529
|
+
else f"{socket_id}:{channel}:{channel_data}"
|
|
530
|
+
)
|
|
531
|
+
shared_secret = None
|
|
532
|
+
if _is_encrypted_channel(channel):
|
|
533
|
+
shared_secret = base64.b64encode(
|
|
534
|
+
self.channel_shared_secret(channel)
|
|
535
|
+
).decode("ascii")
|
|
536
|
+
return AuthData(
|
|
537
|
+
auth=f"{self.key}:{sign(string_to_sign, self.secret)}",
|
|
538
|
+
channel_data=channel_data,
|
|
539
|
+
shared_secret=shared_secret,
|
|
540
|
+
).to_json()
|
|
541
|
+
|
|
542
|
+
def authenticate_user(self, socket_id: str, user_data: Mapping[str, Any]) -> str:
|
|
543
|
+
user_data_json = self._marshaller(dict(user_data))
|
|
544
|
+
string_to_sign = f"{socket_id}::user::{user_data_json}"
|
|
545
|
+
return json.dumps(
|
|
546
|
+
{
|
|
547
|
+
"auth": f"{self.key}:{sign(string_to_sign, self.secret)}",
|
|
548
|
+
"user_data": user_data_json,
|
|
549
|
+
},
|
|
550
|
+
separators=(",", ":"),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def validate_webhook_signature(
|
|
554
|
+
self, key_header: str, signature_header: str, body: Union[str, bytes]
|
|
555
|
+
) -> Validity:
|
|
556
|
+
if key_header != self.key:
|
|
557
|
+
return Validity.SIGNED_WITH_WRONG_KEY
|
|
558
|
+
raw = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
559
|
+
expected = sign(raw, self.secret)
|
|
560
|
+
return (
|
|
561
|
+
Validity.VALID
|
|
562
|
+
if hmac.compare_digest(expected, signature_header)
|
|
563
|
+
else Validity.INVALID
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def parse_webhook(
|
|
567
|
+
self, key_header: str, signature_header: str, body: Union[str, bytes]
|
|
568
|
+
) -> Webhook:
|
|
569
|
+
if (
|
|
570
|
+
self.validate_webhook_signature(key_header, signature_header, body)
|
|
571
|
+
is not Validity.VALID
|
|
572
|
+
):
|
|
573
|
+
raise SockudoError("Invalid webhook signature")
|
|
574
|
+
webhook = Webhook.parse(body)
|
|
575
|
+
return self._decrypt_webhook(webhook)
|
|
576
|
+
|
|
577
|
+
def channel_shared_secret(self, channel: str) -> bytes:
|
|
578
|
+
if not _is_encrypted_channel(channel):
|
|
579
|
+
raise SockudoError(
|
|
580
|
+
f"Encrypted channel name must start with {ENCRYPTED_CHANNEL_PREFIX!r}"
|
|
581
|
+
)
|
|
582
|
+
if self._encryption_master_key is None:
|
|
583
|
+
raise SockudoError(
|
|
584
|
+
"Cannot generate shared_secret because encryption_master_key_base64 is not set"
|
|
585
|
+
)
|
|
586
|
+
return hashlib.sha256(
|
|
587
|
+
channel.encode("utf-8") + self._encryption_master_key
|
|
588
|
+
).digest()
|
|
589
|
+
|
|
590
|
+
def _next_idempotency_key(self) -> str:
|
|
591
|
+
self._idempotency_serial += 1
|
|
592
|
+
return f"{self._idempotency_base}:{self._idempotency_serial}"
|
|
593
|
+
|
|
594
|
+
def _event_payload(
|
|
595
|
+
self,
|
|
596
|
+
channels: Union[str, Sequence[str]],
|
|
597
|
+
event: str,
|
|
598
|
+
data: Any,
|
|
599
|
+
options: Optional[TriggerOptions],
|
|
600
|
+
) -> Tuple[JsonDict, Dict[str, str]]:
|
|
601
|
+
channel_list = [channels] if isinstance(channels, str) else list(channels)
|
|
602
|
+
if not channel_list:
|
|
603
|
+
raise SockudoError("At least one channel is required")
|
|
604
|
+
if len(channel_list) > 100:
|
|
605
|
+
raise SockudoError("Sockudo supports at most 100 channels per trigger")
|
|
606
|
+
encrypted_channels = [
|
|
607
|
+
channel for channel in channel_list if _is_encrypted_channel(channel)
|
|
608
|
+
]
|
|
609
|
+
if encrypted_channels and len(channel_list) > 1:
|
|
610
|
+
raise SockudoError(
|
|
611
|
+
"You cannot trigger to multiple channels when using encrypted channels"
|
|
612
|
+
)
|
|
613
|
+
opts = options or TriggerOptions()
|
|
614
|
+
idempotency_key = _resolve_idempotency_key(opts.idempotency_key)
|
|
615
|
+
if idempotency_key is None and self.options.auto_idempotency:
|
|
616
|
+
idempotency_key = self._next_idempotency_key()
|
|
617
|
+
if encrypted_channels:
|
|
618
|
+
payload: JsonDict = {
|
|
619
|
+
"name": event,
|
|
620
|
+
"data": self._encrypt_payload(channel_list[0], data),
|
|
621
|
+
"channels": channel_list,
|
|
622
|
+
}
|
|
623
|
+
else:
|
|
624
|
+
payload = {"name": event, "data": _encode_data(data, self._marshaller)}
|
|
625
|
+
payload["channel"] = channel_list[0]
|
|
626
|
+
if len(channel_list) > 1:
|
|
627
|
+
payload.pop("channel")
|
|
628
|
+
payload["channels"] = channel_list
|
|
629
|
+
_apply_common_event_fields(
|
|
630
|
+
payload, opts.socket_id, opts.info, idempotency_key, opts.extras, opts.tags
|
|
631
|
+
)
|
|
632
|
+
headers = {"X-Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
633
|
+
return payload, headers
|
|
634
|
+
|
|
635
|
+
def _batch_payload(self, batch: Sequence[Event]) -> JsonDict:
|
|
636
|
+
batch_serial: Optional[int] = None
|
|
637
|
+
items: List[JsonDict] = []
|
|
638
|
+
for index, event in enumerate(batch):
|
|
639
|
+
payload = self._event_to_payload(event)
|
|
640
|
+
idempotency_key = _resolve_idempotency_key(payload.get("idempotency_key"))
|
|
641
|
+
if idempotency_key is None and self.options.auto_idempotency:
|
|
642
|
+
if batch_serial is None:
|
|
643
|
+
self._idempotency_serial += 1
|
|
644
|
+
batch_serial = self._idempotency_serial
|
|
645
|
+
idempotency_key = f"{self._idempotency_base}:{batch_serial}:{index}"
|
|
646
|
+
if idempotency_key is not None:
|
|
647
|
+
payload["idempotency_key"] = idempotency_key
|
|
648
|
+
items.append(payload)
|
|
649
|
+
return {"batch": items}
|
|
650
|
+
|
|
651
|
+
def _event_to_payload(self, event: Event) -> JsonDict:
|
|
652
|
+
payload = event.to_payload(self._marshaller)
|
|
653
|
+
channel = event.channel
|
|
654
|
+
if event.channels is not None:
|
|
655
|
+
encrypted_channels = [
|
|
656
|
+
item for item in event.channels if _is_encrypted_channel(item)
|
|
657
|
+
]
|
|
658
|
+
if encrypted_channels and len(event.channels) > 1:
|
|
659
|
+
raise SockudoError(
|
|
660
|
+
"You cannot trigger to multiple channels when using encrypted channels"
|
|
661
|
+
)
|
|
662
|
+
if len(event.channels) == 1:
|
|
663
|
+
channel = event.channels[0]
|
|
664
|
+
if channel is not None and _is_encrypted_channel(channel):
|
|
665
|
+
payload["data"] = self._encrypt_payload(channel, event.data)
|
|
666
|
+
return payload
|
|
667
|
+
|
|
668
|
+
def _encrypt_payload(self, channel: str, data: Any) -> str:
|
|
669
|
+
plaintext = self._marshaller(data).encode("utf-8")
|
|
670
|
+
box = nacl_secret.SecretBox(self.channel_shared_secret(channel))
|
|
671
|
+
nonce = nacl_utils.random(nacl_secret.SecretBox.NONCE_SIZE)
|
|
672
|
+
encrypted = box.encrypt(plaintext, nonce)
|
|
673
|
+
return json.dumps(
|
|
674
|
+
{
|
|
675
|
+
"nonce": base64.b64encode(encrypted.nonce).decode("ascii"),
|
|
676
|
+
"ciphertext": base64.b64encode(encrypted.ciphertext).decode("ascii"),
|
|
677
|
+
},
|
|
678
|
+
separators=(",", ":"),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
def _decrypt_webhook(self, webhook: Webhook) -> Webhook:
|
|
682
|
+
events = tuple(self._decrypt_webhook_event(event) for event in webhook.events)
|
|
683
|
+
return Webhook(time_ms=webhook.time_ms, events=events)
|
|
684
|
+
|
|
685
|
+
def _decrypt_webhook_event(self, event: WebhookEvent) -> WebhookEvent:
|
|
686
|
+
if not event.channel or not _is_encrypted_channel(event.channel):
|
|
687
|
+
return event
|
|
688
|
+
if not isinstance(event.data, str):
|
|
689
|
+
raise SockudoError("Encrypted webhook event data must be a string")
|
|
690
|
+
try:
|
|
691
|
+
encrypted = json.loads(event.data)
|
|
692
|
+
nonce = base64.b64decode(encrypted["nonce"], validate=True)
|
|
693
|
+
ciphertext = base64.b64decode(encrypted["ciphertext"], validate=True)
|
|
694
|
+
plaintext = nacl_secret.SecretBox(
|
|
695
|
+
self.channel_shared_secret(event.channel)
|
|
696
|
+
).decrypt(ciphertext, nonce)
|
|
697
|
+
except (
|
|
698
|
+
KeyError,
|
|
699
|
+
TypeError,
|
|
700
|
+
ValueError,
|
|
701
|
+
binascii.Error,
|
|
702
|
+
nacl_exceptions.CryptoError,
|
|
703
|
+
) as exc:
|
|
704
|
+
raise SockudoError("Failed to decrypt encrypted webhook event") from exc
|
|
705
|
+
return WebhookEvent(
|
|
706
|
+
name=event.name,
|
|
707
|
+
channel=event.channel,
|
|
708
|
+
event=event.event,
|
|
709
|
+
data=plaintext.decode("utf-8"),
|
|
710
|
+
socket_id=event.socket_id,
|
|
711
|
+
user_id=event.user_id,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _serialize(self, payload: Mapping[str, Any]) -> str:
|
|
715
|
+
return json.dumps(payload, separators=(",", ":"), sort_keys=False)
|
|
716
|
+
|
|
717
|
+
def _path(self, suffix: str) -> str:
|
|
718
|
+
return f"/apps/{quote(self.app_id, safe='')}{suffix}"
|
|
719
|
+
|
|
720
|
+
def _channel_path(self, channel: str, suffix: str = "") -> str:
|
|
721
|
+
return self._path(f"/channels/{quote(channel, safe='')}{suffix}")
|
|
722
|
+
|
|
723
|
+
def _push_headers(
|
|
724
|
+
self,
|
|
725
|
+
capability: str = "push-admin",
|
|
726
|
+
device_identity_token: Optional[str] = None,
|
|
727
|
+
) -> Dict[str, str]:
|
|
728
|
+
headers = {"X-Sockudo-Push-Capability": capability}
|
|
729
|
+
if device_identity_token is not None:
|
|
730
|
+
headers["X-Sockudo-Device-Identity-Token"] = device_identity_token
|
|
731
|
+
return headers
|
|
732
|
+
|
|
733
|
+
def _push_path(self, suffix: str) -> str:
|
|
734
|
+
return self._path(f"/push{suffix}")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class Sockudo(_BaseSockudo):
|
|
738
|
+
def __init__(
|
|
739
|
+
self,
|
|
740
|
+
app_id: Union[str, Config],
|
|
741
|
+
key: Optional[str] = None,
|
|
742
|
+
secret: Optional[str] = None,
|
|
743
|
+
encryption_master_key_base64: Optional[str] = None,
|
|
744
|
+
options: Optional[SockudoOptions] = None,
|
|
745
|
+
client: Optional[httpx.Client] = None,
|
|
746
|
+
) -> None:
|
|
747
|
+
super().__init__(app_id, key, secret, encryption_master_key_base64, options)
|
|
748
|
+
self._client = client or _make_sync_client(self.options)
|
|
749
|
+
self._owns_client = client is None
|
|
750
|
+
|
|
751
|
+
def close(self) -> None:
|
|
752
|
+
if self._owns_client:
|
|
753
|
+
self._client.close()
|
|
754
|
+
|
|
755
|
+
def __enter__(self) -> "Sockudo":
|
|
756
|
+
return self
|
|
757
|
+
|
|
758
|
+
def __exit__(self, *_args: object) -> None:
|
|
759
|
+
self.close()
|
|
760
|
+
|
|
761
|
+
def trigger(
|
|
762
|
+
self,
|
|
763
|
+
channels: Union[str, Sequence[str]],
|
|
764
|
+
event: str,
|
|
765
|
+
data: Any,
|
|
766
|
+
options: Optional[TriggerOptions] = None,
|
|
767
|
+
) -> Result:
|
|
768
|
+
payload, headers = self._event_payload(channels, event, data, options)
|
|
769
|
+
return self._post(self._path("/events"), payload, headers=headers)
|
|
770
|
+
|
|
771
|
+
def trigger_batch(self, batch: Sequence[Event]) -> Result:
|
|
772
|
+
return self._post(self._path("/batch_events"), self._batch_payload(batch))
|
|
773
|
+
|
|
774
|
+
def send_to_user(
|
|
775
|
+
self,
|
|
776
|
+
user_id: str,
|
|
777
|
+
event: str,
|
|
778
|
+
data: Any,
|
|
779
|
+
options: Optional[TriggerOptions] = None,
|
|
780
|
+
) -> Result:
|
|
781
|
+
if not user_id:
|
|
782
|
+
raise SockudoError("user_id is required")
|
|
783
|
+
return self.trigger(f"#server-to-user-{user_id}", event, data, options)
|
|
784
|
+
|
|
785
|
+
def get(self, path: str, params: Optional[Mapping[str, Any]] = None) -> Result:
|
|
786
|
+
full_path = (
|
|
787
|
+
path
|
|
788
|
+
if path.startswith("/apps/")
|
|
789
|
+
else self._path(path if path.startswith("/") else f"/{path}")
|
|
790
|
+
)
|
|
791
|
+
return self._request("GET", full_path, None, params=params)
|
|
792
|
+
|
|
793
|
+
def list_channels(self, params: Optional[ChannelsParams] = None) -> Result:
|
|
794
|
+
return self.get("/channels", params.to_query() if params else None)
|
|
795
|
+
|
|
796
|
+
def get_channel(
|
|
797
|
+
self, channel: str, params: Optional[ChannelParams] = None
|
|
798
|
+
) -> Result:
|
|
799
|
+
return self.get(
|
|
800
|
+
self._channel_path(channel), params.to_query() if params else None
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def get_channel_users(self, channel: str) -> Result:
|
|
804
|
+
return self.get(self._channel_path(channel, "/users"))
|
|
805
|
+
|
|
806
|
+
def get_channel_history(
|
|
807
|
+
self, channel: str, params: Optional[HistoryParams] = None
|
|
808
|
+
) -> Result:
|
|
809
|
+
return self.get(
|
|
810
|
+
self._channel_path(channel, "/history"),
|
|
811
|
+
params.to_query() if params else None,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
def get_channel_history_state(self, channel: str) -> Result:
|
|
815
|
+
return self.get(self._channel_path(channel, "/history/state"))
|
|
816
|
+
|
|
817
|
+
def reset_channel_history(
|
|
818
|
+
self, channel: str, reason: str, requested_by: Optional[str] = None
|
|
819
|
+
) -> Result:
|
|
820
|
+
return self._post(
|
|
821
|
+
self._channel_path(channel, "/history/reset"),
|
|
822
|
+
_operator_payload(channel, "reset", reason, requested_by),
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
def purge_channel_history(
|
|
826
|
+
self,
|
|
827
|
+
channel: str,
|
|
828
|
+
mode: str,
|
|
829
|
+
reason: str,
|
|
830
|
+
requested_by: Optional[str] = None,
|
|
831
|
+
before_serial: Optional[int] = None,
|
|
832
|
+
before_time_ms: Optional[int] = None,
|
|
833
|
+
) -> Result:
|
|
834
|
+
payload = _operator_payload(channel, "purge", reason, requested_by)
|
|
835
|
+
payload.update(
|
|
836
|
+
_clean_raw(
|
|
837
|
+
{
|
|
838
|
+
"mode": mode,
|
|
839
|
+
"before_serial": before_serial,
|
|
840
|
+
"before_time_ms": before_time_ms,
|
|
841
|
+
}
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
return self._post(self._channel_path(channel, "/history/purge"), payload)
|
|
845
|
+
|
|
846
|
+
def get_channel_presence_history(
|
|
847
|
+
self, channel: str, params: Optional[PresenceHistoryParams] = None
|
|
848
|
+
) -> Result:
|
|
849
|
+
return self.get(
|
|
850
|
+
self._channel_path(channel, "/presence/history"),
|
|
851
|
+
params.to_query() if params else None,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def get_channel_presence_history_state(self, channel: str) -> Result:
|
|
855
|
+
return self.get(self._channel_path(channel, "/presence/history/state"))
|
|
856
|
+
|
|
857
|
+
def reset_channel_presence_history(
|
|
858
|
+
self, channel: str, reason: str, requested_by: Optional[str] = None
|
|
859
|
+
) -> Result:
|
|
860
|
+
return self._post(
|
|
861
|
+
self._channel_path(channel, "/presence/history/reset"),
|
|
862
|
+
_operator_payload(channel, "reset", reason, requested_by),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
def get_channel_presence_snapshot(
|
|
866
|
+
self, channel: str, params: Optional[PresenceSnapshotParams] = None
|
|
867
|
+
) -> Result:
|
|
868
|
+
return self.get(
|
|
869
|
+
self._channel_path(channel, "/presence/history/snapshot"),
|
|
870
|
+
params.to_query() if params else None,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def get_message(self, channel: str, message_serial: str) -> Result:
|
|
874
|
+
return self.get(self._message_path(channel, message_serial))
|
|
875
|
+
|
|
876
|
+
def get_message_versions(
|
|
877
|
+
self,
|
|
878
|
+
channel: str,
|
|
879
|
+
message_serial: str,
|
|
880
|
+
params: Optional[MessageVersionsParams] = None,
|
|
881
|
+
) -> Result:
|
|
882
|
+
return self.get(
|
|
883
|
+
f"{self._message_path(channel, message_serial)}/versions",
|
|
884
|
+
params.to_query() if params else None,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
def update_message(
|
|
888
|
+
self,
|
|
889
|
+
channel: str,
|
|
890
|
+
message_serial: str,
|
|
891
|
+
mutation: Union[MessageMutation, Mapping[str, Any]],
|
|
892
|
+
) -> Result:
|
|
893
|
+
return self._post(
|
|
894
|
+
f"{self._message_path(channel, message_serial)}/update",
|
|
895
|
+
_mutation_payload(mutation, self._marshaller),
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
def delete_message(
|
|
899
|
+
self,
|
|
900
|
+
channel: str,
|
|
901
|
+
message_serial: str,
|
|
902
|
+
mutation: Optional[Union[MessageMutation, Mapping[str, Any]]] = None,
|
|
903
|
+
) -> Result:
|
|
904
|
+
return self._post(
|
|
905
|
+
f"{self._message_path(channel, message_serial)}/delete",
|
|
906
|
+
_mutation_payload(mutation, self._marshaller)
|
|
907
|
+
if mutation is not None
|
|
908
|
+
else {},
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def append_message(
|
|
912
|
+
self,
|
|
913
|
+
channel: str,
|
|
914
|
+
message_serial: str,
|
|
915
|
+
data: str,
|
|
916
|
+
socket_id: Optional[str] = None,
|
|
917
|
+
) -> Result:
|
|
918
|
+
if not data:
|
|
919
|
+
raise SockudoError("append_message data must be non-empty")
|
|
920
|
+
return self._post(
|
|
921
|
+
f"{self._message_path(channel, message_serial)}/append",
|
|
922
|
+
_clean_raw({"data": data, "socket_id": socket_id}),
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
def publish_annotation(
|
|
926
|
+
self,
|
|
927
|
+
channel: str,
|
|
928
|
+
message_serial: str,
|
|
929
|
+
request: Union[PublishAnnotationRequest, Mapping[str, Any]],
|
|
930
|
+
) -> Result:
|
|
931
|
+
payload = (
|
|
932
|
+
request.to_payload()
|
|
933
|
+
if isinstance(request, PublishAnnotationRequest)
|
|
934
|
+
else dict(request)
|
|
935
|
+
)
|
|
936
|
+
return self._post(
|
|
937
|
+
f"{self._message_path(channel, message_serial)}/annotations", payload
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
def list_annotations(
|
|
941
|
+
self,
|
|
942
|
+
channel: str,
|
|
943
|
+
message_serial: str,
|
|
944
|
+
params: Optional[AnnotationEventsParams] = None,
|
|
945
|
+
) -> Result:
|
|
946
|
+
return self.get(
|
|
947
|
+
f"{self._message_path(channel, message_serial)}/annotations",
|
|
948
|
+
params.to_query() if params else None,
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def delete_annotation(
|
|
952
|
+
self,
|
|
953
|
+
channel: str,
|
|
954
|
+
message_serial: str,
|
|
955
|
+
annotation_serial: str,
|
|
956
|
+
socket_id: Optional[str] = None,
|
|
957
|
+
) -> Result:
|
|
958
|
+
return self._request(
|
|
959
|
+
"DELETE",
|
|
960
|
+
f"{self._message_path(channel, message_serial)}/annotations/{quote(annotation_serial, safe='')}",
|
|
961
|
+
None,
|
|
962
|
+
params=_clean_query({"socket_id": socket_id}),
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
def terminate_user_connections(self, user_id: str) -> Result:
|
|
966
|
+
return self._post(
|
|
967
|
+
self._path(f"/users/{quote(user_id, safe='')}/terminate_connections"), {}
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
def activate_device(
|
|
971
|
+
self, device: Mapping[str, Any], rotate_device_identity_token: bool = False
|
|
972
|
+
) -> Result:
|
|
973
|
+
headers = self._push_headers("push-admin")
|
|
974
|
+
if rotate_device_identity_token:
|
|
975
|
+
headers["X-Sockudo-Rotate-Device-Identity-Token"] = "true"
|
|
976
|
+
return self._post(
|
|
977
|
+
self._push_path("/deviceRegistrations"), dict(device), headers=headers
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
def create_device_activation(self, device: Mapping[str, Any]) -> Result:
|
|
981
|
+
return self.activate_device(device)
|
|
982
|
+
|
|
983
|
+
def update_device_registration(
|
|
984
|
+
self, device: Mapping[str, Any], device_identity_token: str
|
|
985
|
+
) -> Result:
|
|
986
|
+
return self._post(
|
|
987
|
+
self._push_path("/deviceRegistrations"),
|
|
988
|
+
dict(device),
|
|
989
|
+
headers=self._push_headers("push-subscribe", device_identity_token),
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
def list_device_registrations(
|
|
993
|
+
self, params: Optional[PushCursorParams] = None
|
|
994
|
+
) -> Result:
|
|
995
|
+
return self._request(
|
|
996
|
+
"GET",
|
|
997
|
+
self._push_path("/deviceRegistrations"),
|
|
998
|
+
None,
|
|
999
|
+
params=params.to_query() if params else None,
|
|
1000
|
+
headers=self._push_headers("push-admin"),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
def get_device_registration(
|
|
1004
|
+
self, device_id: str, device_identity_token: Optional[str] = None
|
|
1005
|
+
) -> Result:
|
|
1006
|
+
headers = (
|
|
1007
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1008
|
+
if device_identity_token
|
|
1009
|
+
else self._push_headers("push-admin")
|
|
1010
|
+
)
|
|
1011
|
+
return self._request(
|
|
1012
|
+
"GET",
|
|
1013
|
+
self._push_path(f"/deviceRegistrations/{quote(device_id, safe='')}"),
|
|
1014
|
+
None,
|
|
1015
|
+
headers=headers,
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
def delete_device_registration(
|
|
1019
|
+
self, device_id: str, device_identity_token: Optional[str] = None
|
|
1020
|
+
) -> Result:
|
|
1021
|
+
headers = (
|
|
1022
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1023
|
+
if device_identity_token
|
|
1024
|
+
else self._push_headers("push-admin")
|
|
1025
|
+
)
|
|
1026
|
+
return self._request(
|
|
1027
|
+
"DELETE",
|
|
1028
|
+
self._push_path(f"/deviceRegistrations/{quote(device_id, safe='')}"),
|
|
1029
|
+
None,
|
|
1030
|
+
headers=headers,
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
def remove_device_registrations_by_client(self, client_id: str) -> Result:
|
|
1034
|
+
return self._request(
|
|
1035
|
+
"DELETE",
|
|
1036
|
+
self._push_path("/deviceRegistrations"),
|
|
1037
|
+
None,
|
|
1038
|
+
params={"clientId": client_id},
|
|
1039
|
+
headers=self._push_headers("push-admin"),
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
def upsert_channel_push_subscription(
|
|
1043
|
+
self,
|
|
1044
|
+
subscription: Mapping[str, Any],
|
|
1045
|
+
device_identity_token: Optional[str] = None,
|
|
1046
|
+
) -> Result:
|
|
1047
|
+
headers = (
|
|
1048
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1049
|
+
if device_identity_token
|
|
1050
|
+
else self._push_headers("push-admin")
|
|
1051
|
+
)
|
|
1052
|
+
return self._post(
|
|
1053
|
+
self._push_path("/channelSubscriptions"),
|
|
1054
|
+
dict(subscription),
|
|
1055
|
+
headers=headers,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
def list_channel_push_subscriptions(
|
|
1059
|
+
self,
|
|
1060
|
+
params: Optional[PushSubscriptionParams] = None,
|
|
1061
|
+
device_identity_token: Optional[str] = None,
|
|
1062
|
+
) -> Result:
|
|
1063
|
+
headers = (
|
|
1064
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1065
|
+
if device_identity_token
|
|
1066
|
+
else self._push_headers("push-admin")
|
|
1067
|
+
)
|
|
1068
|
+
return self._request(
|
|
1069
|
+
"GET",
|
|
1070
|
+
self._push_path("/channelSubscriptions"),
|
|
1071
|
+
None,
|
|
1072
|
+
params=params.to_query() if params else None,
|
|
1073
|
+
headers=headers,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
def delete_channel_push_subscriptions(
|
|
1077
|
+
self,
|
|
1078
|
+
params: PushSubscriptionParams,
|
|
1079
|
+
device_identity_token: Optional[str] = None,
|
|
1080
|
+
) -> Result:
|
|
1081
|
+
headers = (
|
|
1082
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1083
|
+
if device_identity_token
|
|
1084
|
+
else self._push_headers("push-admin")
|
|
1085
|
+
)
|
|
1086
|
+
return self._request(
|
|
1087
|
+
"DELETE",
|
|
1088
|
+
self._push_path("/channelSubscriptions"),
|
|
1089
|
+
None,
|
|
1090
|
+
params=params.to_query(),
|
|
1091
|
+
headers=headers,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
def list_channel_push_subscription_channels(
|
|
1095
|
+
self, params: Optional[PushCursorParams] = None
|
|
1096
|
+
) -> Result:
|
|
1097
|
+
return self._request(
|
|
1098
|
+
"GET",
|
|
1099
|
+
self._push_path("/channelSubscriptions/channels"),
|
|
1100
|
+
None,
|
|
1101
|
+
params=params.to_query() if params else None,
|
|
1102
|
+
headers=self._push_headers("push-admin"),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
def list_push_credentials(
|
|
1106
|
+
self, params: Optional[PushCursorParams] = None
|
|
1107
|
+
) -> Result:
|
|
1108
|
+
return self._request(
|
|
1109
|
+
"GET",
|
|
1110
|
+
self._push_path("/credentials"),
|
|
1111
|
+
None,
|
|
1112
|
+
params=params.to_query() if params else None,
|
|
1113
|
+
headers=self._push_headers("push-admin"),
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def put_push_credential(
|
|
1117
|
+
self, provider: str, credential: Mapping[str, Any]
|
|
1118
|
+
) -> Result:
|
|
1119
|
+
return self._post(
|
|
1120
|
+
self._push_path(f"/credentials/{quote(provider, safe='')}"),
|
|
1121
|
+
dict(credential),
|
|
1122
|
+
headers=self._push_headers("push-admin"),
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
def publish_push(self, request: Mapping[str, Any]) -> Result:
|
|
1126
|
+
payload = dict(request)
|
|
1127
|
+
payload["sync"] = False
|
|
1128
|
+
return self._post(
|
|
1129
|
+
self._push_path("/publish"),
|
|
1130
|
+
payload,
|
|
1131
|
+
headers=self._push_headers("push-admin"),
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
def publish_push_direct(self, request: Mapping[str, Any]) -> Result:
|
|
1135
|
+
return self.publish_push(request)
|
|
1136
|
+
|
|
1137
|
+
def publish_push_batch(self, requests: Sequence[Mapping[str, Any]]) -> Result:
|
|
1138
|
+
payload = []
|
|
1139
|
+
for request in requests:
|
|
1140
|
+
item = dict(request)
|
|
1141
|
+
item["sync"] = False
|
|
1142
|
+
payload.append(item)
|
|
1143
|
+
return self._post(
|
|
1144
|
+
self._push_path("/batch/publish"),
|
|
1145
|
+
payload,
|
|
1146
|
+
headers=self._push_headers("push-admin"),
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
def schedule_push(self, request: Mapping[str, Any]) -> Result:
|
|
1150
|
+
if "notBeforeMs" not in request:
|
|
1151
|
+
raise SockudoError("scheduled push requires notBeforeMs")
|
|
1152
|
+
return self.publish_push(request)
|
|
1153
|
+
|
|
1154
|
+
def get_publish_status(self, publish_id: str) -> Result:
|
|
1155
|
+
return self._request(
|
|
1156
|
+
"GET",
|
|
1157
|
+
self._push_path(f"/publish/{quote(publish_id, safe='')}/status"),
|
|
1158
|
+
None,
|
|
1159
|
+
headers=self._push_headers("push-admin"),
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
def cancel_scheduled_push(self, publish_id: str) -> Result:
|
|
1163
|
+
return self._request(
|
|
1164
|
+
"DELETE",
|
|
1165
|
+
self._push_path(f"/scheduled/{quote(publish_id, safe='')}"),
|
|
1166
|
+
None,
|
|
1167
|
+
headers=self._push_headers("push-admin"),
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
def post_push_delivery_status(self, event: Mapping[str, Any]) -> Result:
|
|
1171
|
+
return self._post(
|
|
1172
|
+
self._push_path("/deliveryStatus"),
|
|
1173
|
+
dict(event),
|
|
1174
|
+
headers=self._push_headers("push-admin"),
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
def _message_path(self, channel: str, message_serial: str) -> str:
|
|
1178
|
+
return self._channel_path(
|
|
1179
|
+
channel, f"/messages/{quote(message_serial, safe='')}"
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
def _post(
|
|
1183
|
+
self,
|
|
1184
|
+
path: str,
|
|
1185
|
+
payload: Mapping[str, Any],
|
|
1186
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
1187
|
+
) -> Result:
|
|
1188
|
+
return self._request("POST", path, self._serialize(payload), headers=headers)
|
|
1189
|
+
|
|
1190
|
+
def _request(
|
|
1191
|
+
self,
|
|
1192
|
+
method: str,
|
|
1193
|
+
path: str,
|
|
1194
|
+
body: Optional[str],
|
|
1195
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
1196
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
1197
|
+
) -> Result:
|
|
1198
|
+
url = self.signed_uri(method, path, body, params)
|
|
1199
|
+
last = Result(Status.NETWORK_ERROR, "request was not attempted")
|
|
1200
|
+
for attempt in range(1, self.options.max_retries + 1):
|
|
1201
|
+
try:
|
|
1202
|
+
response = self._client.request(
|
|
1203
|
+
method,
|
|
1204
|
+
url,
|
|
1205
|
+
content=body,
|
|
1206
|
+
headers={"Content-Type": "application/json", **dict(headers or {})},
|
|
1207
|
+
)
|
|
1208
|
+
last = Result.from_response(response)
|
|
1209
|
+
except httpx.HTTPError as exc:
|
|
1210
|
+
last = Result.from_exception(exc)
|
|
1211
|
+
if not last.status.should_retry() or attempt == self.options.max_retries:
|
|
1212
|
+
return last
|
|
1213
|
+
time.sleep(self.options.retry_base_delay * (2 ** (attempt - 1)))
|
|
1214
|
+
return last
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
class AsyncSockudo(_BaseSockudo):
|
|
1218
|
+
def __init__(
|
|
1219
|
+
self,
|
|
1220
|
+
app_id: Union[str, Config],
|
|
1221
|
+
key: Optional[str] = None,
|
|
1222
|
+
secret: Optional[str] = None,
|
|
1223
|
+
encryption_master_key_base64: Optional[str] = None,
|
|
1224
|
+
options: Optional[SockudoOptions] = None,
|
|
1225
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
1226
|
+
) -> None:
|
|
1227
|
+
super().__init__(app_id, key, secret, encryption_master_key_base64, options)
|
|
1228
|
+
self._client = client or _make_async_client(self.options)
|
|
1229
|
+
self._owns_client = client is None
|
|
1230
|
+
|
|
1231
|
+
async def close(self) -> None:
|
|
1232
|
+
if self._owns_client:
|
|
1233
|
+
await self._client.aclose()
|
|
1234
|
+
|
|
1235
|
+
async def __aenter__(self) -> "AsyncSockudo":
|
|
1236
|
+
return self
|
|
1237
|
+
|
|
1238
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
1239
|
+
await self.close()
|
|
1240
|
+
|
|
1241
|
+
async def trigger(
|
|
1242
|
+
self,
|
|
1243
|
+
channels: Union[str, Sequence[str]],
|
|
1244
|
+
event: str,
|
|
1245
|
+
data: Any,
|
|
1246
|
+
options: Optional[TriggerOptions] = None,
|
|
1247
|
+
) -> Result:
|
|
1248
|
+
payload, headers = self._event_payload(channels, event, data, options)
|
|
1249
|
+
return await self._post(self._path("/events"), payload, headers=headers)
|
|
1250
|
+
|
|
1251
|
+
async def trigger_batch(self, batch: Sequence[Event]) -> Result:
|
|
1252
|
+
return await self._post(self._path("/batch_events"), self._batch_payload(batch))
|
|
1253
|
+
|
|
1254
|
+
async def send_to_user(
|
|
1255
|
+
self,
|
|
1256
|
+
user_id: str,
|
|
1257
|
+
event: str,
|
|
1258
|
+
data: Any,
|
|
1259
|
+
options: Optional[TriggerOptions] = None,
|
|
1260
|
+
) -> Result:
|
|
1261
|
+
if not user_id:
|
|
1262
|
+
raise SockudoError("user_id is required")
|
|
1263
|
+
return await self.trigger(f"#server-to-user-{user_id}", event, data, options)
|
|
1264
|
+
|
|
1265
|
+
async def get(
|
|
1266
|
+
self, path: str, params: Optional[Mapping[str, Any]] = None
|
|
1267
|
+
) -> Result:
|
|
1268
|
+
full_path = (
|
|
1269
|
+
path
|
|
1270
|
+
if path.startswith("/apps/")
|
|
1271
|
+
else self._path(path if path.startswith("/") else f"/{path}")
|
|
1272
|
+
)
|
|
1273
|
+
return await self._request("GET", full_path, None, params=params)
|
|
1274
|
+
|
|
1275
|
+
async def list_channels(self, params: Optional[ChannelsParams] = None) -> Result:
|
|
1276
|
+
return await self.get("/channels", params.to_query() if params else None)
|
|
1277
|
+
|
|
1278
|
+
async def get_channel(
|
|
1279
|
+
self, channel: str, params: Optional[ChannelParams] = None
|
|
1280
|
+
) -> Result:
|
|
1281
|
+
return await self.get(
|
|
1282
|
+
self._channel_path(channel), params.to_query() if params else None
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
async def get_channel_users(self, channel: str) -> Result:
|
|
1286
|
+
return await self.get(self._channel_path(channel, "/users"))
|
|
1287
|
+
|
|
1288
|
+
async def get_channel_history(
|
|
1289
|
+
self, channel: str, params: Optional[HistoryParams] = None
|
|
1290
|
+
) -> Result:
|
|
1291
|
+
return await self.get(
|
|
1292
|
+
self._channel_path(channel, "/history"),
|
|
1293
|
+
params.to_query() if params else None,
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
async def get_channel_history_state(self, channel: str) -> Result:
|
|
1297
|
+
return await self.get(self._channel_path(channel, "/history/state"))
|
|
1298
|
+
|
|
1299
|
+
async def reset_channel_history(
|
|
1300
|
+
self, channel: str, reason: str, requested_by: Optional[str] = None
|
|
1301
|
+
) -> Result:
|
|
1302
|
+
return await self._post(
|
|
1303
|
+
self._channel_path(channel, "/history/reset"),
|
|
1304
|
+
_operator_payload(channel, "reset", reason, requested_by),
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
async def purge_channel_history(
|
|
1308
|
+
self,
|
|
1309
|
+
channel: str,
|
|
1310
|
+
mode: str,
|
|
1311
|
+
reason: str,
|
|
1312
|
+
requested_by: Optional[str] = None,
|
|
1313
|
+
before_serial: Optional[int] = None,
|
|
1314
|
+
before_time_ms: Optional[int] = None,
|
|
1315
|
+
) -> Result:
|
|
1316
|
+
payload = _operator_payload(channel, "purge", reason, requested_by)
|
|
1317
|
+
payload.update(
|
|
1318
|
+
_clean_raw(
|
|
1319
|
+
{
|
|
1320
|
+
"mode": mode,
|
|
1321
|
+
"before_serial": before_serial,
|
|
1322
|
+
"before_time_ms": before_time_ms,
|
|
1323
|
+
}
|
|
1324
|
+
)
|
|
1325
|
+
)
|
|
1326
|
+
return await self._post(self._channel_path(channel, "/history/purge"), payload)
|
|
1327
|
+
|
|
1328
|
+
async def get_channel_presence_history(
|
|
1329
|
+
self, channel: str, params: Optional[PresenceHistoryParams] = None
|
|
1330
|
+
) -> Result:
|
|
1331
|
+
return await self.get(
|
|
1332
|
+
self._channel_path(channel, "/presence/history"),
|
|
1333
|
+
params.to_query() if params else None,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
async def get_channel_presence_history_state(self, channel: str) -> Result:
|
|
1337
|
+
return await self.get(self._channel_path(channel, "/presence/history/state"))
|
|
1338
|
+
|
|
1339
|
+
async def reset_channel_presence_history(
|
|
1340
|
+
self, channel: str, reason: str, requested_by: Optional[str] = None
|
|
1341
|
+
) -> Result:
|
|
1342
|
+
return await self._post(
|
|
1343
|
+
self._channel_path(channel, "/presence/history/reset"),
|
|
1344
|
+
_operator_payload(channel, "reset", reason, requested_by),
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
async def get_channel_presence_snapshot(
|
|
1348
|
+
self, channel: str, params: Optional[PresenceSnapshotParams] = None
|
|
1349
|
+
) -> Result:
|
|
1350
|
+
return await self.get(
|
|
1351
|
+
self._channel_path(channel, "/presence/history/snapshot"),
|
|
1352
|
+
params.to_query() if params else None,
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
async def get_message(self, channel: str, message_serial: str) -> Result:
|
|
1356
|
+
return await self.get(self._message_path(channel, message_serial))
|
|
1357
|
+
|
|
1358
|
+
async def get_message_versions(
|
|
1359
|
+
self,
|
|
1360
|
+
channel: str,
|
|
1361
|
+
message_serial: str,
|
|
1362
|
+
params: Optional[MessageVersionsParams] = None,
|
|
1363
|
+
) -> Result:
|
|
1364
|
+
return await self.get(
|
|
1365
|
+
f"{self._message_path(channel, message_serial)}/versions",
|
|
1366
|
+
params.to_query() if params else None,
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
async def update_message(
|
|
1370
|
+
self,
|
|
1371
|
+
channel: str,
|
|
1372
|
+
message_serial: str,
|
|
1373
|
+
mutation: Union[MessageMutation, Mapping[str, Any]],
|
|
1374
|
+
) -> Result:
|
|
1375
|
+
return await self._post(
|
|
1376
|
+
f"{self._message_path(channel, message_serial)}/update",
|
|
1377
|
+
_mutation_payload(mutation, self._marshaller),
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
async def delete_message(
|
|
1381
|
+
self,
|
|
1382
|
+
channel: str,
|
|
1383
|
+
message_serial: str,
|
|
1384
|
+
mutation: Optional[Union[MessageMutation, Mapping[str, Any]]] = None,
|
|
1385
|
+
) -> Result:
|
|
1386
|
+
return await self._post(
|
|
1387
|
+
f"{self._message_path(channel, message_serial)}/delete",
|
|
1388
|
+
_mutation_payload(mutation, self._marshaller)
|
|
1389
|
+
if mutation is not None
|
|
1390
|
+
else {},
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
async def append_message(
|
|
1394
|
+
self,
|
|
1395
|
+
channel: str,
|
|
1396
|
+
message_serial: str,
|
|
1397
|
+
data: str,
|
|
1398
|
+
socket_id: Optional[str] = None,
|
|
1399
|
+
) -> Result:
|
|
1400
|
+
if not data:
|
|
1401
|
+
raise SockudoError("append_message data must be non-empty")
|
|
1402
|
+
return await self._post(
|
|
1403
|
+
f"{self._message_path(channel, message_serial)}/append",
|
|
1404
|
+
_clean_raw({"data": data, "socket_id": socket_id}),
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
async def publish_annotation(
|
|
1408
|
+
self,
|
|
1409
|
+
channel: str,
|
|
1410
|
+
message_serial: str,
|
|
1411
|
+
request: Union[PublishAnnotationRequest, Mapping[str, Any]],
|
|
1412
|
+
) -> Result:
|
|
1413
|
+
payload = (
|
|
1414
|
+
request.to_payload()
|
|
1415
|
+
if isinstance(request, PublishAnnotationRequest)
|
|
1416
|
+
else dict(request)
|
|
1417
|
+
)
|
|
1418
|
+
return await self._post(
|
|
1419
|
+
f"{self._message_path(channel, message_serial)}/annotations", payload
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
async def list_annotations(
|
|
1423
|
+
self,
|
|
1424
|
+
channel: str,
|
|
1425
|
+
message_serial: str,
|
|
1426
|
+
params: Optional[AnnotationEventsParams] = None,
|
|
1427
|
+
) -> Result:
|
|
1428
|
+
return await self.get(
|
|
1429
|
+
f"{self._message_path(channel, message_serial)}/annotations",
|
|
1430
|
+
params.to_query() if params else None,
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
async def delete_annotation(
|
|
1434
|
+
self,
|
|
1435
|
+
channel: str,
|
|
1436
|
+
message_serial: str,
|
|
1437
|
+
annotation_serial: str,
|
|
1438
|
+
socket_id: Optional[str] = None,
|
|
1439
|
+
) -> Result:
|
|
1440
|
+
return await self._request(
|
|
1441
|
+
"DELETE",
|
|
1442
|
+
f"{self._message_path(channel, message_serial)}/annotations/{quote(annotation_serial, safe='')}",
|
|
1443
|
+
None,
|
|
1444
|
+
params=_clean_query({"socket_id": socket_id}),
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
async def terminate_user_connections(self, user_id: str) -> Result:
|
|
1448
|
+
return await self._post(
|
|
1449
|
+
self._path(f"/users/{quote(user_id, safe='')}/terminate_connections"), {}
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
async def activate_device(
|
|
1453
|
+
self, device: Mapping[str, Any], rotate_device_identity_token: bool = False
|
|
1454
|
+
) -> Result:
|
|
1455
|
+
headers = self._push_headers("push-admin")
|
|
1456
|
+
if rotate_device_identity_token:
|
|
1457
|
+
headers["X-Sockudo-Rotate-Device-Identity-Token"] = "true"
|
|
1458
|
+
return await self._post(
|
|
1459
|
+
self._push_path("/deviceRegistrations"), dict(device), headers=headers
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
async def create_device_activation(self, device: Mapping[str, Any]) -> Result:
|
|
1463
|
+
return await self.activate_device(device)
|
|
1464
|
+
|
|
1465
|
+
async def update_device_registration(
|
|
1466
|
+
self, device: Mapping[str, Any], device_identity_token: str
|
|
1467
|
+
) -> Result:
|
|
1468
|
+
return await self._post(
|
|
1469
|
+
self._push_path("/deviceRegistrations"),
|
|
1470
|
+
dict(device),
|
|
1471
|
+
headers=self._push_headers("push-subscribe", device_identity_token),
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
async def list_device_registrations(
|
|
1475
|
+
self, params: Optional[PushCursorParams] = None
|
|
1476
|
+
) -> Result:
|
|
1477
|
+
return await self._request(
|
|
1478
|
+
"GET",
|
|
1479
|
+
self._push_path("/deviceRegistrations"),
|
|
1480
|
+
None,
|
|
1481
|
+
params=params.to_query() if params else None,
|
|
1482
|
+
headers=self._push_headers("push-admin"),
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
async def get_device_registration(
|
|
1486
|
+
self, device_id: str, device_identity_token: Optional[str] = None
|
|
1487
|
+
) -> Result:
|
|
1488
|
+
headers = (
|
|
1489
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1490
|
+
if device_identity_token
|
|
1491
|
+
else self._push_headers("push-admin")
|
|
1492
|
+
)
|
|
1493
|
+
return await self._request(
|
|
1494
|
+
"GET",
|
|
1495
|
+
self._push_path(f"/deviceRegistrations/{quote(device_id, safe='')}"),
|
|
1496
|
+
None,
|
|
1497
|
+
headers=headers,
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
async def delete_device_registration(
|
|
1501
|
+
self, device_id: str, device_identity_token: Optional[str] = None
|
|
1502
|
+
) -> Result:
|
|
1503
|
+
headers = (
|
|
1504
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1505
|
+
if device_identity_token
|
|
1506
|
+
else self._push_headers("push-admin")
|
|
1507
|
+
)
|
|
1508
|
+
return await self._request(
|
|
1509
|
+
"DELETE",
|
|
1510
|
+
self._push_path(f"/deviceRegistrations/{quote(device_id, safe='')}"),
|
|
1511
|
+
None,
|
|
1512
|
+
headers=headers,
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
async def remove_device_registrations_by_client(self, client_id: str) -> Result:
|
|
1516
|
+
return await self._request(
|
|
1517
|
+
"DELETE",
|
|
1518
|
+
self._push_path("/deviceRegistrations"),
|
|
1519
|
+
None,
|
|
1520
|
+
params={"clientId": client_id},
|
|
1521
|
+
headers=self._push_headers("push-admin"),
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
async def upsert_channel_push_subscription(
|
|
1525
|
+
self,
|
|
1526
|
+
subscription: Mapping[str, Any],
|
|
1527
|
+
device_identity_token: Optional[str] = None,
|
|
1528
|
+
) -> Result:
|
|
1529
|
+
headers = (
|
|
1530
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1531
|
+
if device_identity_token
|
|
1532
|
+
else self._push_headers("push-admin")
|
|
1533
|
+
)
|
|
1534
|
+
return await self._post(
|
|
1535
|
+
self._push_path("/channelSubscriptions"),
|
|
1536
|
+
dict(subscription),
|
|
1537
|
+
headers=headers,
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
async def list_channel_push_subscriptions(
|
|
1541
|
+
self,
|
|
1542
|
+
params: Optional[PushSubscriptionParams] = None,
|
|
1543
|
+
device_identity_token: Optional[str] = None,
|
|
1544
|
+
) -> Result:
|
|
1545
|
+
headers = (
|
|
1546
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1547
|
+
if device_identity_token
|
|
1548
|
+
else self._push_headers("push-admin")
|
|
1549
|
+
)
|
|
1550
|
+
return await self._request(
|
|
1551
|
+
"GET",
|
|
1552
|
+
self._push_path("/channelSubscriptions"),
|
|
1553
|
+
None,
|
|
1554
|
+
params=params.to_query() if params else None,
|
|
1555
|
+
headers=headers,
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
async def delete_channel_push_subscriptions(
|
|
1559
|
+
self,
|
|
1560
|
+
params: PushSubscriptionParams,
|
|
1561
|
+
device_identity_token: Optional[str] = None,
|
|
1562
|
+
) -> Result:
|
|
1563
|
+
headers = (
|
|
1564
|
+
self._push_headers("push-subscribe", device_identity_token)
|
|
1565
|
+
if device_identity_token
|
|
1566
|
+
else self._push_headers("push-admin")
|
|
1567
|
+
)
|
|
1568
|
+
return await self._request(
|
|
1569
|
+
"DELETE",
|
|
1570
|
+
self._push_path("/channelSubscriptions"),
|
|
1571
|
+
None,
|
|
1572
|
+
params=params.to_query(),
|
|
1573
|
+
headers=headers,
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
async def list_channel_push_subscription_channels(
|
|
1577
|
+
self, params: Optional[PushCursorParams] = None
|
|
1578
|
+
) -> Result:
|
|
1579
|
+
return await self._request(
|
|
1580
|
+
"GET",
|
|
1581
|
+
self._push_path("/channelSubscriptions/channels"),
|
|
1582
|
+
None,
|
|
1583
|
+
params=params.to_query() if params else None,
|
|
1584
|
+
headers=self._push_headers("push-admin"),
|
|
1585
|
+
)
|
|
1586
|
+
|
|
1587
|
+
async def list_push_credentials(
|
|
1588
|
+
self, params: Optional[PushCursorParams] = None
|
|
1589
|
+
) -> Result:
|
|
1590
|
+
return await self._request(
|
|
1591
|
+
"GET",
|
|
1592
|
+
self._push_path("/credentials"),
|
|
1593
|
+
None,
|
|
1594
|
+
params=params.to_query() if params else None,
|
|
1595
|
+
headers=self._push_headers("push-admin"),
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
async def put_push_credential(
|
|
1599
|
+
self, provider: str, credential: Mapping[str, Any]
|
|
1600
|
+
) -> Result:
|
|
1601
|
+
return await self._post(
|
|
1602
|
+
self._push_path(f"/credentials/{quote(provider, safe='')}"),
|
|
1603
|
+
dict(credential),
|
|
1604
|
+
headers=self._push_headers("push-admin"),
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
async def publish_push(self, request: Mapping[str, Any]) -> Result:
|
|
1608
|
+
payload = dict(request)
|
|
1609
|
+
payload["sync"] = False
|
|
1610
|
+
return await self._post(
|
|
1611
|
+
self._push_path("/publish"),
|
|
1612
|
+
payload,
|
|
1613
|
+
headers=self._push_headers("push-admin"),
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
async def publish_push_direct(self, request: Mapping[str, Any]) -> Result:
|
|
1617
|
+
return await self.publish_push(request)
|
|
1618
|
+
|
|
1619
|
+
async def publish_push_batch(self, requests: Sequence[Mapping[str, Any]]) -> Result:
|
|
1620
|
+
payload = []
|
|
1621
|
+
for request in requests:
|
|
1622
|
+
item = dict(request)
|
|
1623
|
+
item["sync"] = False
|
|
1624
|
+
payload.append(item)
|
|
1625
|
+
return await self._post(
|
|
1626
|
+
self._push_path("/batch/publish"),
|
|
1627
|
+
payload,
|
|
1628
|
+
headers=self._push_headers("push-admin"),
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
async def schedule_push(self, request: Mapping[str, Any]) -> Result:
|
|
1632
|
+
if "notBeforeMs" not in request:
|
|
1633
|
+
raise SockudoError("scheduled push requires notBeforeMs")
|
|
1634
|
+
return await self.publish_push(request)
|
|
1635
|
+
|
|
1636
|
+
async def get_publish_status(self, publish_id: str) -> Result:
|
|
1637
|
+
return await self._request(
|
|
1638
|
+
"GET",
|
|
1639
|
+
self._push_path(f"/publish/{quote(publish_id, safe='')}/status"),
|
|
1640
|
+
None,
|
|
1641
|
+
headers=self._push_headers("push-admin"),
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
async def cancel_scheduled_push(self, publish_id: str) -> Result:
|
|
1645
|
+
return await self._request(
|
|
1646
|
+
"DELETE",
|
|
1647
|
+
self._push_path(f"/scheduled/{quote(publish_id, safe='')}"),
|
|
1648
|
+
None,
|
|
1649
|
+
headers=self._push_headers("push-admin"),
|
|
1650
|
+
)
|
|
1651
|
+
|
|
1652
|
+
async def post_push_delivery_status(self, event: Mapping[str, Any]) -> Result:
|
|
1653
|
+
return await self._post(
|
|
1654
|
+
self._push_path("/deliveryStatus"),
|
|
1655
|
+
dict(event),
|
|
1656
|
+
headers=self._push_headers("push-admin"),
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
def _message_path(self, channel: str, message_serial: str) -> str:
|
|
1660
|
+
return self._channel_path(
|
|
1661
|
+
channel, f"/messages/{quote(message_serial, safe='')}"
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
async def _post(
|
|
1665
|
+
self,
|
|
1666
|
+
path: str,
|
|
1667
|
+
payload: Mapping[str, Any],
|
|
1668
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
1669
|
+
) -> Result:
|
|
1670
|
+
return await self._request(
|
|
1671
|
+
"POST", path, self._serialize(payload), headers=headers
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
async def _request(
|
|
1675
|
+
self,
|
|
1676
|
+
method: str,
|
|
1677
|
+
path: str,
|
|
1678
|
+
body: Optional[str],
|
|
1679
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
1680
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
1681
|
+
) -> Result:
|
|
1682
|
+
url = self.signed_uri(method, path, body, params)
|
|
1683
|
+
last = Result(Status.NETWORK_ERROR, "request was not attempted")
|
|
1684
|
+
for attempt in range(1, self.options.max_retries + 1):
|
|
1685
|
+
try:
|
|
1686
|
+
response = await self._client.request(
|
|
1687
|
+
method,
|
|
1688
|
+
url,
|
|
1689
|
+
content=body,
|
|
1690
|
+
headers={"Content-Type": "application/json", **dict(headers or {})},
|
|
1691
|
+
)
|
|
1692
|
+
last = Result.from_response(response)
|
|
1693
|
+
except httpx.HTTPError as exc:
|
|
1694
|
+
last = Result.from_exception(exc)
|
|
1695
|
+
if not last.status.should_retry() or attempt == self.options.max_retries:
|
|
1696
|
+
return last
|
|
1697
|
+
await _async_sleep(self.options.retry_base_delay * (2 ** (attempt - 1)))
|
|
1698
|
+
return last
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
async def _async_sleep(delay: float) -> None:
|
|
1702
|
+
import asyncio
|
|
1703
|
+
|
|
1704
|
+
await asyncio.sleep(delay)
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _signature_string(method: str, path: str, query_params: Mapping[str, str]) -> str:
|
|
1708
|
+
params = {
|
|
1709
|
+
key.lower(): value
|
|
1710
|
+
for key, value in query_params.items()
|
|
1711
|
+
if key != "auth_signature"
|
|
1712
|
+
}
|
|
1713
|
+
query = "&".join(f"{key}={params[key]}" for key in sorted(params))
|
|
1714
|
+
return f"{method}\n{path}\n{query}"
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _default_marshaller(data: Any) -> str:
|
|
1718
|
+
return json.dumps(data, separators=(",", ":"))
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _encode_data(data: Any, marshaller: JsonMarshaller) -> str:
|
|
1722
|
+
return data if isinstance(data, str) else marshaller(data)
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _resolve_idempotency_key(value: Optional[Union[str, bool]]) -> Optional[str]:
|
|
1726
|
+
if value is True:
|
|
1727
|
+
return str(uuid.uuid4())
|
|
1728
|
+
if value in (False, None):
|
|
1729
|
+
return None
|
|
1730
|
+
return str(value)
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def _is_encrypted_channel(channel: str) -> bool:
|
|
1734
|
+
return channel.startswith(ENCRYPTED_CHANNEL_PREFIX)
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _join_info(info: Optional[Union[str, Sequence[str]]]) -> Optional[str]:
|
|
1738
|
+
if info is None or isinstance(info, str):
|
|
1739
|
+
return info
|
|
1740
|
+
return ",".join(info)
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def _clean_raw(values: Mapping[str, Any]) -> JsonDict:
|
|
1744
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def _clean_query(values: Mapping[str, Any]) -> Dict[str, str]:
|
|
1748
|
+
return {key: str(value) for key, value in values.items() if value is not None}
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _apply_common_event_fields(
|
|
1752
|
+
payload: JsonDict,
|
|
1753
|
+
socket_id: Optional[str],
|
|
1754
|
+
info: Optional[Union[str, Sequence[str]]],
|
|
1755
|
+
idempotency_key: Optional[str],
|
|
1756
|
+
extras: Optional[MessageExtras],
|
|
1757
|
+
tags: Optional[Dict[str, str]],
|
|
1758
|
+
) -> None:
|
|
1759
|
+
if socket_id is not None:
|
|
1760
|
+
payload["socket_id"] = socket_id
|
|
1761
|
+
joined_info = _join_info(info)
|
|
1762
|
+
if joined_info is not None:
|
|
1763
|
+
payload["info"] = joined_info
|
|
1764
|
+
if idempotency_key is not None:
|
|
1765
|
+
payload["idempotency_key"] = idempotency_key
|
|
1766
|
+
if extras is not None:
|
|
1767
|
+
extras_payload = extras.to_payload()
|
|
1768
|
+
if extras_payload:
|
|
1769
|
+
payload["extras"] = extras_payload
|
|
1770
|
+
if tags is not None:
|
|
1771
|
+
payload["tags"] = tags
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _mutation_payload(
|
|
1775
|
+
mutation: Union[MessageMutation, Mapping[str, Any]], marshaller: JsonMarshaller
|
|
1776
|
+
) -> JsonDict:
|
|
1777
|
+
return (
|
|
1778
|
+
mutation.to_payload(marshaller)
|
|
1779
|
+
if isinstance(mutation, MessageMutation)
|
|
1780
|
+
else dict(mutation)
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _operator_payload(
|
|
1785
|
+
channel: str, operation: str, reason: str, requested_by: Optional[str]
|
|
1786
|
+
) -> JsonDict:
|
|
1787
|
+
if not reason:
|
|
1788
|
+
raise SockudoError("reason is required for operator history controls")
|
|
1789
|
+
return _clean_raw(
|
|
1790
|
+
{
|
|
1791
|
+
"confirm_channel": channel,
|
|
1792
|
+
"confirm_operation": operation,
|
|
1793
|
+
"reason": reason,
|
|
1794
|
+
"requested_by": requested_by,
|
|
1795
|
+
}
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _make_sync_client(options: SockudoOptions) -> httpx.Client:
|
|
1800
|
+
try:
|
|
1801
|
+
return httpx.Client(
|
|
1802
|
+
timeout=options.timeout, http2=options.http2, verify=options.verify_tls
|
|
1803
|
+
)
|
|
1804
|
+
except ImportError:
|
|
1805
|
+
if not options.http2:
|
|
1806
|
+
raise
|
|
1807
|
+
return httpx.Client(
|
|
1808
|
+
timeout=options.timeout, http2=False, verify=options.verify_tls
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def _make_async_client(options: SockudoOptions) -> httpx.AsyncClient:
|
|
1813
|
+
try:
|
|
1814
|
+
return httpx.AsyncClient(
|
|
1815
|
+
timeout=options.timeout, http2=options.http2, verify=options.verify_tls
|
|
1816
|
+
)
|
|
1817
|
+
except ImportError:
|
|
1818
|
+
if not options.http2:
|
|
1819
|
+
raise
|
|
1820
|
+
return httpx.AsyncClient(
|
|
1821
|
+
timeout=options.timeout, http2=False, verify=options.verify_tls
|
|
1822
|
+
)
|