cue-notify-client 0.1.0__tar.gz
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.
- cue_notify_client-0.1.0/.gitignore +38 -0
- cue_notify_client-0.1.0/PKG-INFO +66 -0
- cue_notify_client-0.1.0/README.md +50 -0
- cue_notify_client-0.1.0/pyproject.toml +51 -0
- cue_notify_client-0.1.0/src/cue_client/__init__.py +16 -0
- cue_notify_client-0.1.0/src/cue_client/client.py +333 -0
- cue_notify_client-0.1.0/src/cue_client/py.typed +0 -0
- cue_notify_client-0.1.0/src/cue_client/webhooks.py +48 -0
- cue_notify_client-0.1.0/tests/test_client.py +151 -0
- cue_notify_client-0.1.0/tests/test_webhooks.py +44 -0
- cue_notify_client-0.1.0/uv.lock +2019 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# Tooling caches
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.pyright/
|
|
16
|
+
.coverage
|
|
17
|
+
.coverage.*
|
|
18
|
+
coverage.xml
|
|
19
|
+
htmlcov/
|
|
20
|
+
|
|
21
|
+
# Docs build
|
|
22
|
+
site/
|
|
23
|
+
|
|
24
|
+
# Local configuration and data
|
|
25
|
+
.env
|
|
26
|
+
.env.*
|
|
27
|
+
!.env.example
|
|
28
|
+
/cue.toml
|
|
29
|
+
*.db
|
|
30
|
+
*.db-journal
|
|
31
|
+
*.sqlite3
|
|
32
|
+
secrets/
|
|
33
|
+
|
|
34
|
+
# Editors / OS
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
*.swp
|
|
38
|
+
.DS_Store
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cue-notify-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Cue: send events and messages, and verify Cue's signed webhooks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/murtazox04/Cue/tree/main/sdks/python
|
|
6
|
+
Project-URL: Documentation, https://murtazox04.github.io/Cue
|
|
7
|
+
Author: Murtazo Xurramov
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: cue,notifications,sdk,webhooks
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# cue-notify-client
|
|
18
|
+
|
|
19
|
+
Python client for [Cue](https://github.com/murtazox04/Cue), the attention layer between
|
|
20
|
+
software, AI agents and people. Its only dependency is `httpx`.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install cue-notify-client
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The package is imported as `cue_client`.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from cue_client import Cue
|
|
30
|
+
|
|
31
|
+
with Cue(api_key="ck_…", base_url="https://cue.example.com") as cue:
|
|
32
|
+
cue.upsert_recipient(
|
|
33
|
+
"user-42",
|
|
34
|
+
timezone="Europe/Berlin",
|
|
35
|
+
addresses=[{"channel": "email", "value": "ann@example.com"}],
|
|
36
|
+
)
|
|
37
|
+
cue.send_event(
|
|
38
|
+
"order.shipped", "user-42", {"order_id": "1001"}, idempotency_key="order-1001-shipped"
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`AsyncCue` has the same methods for `asyncio`. Errors raise `CueError` with the problem
|
|
43
|
+
details Cue returns (`status`, `title`, `detail`, `errors`). Rate limits and gateway
|
|
44
|
+
errors are retried automatically for requests that are safe to repeat: reads, profile
|
|
45
|
+
updates, and sends with an idempotency key.
|
|
46
|
+
|
|
47
|
+
## Verifying webhooks
|
|
48
|
+
|
|
49
|
+
If Cue delivers to your service through its webhook connector, check every request
|
|
50
|
+
before trusting it:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from cue_client import InvalidSignatureError, verify_webhook
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def handle(request):
|
|
57
|
+
try:
|
|
58
|
+
verify_webhook(request.body, request.headers.get("Cue-Signature"), secret=WEBHOOK_SECRET)
|
|
59
|
+
except InvalidSignatureError:
|
|
60
|
+
return 401
|
|
61
|
+
message = json.loads(request.body)
|
|
62
|
+
...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Verify the raw body bytes, before parsing them. Requests signed more than five minutes
|
|
66
|
+
ago are rejected (`tolerance=` changes this).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# cue-notify-client
|
|
2
|
+
|
|
3
|
+
Python client for [Cue](https://github.com/murtazox04/Cue), the attention layer between
|
|
4
|
+
software, AI agents and people. Its only dependency is `httpx`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install cue-notify-client
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The package is imported as `cue_client`.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from cue_client import Cue
|
|
14
|
+
|
|
15
|
+
with Cue(api_key="ck_…", base_url="https://cue.example.com") as cue:
|
|
16
|
+
cue.upsert_recipient(
|
|
17
|
+
"user-42",
|
|
18
|
+
timezone="Europe/Berlin",
|
|
19
|
+
addresses=[{"channel": "email", "value": "ann@example.com"}],
|
|
20
|
+
)
|
|
21
|
+
cue.send_event(
|
|
22
|
+
"order.shipped", "user-42", {"order_id": "1001"}, idempotency_key="order-1001-shipped"
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`AsyncCue` has the same methods for `asyncio`. Errors raise `CueError` with the problem
|
|
27
|
+
details Cue returns (`status`, `title`, `detail`, `errors`). Rate limits and gateway
|
|
28
|
+
errors are retried automatically for requests that are safe to repeat: reads, profile
|
|
29
|
+
updates, and sends with an idempotency key.
|
|
30
|
+
|
|
31
|
+
## Verifying webhooks
|
|
32
|
+
|
|
33
|
+
If Cue delivers to your service through its webhook connector, check every request
|
|
34
|
+
before trusting it:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from cue_client import InvalidSignatureError, verify_webhook
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle(request):
|
|
41
|
+
try:
|
|
42
|
+
verify_webhook(request.body, request.headers.get("Cue-Signature"), secret=WEBHOOK_SECRET)
|
|
43
|
+
except InvalidSignatureError:
|
|
44
|
+
return 401
|
|
45
|
+
message = json.loads(request.body)
|
|
46
|
+
...
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Verify the raw body bytes, before parsing them. Requests signed more than five minutes
|
|
50
|
+
ago are rejected (`tolerance=` changes this).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cue-notify-client"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python client for Cue: send events and messages, and verify Cue's signed webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Murtazo Xurramov" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["httpx>=0.27"]
|
|
14
|
+
keywords = ["notifications", "cue", "webhooks", "sdk"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/murtazox04/Cue/tree/main/sdks/python"
|
|
23
|
+
Documentation = "https://murtazox04.github.io/Cue"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.version]
|
|
26
|
+
path = "src/cue_client/__init__.py"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/cue_client"]
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.4",
|
|
34
|
+
"pytest-asyncio>=1.0",
|
|
35
|
+
"pyright>=1.1.400",
|
|
36
|
+
# The tests run the client against the real Cue application.
|
|
37
|
+
"cue-notify; python_version >= '3.12'",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.uv.sources]
|
|
41
|
+
cue-notify = { path = "../..", editable = true }
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[tool.pyright]
|
|
49
|
+
include = ["src"]
|
|
50
|
+
pythonVersion = "3.10"
|
|
51
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Python client for Cue: send events and messages, and verify Cue's signed webhooks."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from cue_client.client import AsyncCue, Cue, CueError, Importance
|
|
6
|
+
from cue_client.webhooks import SIGNATURE_HEADER, InvalidSignatureError, verify_webhook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SIGNATURE_HEADER",
|
|
10
|
+
"AsyncCue",
|
|
11
|
+
"Cue",
|
|
12
|
+
"CueError",
|
|
13
|
+
"Importance",
|
|
14
|
+
"InvalidSignatureError",
|
|
15
|
+
"verify_webhook",
|
|
16
|
+
]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Synchronous and asynchronous clients for Cue's HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from cue_client import __version__
|
|
15
|
+
|
|
16
|
+
Importance = Literal["low", "normal", "high", "critical"]
|
|
17
|
+
JSON = dict[str, Any]
|
|
18
|
+
|
|
19
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CueError(Exception):
|
|
23
|
+
"""An error response from Cue (an RFC 9457 problem document)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, title: str, detail: str, errors: list[Any] | None) -> None:
|
|
26
|
+
super().__init__(f"{status} {title}: {detail}")
|
|
27
|
+
self.status = status
|
|
28
|
+
self.title = title
|
|
29
|
+
self.detail = detail
|
|
30
|
+
self.errors = errors or []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _raise_for_problem(response: httpx.Response) -> None:
|
|
34
|
+
if response.is_success:
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
problem: Mapping[str, Any] = response.json()
|
|
38
|
+
except ValueError:
|
|
39
|
+
problem = {}
|
|
40
|
+
raise CueError(
|
|
41
|
+
response.status_code,
|
|
42
|
+
str(problem.get("title", response.reason_phrase)),
|
|
43
|
+
str(problem.get("detail", response.text[:300])),
|
|
44
|
+
problem.get("errors"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _drop_none(values: Mapping[str, Any]) -> JSON:
|
|
49
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _iso(value: datetime | None) -> str | None:
|
|
53
|
+
return value.isoformat() if value is not None else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _event(
|
|
57
|
+
name: str,
|
|
58
|
+
recipient: str | Mapping[str, Any],
|
|
59
|
+
data: Mapping[str, Any] | None,
|
|
60
|
+
idempotency_key: str | None,
|
|
61
|
+
occurred_at: datetime | None,
|
|
62
|
+
) -> JSON:
|
|
63
|
+
return _drop_none(
|
|
64
|
+
{
|
|
65
|
+
"name": name,
|
|
66
|
+
"recipient": recipient,
|
|
67
|
+
"data": dict(data or {}),
|
|
68
|
+
"idempotency_key": idempotency_key,
|
|
69
|
+
"occurred_at": _iso(occurred_at),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _message(
|
|
75
|
+
recipient: str,
|
|
76
|
+
template: str,
|
|
77
|
+
channels: list[str],
|
|
78
|
+
data: Mapping[str, Any] | None,
|
|
79
|
+
category: str,
|
|
80
|
+
importance: Importance,
|
|
81
|
+
expires_at: datetime | None,
|
|
82
|
+
send_at: datetime | None,
|
|
83
|
+
idempotency_key: str | None,
|
|
84
|
+
) -> JSON:
|
|
85
|
+
return _drop_none(
|
|
86
|
+
{
|
|
87
|
+
"recipient": recipient,
|
|
88
|
+
"template": template,
|
|
89
|
+
"channels": channels,
|
|
90
|
+
"data": dict(data or {}),
|
|
91
|
+
"category": category,
|
|
92
|
+
"importance": importance,
|
|
93
|
+
"expires_at": _iso(expires_at),
|
|
94
|
+
"send_at": _iso(send_at),
|
|
95
|
+
"idempotency_key": idempotency_key,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _profile(
|
|
101
|
+
locale: str | None,
|
|
102
|
+
timezone: str | None,
|
|
103
|
+
attributes: Mapping[str, Any] | None,
|
|
104
|
+
addresses: list[Mapping[str, str]] | None,
|
|
105
|
+
) -> JSON:
|
|
106
|
+
return _drop_none(
|
|
107
|
+
{
|
|
108
|
+
"locale": locale,
|
|
109
|
+
"timezone": timezone,
|
|
110
|
+
"attributes": dict(attributes) if attributes is not None else None,
|
|
111
|
+
"addresses": [dict(a) for a in addresses] if addresses is not None else None,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _headers(api_key: str) -> dict[str, str]:
|
|
117
|
+
return {
|
|
118
|
+
"Authorization": f"Bearer {api_key}",
|
|
119
|
+
"User-Agent": f"cue-notify-client-python/{__version__}",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Cue:
|
|
124
|
+
"""Client for Cue's API.
|
|
125
|
+
|
|
126
|
+
``retries`` applies to rate limiting and gateway errors on requests that are safe to
|
|
127
|
+
repeat: reads, profile updates, and sends that carry an idempotency key.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
api_key: str,
|
|
133
|
+
base_url: str = "http://127.0.0.1:8000",
|
|
134
|
+
*,
|
|
135
|
+
timeout: float = 10.0,
|
|
136
|
+
retries: int = 2,
|
|
137
|
+
transport: httpx.BaseTransport | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._http = httpx.Client(
|
|
140
|
+
base_url=base_url, headers=_headers(api_key), timeout=timeout, transport=transport
|
|
141
|
+
)
|
|
142
|
+
self._retries = retries
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> Cue:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *_: object) -> None:
|
|
148
|
+
self.close()
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
self._http.close()
|
|
152
|
+
|
|
153
|
+
def _request(self, method: str, path: str, *, safe: bool, **kwargs: Any) -> Any:
|
|
154
|
+
attempts = self._retries + 1 if safe else 1
|
|
155
|
+
response = self._http.request(method, path, **kwargs)
|
|
156
|
+
for attempt in range(1, attempts):
|
|
157
|
+
if response.status_code not in RETRY_STATUSES:
|
|
158
|
+
break
|
|
159
|
+
time.sleep(_backoff(response, attempt))
|
|
160
|
+
response = self._http.request(method, path, **kwargs)
|
|
161
|
+
_raise_for_problem(response)
|
|
162
|
+
return response.json() if response.content else None
|
|
163
|
+
|
|
164
|
+
def send_event(
|
|
165
|
+
self,
|
|
166
|
+
name: str,
|
|
167
|
+
recipient: str | Mapping[str, Any],
|
|
168
|
+
data: Mapping[str, Any] | None = None,
|
|
169
|
+
*,
|
|
170
|
+
idempotency_key: str | None = None,
|
|
171
|
+
occurred_at: datetime | None = None,
|
|
172
|
+
) -> JSON:
|
|
173
|
+
"""Report that something happened; Cue's rules decide what to send."""
|
|
174
|
+
body = _event(name, recipient, data, idempotency_key, occurred_at)
|
|
175
|
+
return self._request("POST", "/v1/events", json=body, safe=idempotency_key is not None)
|
|
176
|
+
|
|
177
|
+
def send_message(
|
|
178
|
+
self,
|
|
179
|
+
recipient: str,
|
|
180
|
+
template: str,
|
|
181
|
+
channels: list[str],
|
|
182
|
+
data: Mapping[str, Any] | None = None,
|
|
183
|
+
*,
|
|
184
|
+
category: str = "transactional",
|
|
185
|
+
importance: Importance = "normal",
|
|
186
|
+
expires_at: datetime | None = None,
|
|
187
|
+
send_at: datetime | None = None,
|
|
188
|
+
idempotency_key: str | None = None,
|
|
189
|
+
) -> JSON:
|
|
190
|
+
"""Send a template directly. Check ``status``: it may be ``suppressed``."""
|
|
191
|
+
body = _message(
|
|
192
|
+
recipient,
|
|
193
|
+
template,
|
|
194
|
+
channels,
|
|
195
|
+
data,
|
|
196
|
+
category,
|
|
197
|
+
importance,
|
|
198
|
+
expires_at,
|
|
199
|
+
send_at,
|
|
200
|
+
idempotency_key,
|
|
201
|
+
)
|
|
202
|
+
return self._request("POST", "/v1/messages", json=body, safe=idempotency_key is not None)
|
|
203
|
+
|
|
204
|
+
def upsert_recipient(
|
|
205
|
+
self,
|
|
206
|
+
external_id: str,
|
|
207
|
+
*,
|
|
208
|
+
locale: str | None = None,
|
|
209
|
+
timezone: str | None = None,
|
|
210
|
+
attributes: Mapping[str, Any] | None = None,
|
|
211
|
+
addresses: list[Mapping[str, str]] | None = None,
|
|
212
|
+
) -> JSON:
|
|
213
|
+
body = _profile(locale, timezone, attributes, addresses)
|
|
214
|
+
return self._request("PUT", f"/v1/recipients/{_segment(external_id)}", json=body, safe=True)
|
|
215
|
+
|
|
216
|
+
def get_message(self, message_id: str) -> JSON:
|
|
217
|
+
return self._request("GET", f"/v1/messages/{_segment(message_id)}", safe=True)
|
|
218
|
+
|
|
219
|
+
def list_messages(self, **filters: str | int) -> JSON:
|
|
220
|
+
"""Filters: ``recipient``, ``status``, ``event_id``, ``agent``, ``limit``, ``cursor``."""
|
|
221
|
+
return self._request("GET", "/v1/messages", params=filters, safe=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AsyncCue:
|
|
225
|
+
"""The asynchronous twin of :class:`Cue`."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
api_key: str,
|
|
230
|
+
base_url: str = "http://127.0.0.1:8000",
|
|
231
|
+
*,
|
|
232
|
+
timeout: float = 10.0,
|
|
233
|
+
retries: int = 2,
|
|
234
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
235
|
+
) -> None:
|
|
236
|
+
self._http = httpx.AsyncClient(
|
|
237
|
+
base_url=base_url, headers=_headers(api_key), timeout=timeout, transport=transport
|
|
238
|
+
)
|
|
239
|
+
self._retries = retries
|
|
240
|
+
|
|
241
|
+
async def __aenter__(self) -> AsyncCue:
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
async def __aexit__(self, *_: object) -> None:
|
|
245
|
+
await self.aclose()
|
|
246
|
+
|
|
247
|
+
async def aclose(self) -> None:
|
|
248
|
+
await self._http.aclose()
|
|
249
|
+
|
|
250
|
+
async def _request(self, method: str, path: str, *, safe: bool, **kwargs: Any) -> Any:
|
|
251
|
+
attempts = self._retries + 1 if safe else 1
|
|
252
|
+
response = await self._http.request(method, path, **kwargs)
|
|
253
|
+
for attempt in range(1, attempts):
|
|
254
|
+
if response.status_code not in RETRY_STATUSES:
|
|
255
|
+
break
|
|
256
|
+
await asyncio.sleep(_backoff(response, attempt))
|
|
257
|
+
response = await self._http.request(method, path, **kwargs)
|
|
258
|
+
_raise_for_problem(response)
|
|
259
|
+
return response.json() if response.content else None
|
|
260
|
+
|
|
261
|
+
async def send_event(
|
|
262
|
+
self,
|
|
263
|
+
name: str,
|
|
264
|
+
recipient: str | Mapping[str, Any],
|
|
265
|
+
data: Mapping[str, Any] | None = None,
|
|
266
|
+
*,
|
|
267
|
+
idempotency_key: str | None = None,
|
|
268
|
+
occurred_at: datetime | None = None,
|
|
269
|
+
) -> JSON:
|
|
270
|
+
body = _event(name, recipient, data, idempotency_key, occurred_at)
|
|
271
|
+
return await self._request(
|
|
272
|
+
"POST", "/v1/events", json=body, safe=idempotency_key is not None
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def send_message(
|
|
276
|
+
self,
|
|
277
|
+
recipient: str,
|
|
278
|
+
template: str,
|
|
279
|
+
channels: list[str],
|
|
280
|
+
data: Mapping[str, Any] | None = None,
|
|
281
|
+
*,
|
|
282
|
+
category: str = "transactional",
|
|
283
|
+
importance: Importance = "normal",
|
|
284
|
+
expires_at: datetime | None = None,
|
|
285
|
+
send_at: datetime | None = None,
|
|
286
|
+
idempotency_key: str | None = None,
|
|
287
|
+
) -> JSON:
|
|
288
|
+
body = _message(
|
|
289
|
+
recipient,
|
|
290
|
+
template,
|
|
291
|
+
channels,
|
|
292
|
+
data,
|
|
293
|
+
category,
|
|
294
|
+
importance,
|
|
295
|
+
expires_at,
|
|
296
|
+
send_at,
|
|
297
|
+
idempotency_key,
|
|
298
|
+
)
|
|
299
|
+
return await self._request(
|
|
300
|
+
"POST", "/v1/messages", json=body, safe=idempotency_key is not None
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def upsert_recipient(
|
|
304
|
+
self,
|
|
305
|
+
external_id: str,
|
|
306
|
+
*,
|
|
307
|
+
locale: str | None = None,
|
|
308
|
+
timezone: str | None = None,
|
|
309
|
+
attributes: Mapping[str, Any] | None = None,
|
|
310
|
+
addresses: list[Mapping[str, str]] | None = None,
|
|
311
|
+
) -> JSON:
|
|
312
|
+
body = _profile(locale, timezone, attributes, addresses)
|
|
313
|
+
return await self._request(
|
|
314
|
+
"PUT", f"/v1/recipients/{_segment(external_id)}", json=body, safe=True
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def get_message(self, message_id: str) -> JSON:
|
|
318
|
+
return await self._request("GET", f"/v1/messages/{_segment(message_id)}", safe=True)
|
|
319
|
+
|
|
320
|
+
async def list_messages(self, **filters: str | int) -> JSON:
|
|
321
|
+
return await self._request("GET", "/v1/messages", params=filters, safe=True)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _segment(value: str) -> str:
|
|
325
|
+
"""A path segment that cannot step into another endpoint."""
|
|
326
|
+
return quote(value, safe="")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _backoff(response: httpx.Response, attempt: int) -> float:
|
|
330
|
+
retry_after = response.headers.get("Retry-After")
|
|
331
|
+
if retry_after and retry_after.isdigit():
|
|
332
|
+
return min(float(retry_after), 30.0)
|
|
333
|
+
return min(0.5 * 2 ** (attempt - 1), 8.0)
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Verify the signature on requests Cue's webhook connector sends to your service.
|
|
2
|
+
|
|
3
|
+
Cue signs the raw body: ``Cue-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of
|
|
4
|
+
"<t>.<raw body>">``. Verify against the bytes you received, before parsing them, and
|
|
5
|
+
reject old timestamps so a captured request cannot be replayed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
SIGNATURE_HEADER = "Cue-Signature"
|
|
15
|
+
DEFAULT_TOLERANCE = 300
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidSignatureError(Exception):
|
|
19
|
+
"""The request was not signed by Cue with this secret, or is too old."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_webhook(
|
|
23
|
+
body: bytes,
|
|
24
|
+
signature: str | None,
|
|
25
|
+
secret: str,
|
|
26
|
+
*,
|
|
27
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
28
|
+
now: float | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Raise :class:`InvalidSignatureError` unless ``signature`` (the ``Cue-Signature``
|
|
31
|
+
header) is a valid, fresh signature of ``body``.
|
|
32
|
+
|
|
33
|
+
``tolerance`` is the accepted clock difference in seconds.
|
|
34
|
+
"""
|
|
35
|
+
if not signature:
|
|
36
|
+
raise InvalidSignatureError("missing Cue-Signature header")
|
|
37
|
+
parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part)
|
|
38
|
+
try:
|
|
39
|
+
timestamp = int(parts["t"])
|
|
40
|
+
candidate = parts["v1"]
|
|
41
|
+
except (KeyError, ValueError) as exc:
|
|
42
|
+
raise InvalidSignatureError("malformed Cue-Signature header") from exc
|
|
43
|
+
current = time.time() if now is None else now
|
|
44
|
+
if abs(current - timestamp) > tolerance:
|
|
45
|
+
raise InvalidSignatureError("signature timestamp is outside the tolerance")
|
|
46
|
+
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256)
|
|
47
|
+
if not hmac.compare_digest(expected.hexdigest(), candidate):
|
|
48
|
+
raise InvalidSignatureError("signature does not match")
|