goomzo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goomzo/__init__.py +26 -0
- goomzo/_client.py +119 -0
- goomzo/_errors.py +49 -0
- goomzo/_pagination.py +77 -0
- goomzo/_transport.py +229 -0
- goomzo/_version.py +3 -0
- goomzo/_webhooks.py +80 -0
- goomzo/py.typed +0 -0
- goomzo/resources/__init__.py +0 -0
- goomzo/resources/broadcasts.py +95 -0
- goomzo/resources/customers.py +283 -0
- goomzo/resources/messages.py +143 -0
- goomzo/resources/segments.py +117 -0
- goomzo/resources/templates.py +123 -0
- goomzo/resources/webhook_endpoints.py +72 -0
- goomzo/types.py +407 -0
- goomzo-0.1.0.dist-info/METADATA +77 -0
- goomzo-0.1.0.dist-info/RECORD +20 -0
- goomzo-0.1.0.dist-info/WHEEL +4 -0
- goomzo-0.1.0.dist-info/licenses/LICENSE +21 -0
goomzo/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The Goomzo API for Python: messages, templates, customers and webhooks."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncGoomzo, Goomzo
|
|
4
|
+
from ._errors import FieldProblem, GoomzoConnectionError, GoomzoError, WebhookVerificationError
|
|
5
|
+
from ._pagination import AsyncPage, AsyncPager, Page
|
|
6
|
+
from ._transport import API_VERSION, Media
|
|
7
|
+
from ._version import VERSION
|
|
8
|
+
from ._webhooks import verify_webhook
|
|
9
|
+
|
|
10
|
+
__version__ = VERSION
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'API_VERSION',
|
|
14
|
+
'AsyncGoomzo',
|
|
15
|
+
'AsyncPage',
|
|
16
|
+
'AsyncPager',
|
|
17
|
+
'FieldProblem',
|
|
18
|
+
'Goomzo',
|
|
19
|
+
'GoomzoConnectionError',
|
|
20
|
+
'GoomzoError',
|
|
21
|
+
'Media',
|
|
22
|
+
'Page',
|
|
23
|
+
'WebhookVerificationError',
|
|
24
|
+
'__version__',
|
|
25
|
+
'verify_webhook',
|
|
26
|
+
]
|
goomzo/_client.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from ._transport import AsyncTransport, SyncTransport, make_config
|
|
9
|
+
from .resources.broadcasts import AsyncBroadcasts, Broadcasts
|
|
10
|
+
from .resources.customers import ActivityRecorder, AsyncActivityRecorder, AsyncCustomers, Customers
|
|
11
|
+
from .resources.messages import AsyncMessages, Messages
|
|
12
|
+
from .resources.segments import AsyncSegments, Segments
|
|
13
|
+
from .resources.templates import AsyncTemplates, AsyncWhatsApp, Templates, WhatsApp
|
|
14
|
+
from .resources.webhook_endpoints import AsyncWebhookEndpoints, WebhookEndpoints
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Goomzo:
|
|
18
|
+
"""
|
|
19
|
+
The Goomzo API client. Make one and share it across your app.
|
|
20
|
+
|
|
21
|
+
from goomzo import Goomzo
|
|
22
|
+
|
|
23
|
+
goomzo = Goomzo() # reads GOOMZO_API_KEY
|
|
24
|
+
goomzo.messages.send_one({'to': '+255712345678', 'channel': 'sms', 'content': {'text': 'Your order is ready.'}})
|
|
25
|
+
|
|
26
|
+
`api_key` is your API key's secret, `gzk_…`: keep it on your server. A failed request is tried again
|
|
27
|
+
`max_retries` times after a network error, a 429 or a 5xx, and each try waits up to `timeout` seconds.
|
|
28
|
+
|
|
29
|
+
Docs: https://docs.goomzo.africa/docs/developers/python-sdk
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
api_key: str | None = None,
|
|
36
|
+
base_url: str | None = None,
|
|
37
|
+
max_retries: int = 2,
|
|
38
|
+
timeout: float = 60.0,
|
|
39
|
+
http_client: httpx.Client | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._transport = SyncTransport(make_config(api_key, base_url, max_retries, timeout), http_client)
|
|
42
|
+
self.messages = Messages(self._transport)
|
|
43
|
+
"""Send SMS and WhatsApp messages, each to one person."""
|
|
44
|
+
self.broadcasts = Broadcasts(self._transport)
|
|
45
|
+
"""Send one message to many people."""
|
|
46
|
+
self.templates = Templates(self._transport)
|
|
47
|
+
"""Saved templates, sent by name."""
|
|
48
|
+
self.whatsapp = WhatsApp(self._transport)
|
|
49
|
+
"""The business's WhatsApp account and its templates."""
|
|
50
|
+
self.customers = Customers(self._transport)
|
|
51
|
+
"""Add and update customers."""
|
|
52
|
+
self.activity = ActivityRecorder(self._transport)
|
|
53
|
+
"""Record customer activity, by phone, email or id."""
|
|
54
|
+
self.segments = Segments(self._transport)
|
|
55
|
+
"""Groups of customers."""
|
|
56
|
+
self.webhook_endpoints = WebhookEndpoints(self._transport)
|
|
57
|
+
"""URLs that hear webhook events."""
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
"""Closes the connections it opened. Not needed for an http_client you gave it."""
|
|
61
|
+
self._transport.close()
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> Self:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None) -> None:
|
|
67
|
+
self.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AsyncGoomzo:
|
|
71
|
+
"""
|
|
72
|
+
The Goomzo API client, for asyncio. The same as `Goomzo`, with every call awaited.
|
|
73
|
+
|
|
74
|
+
from goomzo import AsyncGoomzo
|
|
75
|
+
|
|
76
|
+
goomzo = AsyncGoomzo()
|
|
77
|
+
await goomzo.messages.send_one({'to': '+255712345678', 'template': 'auth_1', 'variables': {'code': '482913'}})
|
|
78
|
+
|
|
79
|
+
Docs: https://docs.goomzo.africa/docs/developers/python-sdk
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
api_key: str | None = None,
|
|
86
|
+
base_url: str | None = None,
|
|
87
|
+
max_retries: int = 2,
|
|
88
|
+
timeout: float = 60.0,
|
|
89
|
+
http_client: httpx.AsyncClient | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._transport = AsyncTransport(make_config(api_key, base_url, max_retries, timeout), http_client)
|
|
92
|
+
self.messages = AsyncMessages(self._transport)
|
|
93
|
+
"""Send SMS and WhatsApp messages, each to one person."""
|
|
94
|
+
self.broadcasts = AsyncBroadcasts(self._transport)
|
|
95
|
+
"""Send one message to many people."""
|
|
96
|
+
self.templates = AsyncTemplates(self._transport)
|
|
97
|
+
"""Saved templates, sent by name."""
|
|
98
|
+
self.whatsapp = AsyncWhatsApp(self._transport)
|
|
99
|
+
"""The business's WhatsApp account and its templates."""
|
|
100
|
+
self.customers = AsyncCustomers(self._transport)
|
|
101
|
+
"""Add and update customers."""
|
|
102
|
+
self.activity = AsyncActivityRecorder(self._transport)
|
|
103
|
+
"""Record customer activity, by phone, email or id."""
|
|
104
|
+
self.segments = AsyncSegments(self._transport)
|
|
105
|
+
"""Groups of customers."""
|
|
106
|
+
self.webhook_endpoints = AsyncWebhookEndpoints(self._transport)
|
|
107
|
+
"""URLs that hear webhook events."""
|
|
108
|
+
|
|
109
|
+
async def close(self) -> None:
|
|
110
|
+
"""Closes the connections it opened. Not needed for an http_client you gave it."""
|
|
111
|
+
await self._transport.close()
|
|
112
|
+
|
|
113
|
+
async def __aenter__(self) -> Self:
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
async def __aexit__(
|
|
117
|
+
self, kind: type[BaseException] | None, error: BaseException | None, tb: TracebackType | None
|
|
118
|
+
) -> None:
|
|
119
|
+
await self.close()
|
goomzo/_errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing_extensions import TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FieldProblem(TypedDict):
|
|
7
|
+
"""A problem with one field of a request."""
|
|
8
|
+
|
|
9
|
+
code: str
|
|
10
|
+
message: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoomzoError(Exception):
|
|
14
|
+
"""
|
|
15
|
+
The API answered with an error.
|
|
16
|
+
|
|
17
|
+
`code` says what went wrong, such as `invalid`, `not_found` or `insufficient_sms`. `fields` names the fields that
|
|
18
|
+
were wrong, such as `customers[1].phone`. Include `request_id` when you contact Goomzo.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
status: int,
|
|
24
|
+
code: str,
|
|
25
|
+
message: str,
|
|
26
|
+
fields: dict[str, FieldProblem] | None = None,
|
|
27
|
+
request_id: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status = status
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message
|
|
33
|
+
self.fields: dict[str, FieldProblem] = fields or {}
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f'GoomzoError(status={self.status}, code={self.code!r}, message={self.message!r})'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GoomzoConnectionError(Exception):
|
|
41
|
+
"""No answer came: the network failed, or the request took longer than the timeout."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, timed_out: bool) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.timed_out = timed_out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WebhookVerificationError(Exception):
|
|
49
|
+
"""A webhook's signature or timestamp didn't check out. Don't trust its body."""
|
goomzo/_pagination.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar('T')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Page(Generic[T]):
|
|
10
|
+
"""
|
|
11
|
+
One page of a list. Loop over it for every item: the next pages are asked for as you go.
|
|
12
|
+
|
|
13
|
+
page = goomzo.customers.list(limit=200)
|
|
14
|
+
page.items, page.next_cursor, page.total
|
|
15
|
+
|
|
16
|
+
for customer in goomzo.customers.list():
|
|
17
|
+
print(customer['name'])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, data: Any, fetch: Callable[[str], Page[T]]) -> None:
|
|
21
|
+
self.items: list[T] = data['items']
|
|
22
|
+
self.next_cursor: str | None = data.get('nextCursor')
|
|
23
|
+
self.total: int | None = data.get('total')
|
|
24
|
+
"""How many match, on every page. Only customers, messages and broadcasts have it."""
|
|
25
|
+
self._fetch = fetch
|
|
26
|
+
|
|
27
|
+
def next_page(self) -> Page[T] | None:
|
|
28
|
+
"""The next page, or None on the last."""
|
|
29
|
+
return self._fetch(self.next_cursor) if self.next_cursor else None
|
|
30
|
+
|
|
31
|
+
def __iter__(self) -> Iterator[T]:
|
|
32
|
+
page: Page[T] | None = self
|
|
33
|
+
while page is not None:
|
|
34
|
+
yield from page.items
|
|
35
|
+
page = page.next_page()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncPage(Generic[T]):
|
|
39
|
+
"""One page of a list, for asyncio. `async for` over it for every item."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, data: Any, fetch: Callable[[str], Awaitable[AsyncPage[T]]]) -> None:
|
|
42
|
+
self.items: list[T] = data['items']
|
|
43
|
+
self.next_cursor: str | None = data.get('nextCursor')
|
|
44
|
+
self.total: int | None = data.get('total')
|
|
45
|
+
self._fetch = fetch
|
|
46
|
+
|
|
47
|
+
async def next_page(self) -> AsyncPage[T] | None:
|
|
48
|
+
"""The next page, or None on the last."""
|
|
49
|
+
return await self._fetch(self.next_cursor) if self.next_cursor else None
|
|
50
|
+
|
|
51
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
52
|
+
page: AsyncPage[T] | None = self
|
|
53
|
+
while page is not None:
|
|
54
|
+
for item in page.items:
|
|
55
|
+
yield item
|
|
56
|
+
page = await page.next_page()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncPager(Generic[T]):
|
|
60
|
+
"""
|
|
61
|
+
A list for asyncio. Await it for the first page, or `async for` over it for every item.
|
|
62
|
+
|
|
63
|
+
page = await goomzo.customers.list(limit=200)
|
|
64
|
+
|
|
65
|
+
async for customer in goomzo.customers.list():
|
|
66
|
+
print(customer['name'])
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, first: Callable[[], Awaitable[AsyncPage[T]]]) -> None:
|
|
70
|
+
self._first = first
|
|
71
|
+
|
|
72
|
+
def __await__(self) -> Generator[Any, None, AsyncPage[T]]:
|
|
73
|
+
return self._first().__await__()
|
|
74
|
+
|
|
75
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
76
|
+
async for item in await self._first():
|
|
77
|
+
yield item
|
goomzo/_transport.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._errors import FieldProblem, GoomzoConnectionError, GoomzoError
|
|
16
|
+
from ._version import VERSION
|
|
17
|
+
|
|
18
|
+
API_VERSION = '2026-11-08'
|
|
19
|
+
"""The API version this release is written for. Every request is sent with it, as `Goomzo-Version`."""
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = 'https://api.goomzo.africa'
|
|
22
|
+
|
|
23
|
+
Method = Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
|
|
24
|
+
Query = Mapping[str, Any]
|
|
25
|
+
|
|
26
|
+
_RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Media:
|
|
31
|
+
"""A file a customer sent on WhatsApp."""
|
|
32
|
+
|
|
33
|
+
content: bytes
|
|
34
|
+
content_type: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class _Config:
|
|
39
|
+
api_key: str
|
|
40
|
+
base_url: str
|
|
41
|
+
max_retries: int
|
|
42
|
+
timeout: float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_config(api_key: str | None, base_url: str | None, max_retries: int, timeout: float) -> _Config:
|
|
46
|
+
key = api_key or os.environ.get('GOOMZO_API_KEY')
|
|
47
|
+
if not key:
|
|
48
|
+
raise ValueError('Give an api_key, or set GOOMZO_API_KEY.')
|
|
49
|
+
return _Config(key, (base_url or DEFAULT_BASE_URL).rstrip('/'), max_retries, timeout)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def path(template: str, **params: str) -> str:
|
|
53
|
+
"""Fills a path's `{name}` parts, each encoded: `path('/v1/customers/{id}', id=...)`."""
|
|
54
|
+
for name, value in params.items():
|
|
55
|
+
if not value:
|
|
56
|
+
raise ValueError(f'Missing {name} for {template}')
|
|
57
|
+
template = template.replace('{' + name + '}', quote(value, safe=''))
|
|
58
|
+
return template
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _headers(config: _Config, method: Method, idempotency_key: str | None, file: bool) -> dict[str, str]:
|
|
62
|
+
headers = {
|
|
63
|
+
'Authorization': f'Bearer {config.api_key}',
|
|
64
|
+
'Accept': '*/*' if file else 'application/json',
|
|
65
|
+
'Goomzo-Version': API_VERSION,
|
|
66
|
+
'User-Agent': f'goomzo-python/{VERSION}',
|
|
67
|
+
}
|
|
68
|
+
if method == 'POST':
|
|
69
|
+
headers['Idempotency-Key'] = idempotency_key or str(uuid.uuid4())
|
|
70
|
+
return headers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _params(query: Query | None) -> dict[str, str]:
|
|
74
|
+
out: dict[str, str] = {}
|
|
75
|
+
for name, value in (query or {}).items():
|
|
76
|
+
if value is None:
|
|
77
|
+
continue
|
|
78
|
+
out[name] = ('true' if value else 'false') if isinstance(value, bool) else str(value)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _backoff(attempt: int) -> float:
|
|
83
|
+
"""0.5 s, 1 s, 2 s… up to 8 s, with jitter so many clients don't retry together."""
|
|
84
|
+
return min(0.5 * 2**attempt, 8.0) * (0.75 + random.random() * 0.5)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
88
|
+
try:
|
|
89
|
+
seconds = float(response.headers.get('retry-after', ''))
|
|
90
|
+
except ValueError:
|
|
91
|
+
return None
|
|
92
|
+
return seconds if 0 < seconds <= 60 else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _to_error(response: httpx.Response) -> GoomzoError:
|
|
96
|
+
try:
|
|
97
|
+
raw: Any = response.json()
|
|
98
|
+
except ValueError:
|
|
99
|
+
raw = None
|
|
100
|
+
body: dict[str, Any] = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
|
|
101
|
+
fields: dict[str, FieldProblem] = body.get('fields') or {}
|
|
102
|
+
return GoomzoError(
|
|
103
|
+
response.status_code,
|
|
104
|
+
str(body.get('code') or f'http_{response.status_code}'),
|
|
105
|
+
str(body.get('message') or f'Goomzo answered {response.status_code} {response.reason_phrase}.'),
|
|
106
|
+
fields,
|
|
107
|
+
response.headers.get('x-request-id'),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _should_retry(error: GoomzoError) -> bool:
|
|
112
|
+
# A key still being answered is safe to wait on; any other 409 is about the request itself.
|
|
113
|
+
return error.status in _RETRY_STATUSES or error.code == 'idempotency_key_in_use'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _result(response: httpx.Response, file: bool) -> Any:
|
|
117
|
+
if response.status_code == 204:
|
|
118
|
+
return None
|
|
119
|
+
if file:
|
|
120
|
+
return Media(response.content, response.headers.get('content-type', 'application/octet-stream'))
|
|
121
|
+
return response.json()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _connection_error(error: httpx.TransportError, timeout: float) -> GoomzoConnectionError:
|
|
125
|
+
if isinstance(error, httpx.TimeoutException):
|
|
126
|
+
return GoomzoConnectionError(f'No answer within {timeout:g} s.', timed_out=True)
|
|
127
|
+
return GoomzoConnectionError(f"Couldn't reach Goomzo: {error}", timed_out=False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SyncTransport:
|
|
131
|
+
"""Makes requests: authenticates, pins the API version, retries what's safe to, and raises GoomzoError."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, config: _Config, http: httpx.Client | None) -> None:
|
|
134
|
+
self._config = config
|
|
135
|
+
self._owns_http = http is None
|
|
136
|
+
self._http = http or httpx.Client()
|
|
137
|
+
|
|
138
|
+
def request(
|
|
139
|
+
self,
|
|
140
|
+
method: Method,
|
|
141
|
+
route: str,
|
|
142
|
+
*,
|
|
143
|
+
query: Query | None = None,
|
|
144
|
+
body: object = None,
|
|
145
|
+
idempotency_key: str | None = None,
|
|
146
|
+
file: bool = False,
|
|
147
|
+
) -> Any:
|
|
148
|
+
config = self._config
|
|
149
|
+
headers = _headers(config, method, idempotency_key, file)
|
|
150
|
+
attempt = 0
|
|
151
|
+
while True:
|
|
152
|
+
try:
|
|
153
|
+
response = self._http.request(
|
|
154
|
+
method,
|
|
155
|
+
config.base_url + route,
|
|
156
|
+
params=_params(query),
|
|
157
|
+
json=body,
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=config.timeout,
|
|
160
|
+
)
|
|
161
|
+
except httpx.TransportError as error:
|
|
162
|
+
if attempt < config.max_retries:
|
|
163
|
+
time.sleep(_backoff(attempt))
|
|
164
|
+
attempt += 1
|
|
165
|
+
continue
|
|
166
|
+
raise _connection_error(error, config.timeout) from error
|
|
167
|
+
if response.is_success:
|
|
168
|
+
return _result(response, file)
|
|
169
|
+
error = _to_error(response)
|
|
170
|
+
if _should_retry(error) and attempt < config.max_retries:
|
|
171
|
+
time.sleep(_retry_after(response) or _backoff(attempt))
|
|
172
|
+
attempt += 1
|
|
173
|
+
continue
|
|
174
|
+
raise error
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
if self._owns_http:
|
|
178
|
+
self._http.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AsyncTransport:
|
|
182
|
+
"""The same as SyncTransport, for asyncio."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, config: _Config, http: httpx.AsyncClient | None) -> None:
|
|
185
|
+
self._config = config
|
|
186
|
+
self._owns_http = http is None
|
|
187
|
+
self._http = http or httpx.AsyncClient()
|
|
188
|
+
|
|
189
|
+
async def request(
|
|
190
|
+
self,
|
|
191
|
+
method: Method,
|
|
192
|
+
route: str,
|
|
193
|
+
*,
|
|
194
|
+
query: Query | None = None,
|
|
195
|
+
body: object = None,
|
|
196
|
+
idempotency_key: str | None = None,
|
|
197
|
+
file: bool = False,
|
|
198
|
+
) -> Any:
|
|
199
|
+
config = self._config
|
|
200
|
+
headers = _headers(config, method, idempotency_key, file)
|
|
201
|
+
attempt = 0
|
|
202
|
+
while True:
|
|
203
|
+
try:
|
|
204
|
+
response = await self._http.request(
|
|
205
|
+
method,
|
|
206
|
+
config.base_url + route,
|
|
207
|
+
params=_params(query),
|
|
208
|
+
json=body,
|
|
209
|
+
headers=headers,
|
|
210
|
+
timeout=config.timeout,
|
|
211
|
+
)
|
|
212
|
+
except httpx.TransportError as error:
|
|
213
|
+
if attempt < config.max_retries:
|
|
214
|
+
await asyncio.sleep(_backoff(attempt))
|
|
215
|
+
attempt += 1
|
|
216
|
+
continue
|
|
217
|
+
raise _connection_error(error, config.timeout) from error
|
|
218
|
+
if response.is_success:
|
|
219
|
+
return _result(response, file)
|
|
220
|
+
error = _to_error(response)
|
|
221
|
+
if _should_retry(error) and attempt < config.max_retries:
|
|
222
|
+
await asyncio.sleep(_retry_after(response) or _backoff(attempt))
|
|
223
|
+
attempt += 1
|
|
224
|
+
continue
|
|
225
|
+
raise error
|
|
226
|
+
|
|
227
|
+
async def close(self) -> None:
|
|
228
|
+
if self._owns_http:
|
|
229
|
+
await self._http.aclose()
|
goomzo/_version.py
ADDED
goomzo/_webhooks.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from ._errors import WebhookVerificationError
|
|
13
|
+
from .types import WebhookEvent
|
|
14
|
+
|
|
15
|
+
_TOLERANCE = 5 * 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
19
|
+
value = headers.get(name)
|
|
20
|
+
if value is not None:
|
|
21
|
+
return value
|
|
22
|
+
return next((v for k, v in headers.items() if k.lower() == name), None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _key(secret: str) -> bytes:
|
|
26
|
+
raw = secret.removeprefix('whsec_')
|
|
27
|
+
try:
|
|
28
|
+
return base64.b64decode(raw, validate=True)
|
|
29
|
+
except (binascii.Error, ValueError):
|
|
30
|
+
return raw.encode()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_webhook(
|
|
34
|
+
payload: str | bytes,
|
|
35
|
+
headers: Mapping[str, str],
|
|
36
|
+
secret: str,
|
|
37
|
+
*,
|
|
38
|
+
now: float | None = None,
|
|
39
|
+
) -> WebhookEvent:
|
|
40
|
+
"""
|
|
41
|
+
Checks that a webhook delivery came from Goomzo and is recent, then returns its event.
|
|
42
|
+
|
|
43
|
+
Pass the body exactly as it arrived, before parsing it: the signature is of those bytes. Pass the request's
|
|
44
|
+
headers, and the endpoint's signing secret, `whsec_…`.
|
|
45
|
+
|
|
46
|
+
event = verify_webhook(request.body, request.headers, os.environ['GOOMZO_WEBHOOK_SECRET'])
|
|
47
|
+
if event['type'] == 'message.delivered':
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
Raises WebhookVerificationError when the signature doesn't match, or the delivery is more than 5 minutes old.
|
|
51
|
+
"""
|
|
52
|
+
webhook_id = _header(headers, 'webhook-id')
|
|
53
|
+
timestamp = _header(headers, 'webhook-timestamp')
|
|
54
|
+
signatures = _header(headers, 'webhook-signature')
|
|
55
|
+
if not webhook_id or not timestamp or not signatures:
|
|
56
|
+
raise WebhookVerificationError('The webhook-id, webhook-timestamp and webhook-signature headers are needed.')
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
sent = int(timestamp)
|
|
60
|
+
except ValueError:
|
|
61
|
+
raise WebhookVerificationError("The webhook-timestamp isn't a number.") from None
|
|
62
|
+
if abs((time.time() if now is None else now) - sent) > _TOLERANCE:
|
|
63
|
+
raise WebhookVerificationError('The delivery is too old, or from the future: check your clock.')
|
|
64
|
+
|
|
65
|
+
body = payload if isinstance(payload, bytes) else payload.encode()
|
|
66
|
+
signed = webhook_id.encode() + b'.' + timestamp.encode() + b'.' + body
|
|
67
|
+
expected = base64.b64encode(hmac.new(_key(secret), signed, hashlib.sha256).digest()).decode()
|
|
68
|
+
|
|
69
|
+
# While a secret is being rotated, a delivery carries a signature for each: any of them will do.
|
|
70
|
+
for part in signatures.split(' '):
|
|
71
|
+
version, _, signature = part.partition(',')
|
|
72
|
+
if version == 'v1' and hmac.compare_digest(signature, expected):
|
|
73
|
+
break
|
|
74
|
+
else:
|
|
75
|
+
raise WebhookVerificationError("The signature doesn't match: check the secret is this endpoint's.")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
return cast(WebhookEvent, json.loads(body))
|
|
79
|
+
except ValueError:
|
|
80
|
+
raise WebhookVerificationError("The body isn't JSON.") from None
|
goomzo/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from .._pagination import AsyncPage, AsyncPager, Page
|
|
6
|
+
from .._transport import AsyncTransport, SyncTransport, path
|
|
7
|
+
from ..types import Broadcast, BroadcastInput, BroadcastStatus, Channel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Broadcasts:
|
|
11
|
+
"""
|
|
12
|
+
Broadcasts: one message to segments, a list of numbers, or every customer, filled in for each person.
|
|
13
|
+
|
|
14
|
+
Docs: https://docs.goomzo.africa/docs/developers/messages/broadcasts
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
18
|
+
self._t = transport
|
|
19
|
+
|
|
20
|
+
def create(self, broadcast: BroadcastInput, *, idempotency_key: str | None = None) -> Broadcast:
|
|
21
|
+
"""
|
|
22
|
+
Sends a broadcast, now or at `sendAt`. `audience` is required: leaving it out is an error, never a send to
|
|
23
|
+
everyone.
|
|
24
|
+
|
|
25
|
+
goomzo.broadcasts.create({
|
|
26
|
+
'name': 'Black Friday',
|
|
27
|
+
'channel': 'sms',
|
|
28
|
+
'content': {'text': 'Hi {{name|there}}, 20% off today.'},
|
|
29
|
+
'audience': {'segments': [{'name': 'VIP'}]},
|
|
30
|
+
})
|
|
31
|
+
"""
|
|
32
|
+
return cast(
|
|
33
|
+
Broadcast, self._t.request('POST', '/v1/broadcasts', body=broadcast, idempotency_key=idempotency_key)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def get(self, id: str) -> Broadcast:
|
|
37
|
+
"""Gets a broadcast, with how its messages are doing."""
|
|
38
|
+
return cast(Broadcast, self._t.request('GET', path('/v1/broadcasts/{id}', id=id)))
|
|
39
|
+
|
|
40
|
+
def list(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
q: str | None = None,
|
|
44
|
+
channel: Channel | None = None,
|
|
45
|
+
status: BroadcastStatus | None = None,
|
|
46
|
+
limit: int | None = None,
|
|
47
|
+
) -> Page[Broadcast]:
|
|
48
|
+
"""Lists broadcasts, newest first. Loop over the page for all of them."""
|
|
49
|
+
query = {'q': q, 'channel': channel, 'status': status, 'limit': limit}
|
|
50
|
+
|
|
51
|
+
def fetch(cursor: str | None = None) -> Page[Broadcast]:
|
|
52
|
+
return Page(self._t.request('GET', '/v1/broadcasts', query={**query, 'cursor': cursor}), fetch)
|
|
53
|
+
|
|
54
|
+
return fetch()
|
|
55
|
+
|
|
56
|
+
def cancel(self, id: str) -> Broadcast:
|
|
57
|
+
"""Cancels a scheduled broadcast before it goes."""
|
|
58
|
+
return cast(Broadcast, self._t.request('POST', path('/v1/broadcasts/{id}/cancel', id=id)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AsyncBroadcasts:
|
|
62
|
+
"""Broadcasts, for asyncio. See `Broadcasts`."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
65
|
+
self._t = transport
|
|
66
|
+
|
|
67
|
+
async def create(self, broadcast: BroadcastInput, *, idempotency_key: str | None = None) -> Broadcast:
|
|
68
|
+
"""Sends a broadcast, now or at `sendAt`."""
|
|
69
|
+
return cast(
|
|
70
|
+
Broadcast, await self._t.request('POST', '/v1/broadcasts', body=broadcast, idempotency_key=idempotency_key)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def get(self, id: str) -> Broadcast:
|
|
74
|
+
"""Gets a broadcast, with how its messages are doing."""
|
|
75
|
+
return cast(Broadcast, await self._t.request('GET', path('/v1/broadcasts/{id}', id=id)))
|
|
76
|
+
|
|
77
|
+
def list(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
q: str | None = None,
|
|
81
|
+
channel: Channel | None = None,
|
|
82
|
+
status: BroadcastStatus | None = None,
|
|
83
|
+
limit: int | None = None,
|
|
84
|
+
) -> AsyncPager[Broadcast]:
|
|
85
|
+
"""Lists broadcasts, newest first. Await it for one page, or `async for` over it for all of them."""
|
|
86
|
+
query = {'q': q, 'channel': channel, 'status': status, 'limit': limit}
|
|
87
|
+
|
|
88
|
+
async def fetch(cursor: str | None = None) -> AsyncPage[Broadcast]:
|
|
89
|
+
return AsyncPage(await self._t.request('GET', '/v1/broadcasts', query={**query, 'cursor': cursor}), fetch)
|
|
90
|
+
|
|
91
|
+
return AsyncPager(fetch)
|
|
92
|
+
|
|
93
|
+
async def cancel(self, id: str) -> Broadcast:
|
|
94
|
+
"""Cancels a scheduled broadcast before it goes."""
|
|
95
|
+
return cast(Broadcast, await self._t.request('POST', path('/v1/broadcasts/{id}/cancel', id=id)))
|