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