whatsarab 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.
- whatsarab-0.1.0/.gitignore +6 -0
- whatsarab-0.1.0/PKG-INFO +192 -0
- whatsarab-0.1.0/README.md +164 -0
- whatsarab-0.1.0/pyproject.toml +42 -0
- whatsarab-0.1.0/src/whatsarab/__init__.py +32 -0
- whatsarab-0.1.0/src/whatsarab/client.py +310 -0
- whatsarab-0.1.0/src/whatsarab/webhooks.py +66 -0
- whatsarab-0.1.0/tests/test_client.py +345 -0
whatsarab-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whatsarab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the WhatsArab API — WhatsApp messages, media, templates, OTP, and signed webhooks on the official Meta Cloud API.
|
|
5
|
+
Project-URL: Homepage, https://whatsarab.com/docs
|
|
6
|
+
Project-URL: Documentation, https://whatsarab.com/docs
|
|
7
|
+
Project-URL: Source, https://github.com/efayek/whatsapp-api/tree/main/sdks/python
|
|
8
|
+
Project-URL: Support, https://whatsarab.com/support
|
|
9
|
+
Author: WhatsArab
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: arabic,mena,messaging,meta-cloud-api,otp,whatsapp,whatsapp-business-api
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Chat
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# whatsarab
|
|
30
|
+
|
|
31
|
+
Official Python client for the [WhatsArab](https://whatsarab.com) API — send WhatsApp messages on the official Meta Cloud API, upload media, manage templates, send and verify one-time codes, and verify signed webhooks.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install whatsarab
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Python 3.9+.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from whatsarab import WhatsArab
|
|
44
|
+
|
|
45
|
+
wa = WhatsArab(api_key=os.environ["WHATSARAB_API_KEY"])
|
|
46
|
+
|
|
47
|
+
# Free-form text — only inside the 24-hour customer-service window
|
|
48
|
+
wa.messages.send(to="201234567890", text="أهلًا يا سارة! طلبك رقم ١٠٤٢٨ خرج للتوصيل.")
|
|
49
|
+
|
|
50
|
+
# An approved template — works any time
|
|
51
|
+
wa.messages.send(
|
|
52
|
+
to="201234567890",
|
|
53
|
+
template="order_shipped",
|
|
54
|
+
language="ar",
|
|
55
|
+
components=[{"type": "body", "parameters": [{"type": "text", "text": "10428"}]}],
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Get an API key from **/app/integrations → API keys**. Keys are shown once, at creation.
|
|
60
|
+
|
|
61
|
+
## What this covers
|
|
62
|
+
|
|
63
|
+
The API-key surface, which is the eleven operations under `/api` in the [OpenAPI document](https://whatsarab.com/public/openapi.json):
|
|
64
|
+
|
|
65
|
+
| | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `wa.messages.send()` | text · template · interactive · media |
|
|
68
|
+
| `wa.media.upload()` / `.download()` | multipart up, raw bytes back |
|
|
69
|
+
| `wa.templates.list() / .create() / .edit() / .delete()` | Meta template management |
|
|
70
|
+
| `wa.otp.send()` / `.verify()` | one-time codes over WhatsApp |
|
|
71
|
+
| `wa.events.list()` | recent webhook events |
|
|
72
|
+
| `verify_webhook_signature()` | HMAC check for deliveries to you |
|
|
73
|
+
|
|
74
|
+
**Not covered, deliberately:** contacts, the inbox and broadcasts are session-authenticated — they belong to a signed-in dashboard user, not to a server-side key — so there is no endpoint for this client to call. If that changes, it changes in the OpenAPI document first.
|
|
75
|
+
|
|
76
|
+
## Sending
|
|
77
|
+
|
|
78
|
+
Exactly one of `template`, `text`, `interactive` or `media` per call — checked locally, so a mistake is a `ValueError` on your machine rather than a 400 from production.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# Media: upload once, then send by id
|
|
82
|
+
with open("invoice.pdf", "rb") as fh:
|
|
83
|
+
uploaded = wa.media.upload(fh, filename="invoice.pdf", content_type="application/pdf")
|
|
84
|
+
|
|
85
|
+
wa.messages.send(
|
|
86
|
+
to="201234567890",
|
|
87
|
+
media={"id": uploaded["media_id"], "kind": "document", "filename": "invoice.pdf"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Interactive buttons
|
|
91
|
+
wa.messages.send(
|
|
92
|
+
to="201234567890",
|
|
93
|
+
interactive={
|
|
94
|
+
"type": "button",
|
|
95
|
+
"body": {"text": "تحب نأكد الطلب؟"},
|
|
96
|
+
"action": {"buttons": [{"type": "reply", "reply": {"id": "yes", "title": "أكيد"}}]},
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Multi-number workspace: pick the sender explicitly
|
|
101
|
+
wa.messages.send(to="201234567890", text="hi", from_phone_number_id="113456789012345")
|
|
102
|
+
|
|
103
|
+
# Check the call without spending a message
|
|
104
|
+
wa.messages.send(to="201234567890", template="order_shipped", dry_run=True)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`text`, `interactive` and `media` are free but only deliverable inside the recipient's 24-hour customer-service window. Outside it the call fails with `OUTSIDE_CSW` and you need an approved template.
|
|
108
|
+
|
|
109
|
+
The response is a `202`: Meta accepted the message. Delivery arrives later on the webhook bus, matched by `message_id` (the wamid).
|
|
110
|
+
|
|
111
|
+
## One-time codes
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
sent = wa.otp.send(to="201234567890", template="otp_login", language="ar")
|
|
115
|
+
result = wa.otp.verify(request_id=sent["request_id"], code="123456")
|
|
116
|
+
|
|
117
|
+
if result["verified"]:
|
|
118
|
+
...
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The code is generated, hashed and checked on our side — it is never returned to you.
|
|
122
|
+
|
|
123
|
+
## Errors
|
|
124
|
+
|
|
125
|
+
Every failure — including a refused connection or a timeout — is a `WhatsArabError`, so one `except` covers the lot. Branch on `.code`, which is stable; the message is for humans and may be reworded.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from whatsarab import WhatsArabError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
wa.messages.send(to="201234567890", text="hi")
|
|
132
|
+
except WhatsArabError as err:
|
|
133
|
+
if err.code == "OUTSIDE_CSW":
|
|
134
|
+
send_template_instead()
|
|
135
|
+
elif err.code == "OVER_QUOTA":
|
|
136
|
+
queue_for_next_month()
|
|
137
|
+
elif err.code in ("TIMEOUT", "NETWORK_ERROR"):
|
|
138
|
+
retry_later()
|
|
139
|
+
else:
|
|
140
|
+
raise
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`err.request_id` is worth logging — quote it in a support ticket and we can find the exact request.
|
|
144
|
+
|
|
145
|
+
## Verifying webhooks
|
|
146
|
+
|
|
147
|
+
Deliveries to your endpoint carry `X-Whatsarab-Timestamp` and `X-Whatsarab-Signature: sha256=<hex>`, computed over `f"{timestamp}.{raw_body}"` with your subscription secret.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
151
|
+
from whatsarab import verify_webhook_signature
|
|
152
|
+
|
|
153
|
+
app = FastAPI()
|
|
154
|
+
|
|
155
|
+
@app.post("/webhooks/whatsarab")
|
|
156
|
+
async def hook(request: Request):
|
|
157
|
+
body = await request.body() # the raw bytes
|
|
158
|
+
ok = verify_webhook_signature(
|
|
159
|
+
secret=os.environ["WHATSARAB_WEBHOOK_SECRET"],
|
|
160
|
+
raw_body=body,
|
|
161
|
+
signature_header=request.headers.get("x-whatsarab-signature"),
|
|
162
|
+
timestamp_header=request.headers.get("x-whatsarab-timestamp"),
|
|
163
|
+
)
|
|
164
|
+
if not ok:
|
|
165
|
+
raise HTTPException(status_code=401)
|
|
166
|
+
...
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Pass the raw body.** A body that has been parsed and re-serialized will not match — that is the single commonest way this check is got wrong. The comparison is constant-time, and deliveries more than five minutes old are rejected (`tolerance_seconds=0` turns that off).
|
|
170
|
+
|
|
171
|
+
## Options
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
WhatsArab(
|
|
175
|
+
api_key="…", # required
|
|
176
|
+
base_url="https://whatsarab.com", # override for staging or self-hosted
|
|
177
|
+
timeout=30.0, # seconds; None disables
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The client holds an `httpx.Client`. Close it when you are done, or use it as a context manager:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
with WhatsArab(api_key="…") as wa:
|
|
185
|
+
wa.messages.send(to="201234567890", text="hi")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Links
|
|
189
|
+
|
|
190
|
+
- [Docs](https://whatsarab.com/docs) · [OpenAPI](https://whatsarab.com/public/openapi.json) · [Guides](https://whatsarab.com/how-to) · [Support](https://whatsarab.com/support)
|
|
191
|
+
|
|
192
|
+
MIT.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# whatsarab
|
|
2
|
+
|
|
3
|
+
Official Python client for the [WhatsArab](https://whatsarab.com) API — send WhatsApp messages on the official Meta Cloud API, upload media, manage templates, send and verify one-time codes, and verify signed webhooks.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install whatsarab
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Python 3.9+.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from whatsarab import WhatsArab
|
|
16
|
+
|
|
17
|
+
wa = WhatsArab(api_key=os.environ["WHATSARAB_API_KEY"])
|
|
18
|
+
|
|
19
|
+
# Free-form text — only inside the 24-hour customer-service window
|
|
20
|
+
wa.messages.send(to="201234567890", text="أهلًا يا سارة! طلبك رقم ١٠٤٢٨ خرج للتوصيل.")
|
|
21
|
+
|
|
22
|
+
# An approved template — works any time
|
|
23
|
+
wa.messages.send(
|
|
24
|
+
to="201234567890",
|
|
25
|
+
template="order_shipped",
|
|
26
|
+
language="ar",
|
|
27
|
+
components=[{"type": "body", "parameters": [{"type": "text", "text": "10428"}]}],
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Get an API key from **/app/integrations → API keys**. Keys are shown once, at creation.
|
|
32
|
+
|
|
33
|
+
## What this covers
|
|
34
|
+
|
|
35
|
+
The API-key surface, which is the eleven operations under `/api` in the [OpenAPI document](https://whatsarab.com/public/openapi.json):
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `wa.messages.send()` | text · template · interactive · media |
|
|
40
|
+
| `wa.media.upload()` / `.download()` | multipart up, raw bytes back |
|
|
41
|
+
| `wa.templates.list() / .create() / .edit() / .delete()` | Meta template management |
|
|
42
|
+
| `wa.otp.send()` / `.verify()` | one-time codes over WhatsApp |
|
|
43
|
+
| `wa.events.list()` | recent webhook events |
|
|
44
|
+
| `verify_webhook_signature()` | HMAC check for deliveries to you |
|
|
45
|
+
|
|
46
|
+
**Not covered, deliberately:** contacts, the inbox and broadcasts are session-authenticated — they belong to a signed-in dashboard user, not to a server-side key — so there is no endpoint for this client to call. If that changes, it changes in the OpenAPI document first.
|
|
47
|
+
|
|
48
|
+
## Sending
|
|
49
|
+
|
|
50
|
+
Exactly one of `template`, `text`, `interactive` or `media` per call — checked locally, so a mistake is a `ValueError` on your machine rather than a 400 from production.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# Media: upload once, then send by id
|
|
54
|
+
with open("invoice.pdf", "rb") as fh:
|
|
55
|
+
uploaded = wa.media.upload(fh, filename="invoice.pdf", content_type="application/pdf")
|
|
56
|
+
|
|
57
|
+
wa.messages.send(
|
|
58
|
+
to="201234567890",
|
|
59
|
+
media={"id": uploaded["media_id"], "kind": "document", "filename": "invoice.pdf"},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Interactive buttons
|
|
63
|
+
wa.messages.send(
|
|
64
|
+
to="201234567890",
|
|
65
|
+
interactive={
|
|
66
|
+
"type": "button",
|
|
67
|
+
"body": {"text": "تحب نأكد الطلب؟"},
|
|
68
|
+
"action": {"buttons": [{"type": "reply", "reply": {"id": "yes", "title": "أكيد"}}]},
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Multi-number workspace: pick the sender explicitly
|
|
73
|
+
wa.messages.send(to="201234567890", text="hi", from_phone_number_id="113456789012345")
|
|
74
|
+
|
|
75
|
+
# Check the call without spending a message
|
|
76
|
+
wa.messages.send(to="201234567890", template="order_shipped", dry_run=True)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`text`, `interactive` and `media` are free but only deliverable inside the recipient's 24-hour customer-service window. Outside it the call fails with `OUTSIDE_CSW` and you need an approved template.
|
|
80
|
+
|
|
81
|
+
The response is a `202`: Meta accepted the message. Delivery arrives later on the webhook bus, matched by `message_id` (the wamid).
|
|
82
|
+
|
|
83
|
+
## One-time codes
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
sent = wa.otp.send(to="201234567890", template="otp_login", language="ar")
|
|
87
|
+
result = wa.otp.verify(request_id=sent["request_id"], code="123456")
|
|
88
|
+
|
|
89
|
+
if result["verified"]:
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The code is generated, hashed and checked on our side — it is never returned to you.
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
Every failure — including a refused connection or a timeout — is a `WhatsArabError`, so one `except` covers the lot. Branch on `.code`, which is stable; the message is for humans and may be reworded.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from whatsarab import WhatsArabError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
wa.messages.send(to="201234567890", text="hi")
|
|
104
|
+
except WhatsArabError as err:
|
|
105
|
+
if err.code == "OUTSIDE_CSW":
|
|
106
|
+
send_template_instead()
|
|
107
|
+
elif err.code == "OVER_QUOTA":
|
|
108
|
+
queue_for_next_month()
|
|
109
|
+
elif err.code in ("TIMEOUT", "NETWORK_ERROR"):
|
|
110
|
+
retry_later()
|
|
111
|
+
else:
|
|
112
|
+
raise
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`err.request_id` is worth logging — quote it in a support ticket and we can find the exact request.
|
|
116
|
+
|
|
117
|
+
## Verifying webhooks
|
|
118
|
+
|
|
119
|
+
Deliveries to your endpoint carry `X-Whatsarab-Timestamp` and `X-Whatsarab-Signature: sha256=<hex>`, computed over `f"{timestamp}.{raw_body}"` with your subscription secret.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
123
|
+
from whatsarab import verify_webhook_signature
|
|
124
|
+
|
|
125
|
+
app = FastAPI()
|
|
126
|
+
|
|
127
|
+
@app.post("/webhooks/whatsarab")
|
|
128
|
+
async def hook(request: Request):
|
|
129
|
+
body = await request.body() # the raw bytes
|
|
130
|
+
ok = verify_webhook_signature(
|
|
131
|
+
secret=os.environ["WHATSARAB_WEBHOOK_SECRET"],
|
|
132
|
+
raw_body=body,
|
|
133
|
+
signature_header=request.headers.get("x-whatsarab-signature"),
|
|
134
|
+
timestamp_header=request.headers.get("x-whatsarab-timestamp"),
|
|
135
|
+
)
|
|
136
|
+
if not ok:
|
|
137
|
+
raise HTTPException(status_code=401)
|
|
138
|
+
...
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Pass the raw body.** A body that has been parsed and re-serialized will not match — that is the single commonest way this check is got wrong. The comparison is constant-time, and deliveries more than five minutes old are rejected (`tolerance_seconds=0` turns that off).
|
|
142
|
+
|
|
143
|
+
## Options
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
WhatsArab(
|
|
147
|
+
api_key="…", # required
|
|
148
|
+
base_url="https://whatsarab.com", # override for staging or self-hosted
|
|
149
|
+
timeout=30.0, # seconds; None disables
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The client holds an `httpx.Client`. Close it when you are done, or use it as a context manager:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
with WhatsArab(api_key="…") as wa:
|
|
157
|
+
wa.messages.send(to="201234567890", text="hi")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Links
|
|
161
|
+
|
|
162
|
+
- [Docs](https://whatsarab.com/docs) · [OpenAPI](https://whatsarab.com/public/openapi.json) · [Guides](https://whatsarab.com/how-to) · [Support](https://whatsarab.com/support)
|
|
163
|
+
|
|
164
|
+
MIT.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "whatsarab"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the WhatsArab API — WhatsApp messages, media, templates, OTP, and signed webhooks on the official Meta Cloud API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "WhatsArab" }]
|
|
13
|
+
keywords = ["whatsapp", "whatsapp-business-api", "meta-cloud-api", "messaging", "otp", "mena", "arabic"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Communications :: Chat",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["httpx>=0.27"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://whatsarab.com/docs"
|
|
31
|
+
Documentation = "https://whatsarab.com/docs"
|
|
32
|
+
Source = "https://github.com/efayek/whatsapp-api/tree/main/sdks/python"
|
|
33
|
+
Support = "https://whatsarab.com/support"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=8.0"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/whatsarab"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""whatsarab — the official Python client for the WhatsArab API.
|
|
2
|
+
|
|
3
|
+
from whatsarab import WhatsArab
|
|
4
|
+
|
|
5
|
+
wa = WhatsArab(api_key=os.environ["WHATSARAB_API_KEY"])
|
|
6
|
+
wa.messages.send(to="201234567890", text="أهلًا! طلبك في الطريق.")
|
|
7
|
+
|
|
8
|
+
sent = wa.otp.send(to="201234567890", template="otp_login")
|
|
9
|
+
wa.otp.verify(request_id=sent["request_id"], code="123456")
|
|
10
|
+
|
|
11
|
+
SCOPE: this client covers the API-KEY surface and nothing else — the eleven
|
|
12
|
+
operations under /api in the published OpenAPI document. Contacts, inbox and
|
|
13
|
+
broadcasts are session-authenticated (they belong to the dashboard, not to a
|
|
14
|
+
server-side key), so an SDK cannot offer them without inventing endpoints.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .client import (
|
|
18
|
+
DEFAULT_BASE_URL,
|
|
19
|
+
WhatsArab,
|
|
20
|
+
WhatsArabError,
|
|
21
|
+
)
|
|
22
|
+
from .webhooks import verify_webhook_signature
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"WhatsArab",
|
|
26
|
+
"WhatsArabError",
|
|
27
|
+
"verify_webhook_signature",
|
|
28
|
+
"DEFAULT_BASE_URL",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""HTTP client and resources.
|
|
2
|
+
|
|
3
|
+
httpx rather than requests: it gives us HTTP/2, a real timeout object, and the
|
|
4
|
+
same call shape for a future async client without a second implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, BinaryIO, Dict, List, Mapping, Optional, Sequence, Union
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://whatsarab.com"
|
|
15
|
+
DEFAULT_TIMEOUT = 30.0
|
|
16
|
+
|
|
17
|
+
MediaKind = str # "image" | "video" | "audio" | "document" | "sticker"
|
|
18
|
+
TemplateCategory = str # "AUTHENTICATION" | "MARKETING" | "UTILITY" | "SERVICE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WhatsArabError(Exception):
|
|
22
|
+
"""Every failure, transport included, so a caller writes one except clause.
|
|
23
|
+
|
|
24
|
+
Branch on ``code`` — it is the platform's stable error code
|
|
25
|
+
(``OUTSIDE_CSW``, ``OVER_QUOTA``, ``INVALID_PHONE``…). ``args[0]`` /
|
|
26
|
+
``str(err)`` is the human message and may be reworded between releases.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
status: int,
|
|
34
|
+
code: str,
|
|
35
|
+
request_id: Optional[str] = None,
|
|
36
|
+
meta_error: Any = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status = status
|
|
40
|
+
self.code = code
|
|
41
|
+
self.request_id = request_id
|
|
42
|
+
self.meta_error = meta_error
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
45
|
+
return f"WhatsArabError(code={self.code!r}, status={self.status}, message={str(self)!r})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WhatsArab:
|
|
49
|
+
"""Client for the WhatsArab API.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
api_key: your key (``whap_<prefix>_<secret>``), from /app/integrations.
|
|
53
|
+
base_url: backend origin. Defaults to the hosted platform.
|
|
54
|
+
timeout: seconds before a request is abandoned. ``None`` disables.
|
|
55
|
+
transport: an ``httpx`` transport, for tests or a custom proxy.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: str,
|
|
61
|
+
*,
|
|
62
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
63
|
+
timeout: Optional[float] = DEFAULT_TIMEOUT,
|
|
64
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
65
|
+
client: Optional[httpx.Client] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not api_key:
|
|
68
|
+
raise ValueError("api_key is required")
|
|
69
|
+
|
|
70
|
+
self.base_url = base_url.rstrip("/")
|
|
71
|
+
self._client = client or httpx.Client(
|
|
72
|
+
base_url=self.base_url,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
transport=transport,
|
|
75
|
+
headers={"X-API-Key": api_key},
|
|
76
|
+
)
|
|
77
|
+
# A caller-supplied client may not carry the key; set it either way so
|
|
78
|
+
# the header is never silently missing.
|
|
79
|
+
self._client.headers["X-API-Key"] = api_key
|
|
80
|
+
|
|
81
|
+
self.messages = _Messages(self)
|
|
82
|
+
self.media = _Media(self)
|
|
83
|
+
self.templates = _Templates(self)
|
|
84
|
+
self.otp = _Otp(self)
|
|
85
|
+
self.events = _Events(self)
|
|
86
|
+
|
|
87
|
+
# ── plumbing ────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def request(
|
|
90
|
+
self,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
*,
|
|
94
|
+
json: Any = None,
|
|
95
|
+
files: Any = None,
|
|
96
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
97
|
+
raw: bool = False,
|
|
98
|
+
) -> Any:
|
|
99
|
+
try:
|
|
100
|
+
response = self._client.request(method, path, json=json, files=files, params=params)
|
|
101
|
+
except httpx.TimeoutException as exc:
|
|
102
|
+
raise WhatsArabError(
|
|
103
|
+
str(exc) or f"Timed out talking to {self.base_url}",
|
|
104
|
+
status=0,
|
|
105
|
+
code="TIMEOUT",
|
|
106
|
+
) from exc
|
|
107
|
+
except httpx.HTTPError as exc:
|
|
108
|
+
raise WhatsArabError(
|
|
109
|
+
str(exc) or f"Could not reach {self.base_url}",
|
|
110
|
+
status=0,
|
|
111
|
+
code="NETWORK_ERROR",
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
if raw:
|
|
115
|
+
if response.status_code >= 400:
|
|
116
|
+
raise self._error_from(response)
|
|
117
|
+
return response.content
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
envelope = response.json()
|
|
121
|
+
except ValueError:
|
|
122
|
+
envelope = None
|
|
123
|
+
|
|
124
|
+
# The envelope is the authority, not the status line: the platform can
|
|
125
|
+
# answer 200 with success=false, and a client that trusted the status
|
|
126
|
+
# would hand back None and call it a send.
|
|
127
|
+
if response.status_code >= 400 or not isinstance(envelope, dict) or not envelope.get("success"):
|
|
128
|
+
raise self._error_from(response, envelope)
|
|
129
|
+
return envelope.get("data")
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _error_from(response: httpx.Response, envelope: Any = None) -> WhatsArabError:
|
|
133
|
+
if envelope is None:
|
|
134
|
+
try:
|
|
135
|
+
envelope = response.json()
|
|
136
|
+
except ValueError:
|
|
137
|
+
envelope = None
|
|
138
|
+
error = envelope.get("error") if isinstance(envelope, dict) else None
|
|
139
|
+
error = error if isinstance(error, dict) else {}
|
|
140
|
+
return WhatsArabError(
|
|
141
|
+
error.get("message") or response.reason_phrase or "Request failed",
|
|
142
|
+
status=response.status_code,
|
|
143
|
+
code=error.get("code") or f"HTTP_{response.status_code}",
|
|
144
|
+
request_id=envelope.get("request_id") if isinstance(envelope, dict) else None,
|
|
145
|
+
meta_error=error.get("meta_error"),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
self._client.close()
|
|
150
|
+
|
|
151
|
+
def __enter__(self) -> "WhatsArab":
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
155
|
+
self.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _Messages:
|
|
159
|
+
def __init__(self, client: WhatsArab) -> None:
|
|
160
|
+
self._client = client
|
|
161
|
+
|
|
162
|
+
def send(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
to: str,
|
|
166
|
+
template: Optional[str] = None,
|
|
167
|
+
text: Optional[str] = None,
|
|
168
|
+
interactive: Optional[Mapping[str, Any]] = None,
|
|
169
|
+
media: Optional[Mapping[str, Any]] = None,
|
|
170
|
+
language: Optional[str] = None,
|
|
171
|
+
components: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
172
|
+
from_phone_number_id: Optional[str] = None,
|
|
173
|
+
dry_run: Optional[bool] = None,
|
|
174
|
+
) -> Dict[str, Any]:
|
|
175
|
+
"""Send one message. Returns the 202 body, including the wamid.
|
|
176
|
+
|
|
177
|
+
Exactly one of ``template`` / ``text`` / ``interactive`` / ``media``.
|
|
178
|
+
Python cannot express that in the signature the way a TypeScript union
|
|
179
|
+
can, so it is checked here — a local ValueError beats a round trip that
|
|
180
|
+
comes back 400.
|
|
181
|
+
|
|
182
|
+
``text``, ``interactive`` and ``media`` are free but deliverable only
|
|
183
|
+
inside the recipient's 24-hour customer-service window; outside it the
|
|
184
|
+
call fails with ``OUTSIDE_CSW``.
|
|
185
|
+
"""
|
|
186
|
+
kinds = {
|
|
187
|
+
"template": template,
|
|
188
|
+
"text": text,
|
|
189
|
+
"interactive": interactive,
|
|
190
|
+
"media": media,
|
|
191
|
+
}
|
|
192
|
+
present = [name for name, value in kinds.items() if value is not None]
|
|
193
|
+
if len(present) != 1:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"exactly one of template / text / interactive / media is required, got: "
|
|
196
|
+
+ (", ".join(present) if present else "none")
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
payload: Dict[str, Any] = {"to": to}
|
|
200
|
+
payload[present[0]] = kinds[present[0]]
|
|
201
|
+
if language is not None:
|
|
202
|
+
payload["language"] = language
|
|
203
|
+
if components is not None:
|
|
204
|
+
payload["components"] = list(components)
|
|
205
|
+
if from_phone_number_id is not None:
|
|
206
|
+
payload["from_phone_number_id"] = from_phone_number_id
|
|
207
|
+
if dry_run is not None:
|
|
208
|
+
payload["dry_run"] = dry_run
|
|
209
|
+
return self._client.request("POST", "/api/messages", json=payload)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _Media:
|
|
213
|
+
def __init__(self, client: WhatsArab) -> None:
|
|
214
|
+
self._client = client
|
|
215
|
+
|
|
216
|
+
def upload(
|
|
217
|
+
self,
|
|
218
|
+
file: Union[bytes, BinaryIO],
|
|
219
|
+
*,
|
|
220
|
+
filename: Optional[str] = None,
|
|
221
|
+
content_type: Optional[str] = None,
|
|
222
|
+
) -> Dict[str, Any]:
|
|
223
|
+
"""Upload a file and get back a ``media_id`` for ``messages.send``."""
|
|
224
|
+
return self._client.request(
|
|
225
|
+
"POST",
|
|
226
|
+
"/api/media",
|
|
227
|
+
files={"file": (filename, file, content_type)},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def download(self, media_id: str) -> bytes:
|
|
231
|
+
"""Download a media file by id. Returns the raw bytes."""
|
|
232
|
+
return self._client.request("GET", f"/api/media/{quote(str(media_id), safe='')}", raw=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _Templates:
|
|
236
|
+
def __init__(self, client: WhatsArab) -> None:
|
|
237
|
+
self._client = client
|
|
238
|
+
|
|
239
|
+
def list(self) -> List[Any]:
|
|
240
|
+
return self._client.request("GET", "/api/templates")
|
|
241
|
+
|
|
242
|
+
def create(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
name: str,
|
|
246
|
+
language: str,
|
|
247
|
+
category: TemplateCategory,
|
|
248
|
+
components: Sequence[Mapping[str, Any]],
|
|
249
|
+
) -> Any:
|
|
250
|
+
return self._client.request(
|
|
251
|
+
"POST",
|
|
252
|
+
"/api/templates",
|
|
253
|
+
json={
|
|
254
|
+
"name": name,
|
|
255
|
+
"language": language,
|
|
256
|
+
"category": category,
|
|
257
|
+
"components": list(components),
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def edit(self, template_id: str, **fields: Any) -> Any:
|
|
262
|
+
"""Edit by Meta template id — note that create/delete key off the NAME."""
|
|
263
|
+
return self._client.request(
|
|
264
|
+
"PATCH", f"/api/templates/{quote(str(template_id), safe='')}", json=fields
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def delete(self, name: str) -> Any:
|
|
268
|
+
return self._client.request("DELETE", f"/api/templates/{quote(str(name), safe='')}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class _Otp:
|
|
272
|
+
def __init__(self, client: WhatsArab) -> None:
|
|
273
|
+
self._client = client
|
|
274
|
+
|
|
275
|
+
def send(
|
|
276
|
+
self,
|
|
277
|
+
*,
|
|
278
|
+
to: str,
|
|
279
|
+
template: str,
|
|
280
|
+
language: Optional[str] = None,
|
|
281
|
+
code_length: Optional[int] = None,
|
|
282
|
+
ttl_seconds: Optional[int] = None,
|
|
283
|
+
max_attempts: Optional[int] = None,
|
|
284
|
+
from_phone_number_id: Optional[str] = None,
|
|
285
|
+
) -> Dict[str, Any]:
|
|
286
|
+
payload: Dict[str, Any] = {"to": to, "template": template}
|
|
287
|
+
for key, value in (
|
|
288
|
+
("language", language),
|
|
289
|
+
("code_length", code_length),
|
|
290
|
+
("ttl_seconds", ttl_seconds),
|
|
291
|
+
("max_attempts", max_attempts),
|
|
292
|
+
("from_phone_number_id", from_phone_number_id),
|
|
293
|
+
):
|
|
294
|
+
if value is not None:
|
|
295
|
+
payload[key] = value
|
|
296
|
+
return self._client.request("POST", "/api/otp/send", json=payload)
|
|
297
|
+
|
|
298
|
+
def verify(self, *, request_id: str, code: str) -> Dict[str, Any]:
|
|
299
|
+
return self._client.request(
|
|
300
|
+
"POST", "/api/otp/verify", json={"request_id": request_id, "code": code}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class _Events:
|
|
305
|
+
def __init__(self, client: WhatsArab) -> None:
|
|
306
|
+
self._client = client
|
|
307
|
+
|
|
308
|
+
def list(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
309
|
+
"""Recent webhook events, newest first."""
|
|
310
|
+
return self._client.request("GET", "/api/events", params={"limit": limit})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Verification for outbound webhooks from WhatsArab."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import time
|
|
8
|
+
from typing import Callable, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_webhook_signature(
|
|
12
|
+
*,
|
|
13
|
+
secret: str,
|
|
14
|
+
raw_body: Union[str, bytes],
|
|
15
|
+
signature_header: Optional[str],
|
|
16
|
+
timestamp_header: Optional[str],
|
|
17
|
+
tolerance_seconds: int = 300,
|
|
18
|
+
now: Optional[Callable[[], float]] = None,
|
|
19
|
+
) -> bool:
|
|
20
|
+
"""Return True if a delivery genuinely came from WhatsArab.
|
|
21
|
+
|
|
22
|
+
Deliveries carry ``X-Whatsarab-Timestamp`` and
|
|
23
|
+
``X-Whatsarab-Signature: sha256=<hex>``, computed over
|
|
24
|
+
``f"{timestamp}.{raw_body}"`` with the subscription's secret.
|
|
25
|
+
|
|
26
|
+
Pass the RAW body — the exact bytes received. A body that has been parsed
|
|
27
|
+
and re-serialized will not match, and that is the single commonest way this
|
|
28
|
+
check is got wrong::
|
|
29
|
+
|
|
30
|
+
@app.post("/webhooks/whatsarab")
|
|
31
|
+
async def hook(request: Request):
|
|
32
|
+
body = await request.body()
|
|
33
|
+
ok = verify_webhook_signature(
|
|
34
|
+
secret=os.environ["WHATSARAB_WEBHOOK_SECRET"],
|
|
35
|
+
raw_body=body,
|
|
36
|
+
signature_header=request.headers.get("x-whatsarab-signature"),
|
|
37
|
+
timestamp_header=request.headers.get("x-whatsarab-timestamp"),
|
|
38
|
+
)
|
|
39
|
+
if not ok:
|
|
40
|
+
raise HTTPException(401)
|
|
41
|
+
|
|
42
|
+
Set ``tolerance_seconds=0`` to skip the freshness check (replay protection
|
|
43
|
+
off). Returns False rather than raising on anything malformed, so a bad
|
|
44
|
+
header cannot turn into a 500 on your webhook route.
|
|
45
|
+
"""
|
|
46
|
+
if not secret or not signature_header or not timestamp_header:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
if tolerance_seconds > 0:
|
|
50
|
+
try:
|
|
51
|
+
sent_at = float(timestamp_header)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return False
|
|
54
|
+
current = now() if now is not None else time.time()
|
|
55
|
+
if abs(current - sent_at) > tolerance_seconds:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
provided = signature_header
|
|
59
|
+
if provided.startswith("sha256="):
|
|
60
|
+
provided = provided[len("sha256=") :]
|
|
61
|
+
|
|
62
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
63
|
+
signed = timestamp_header.encode("utf-8") + b"." + body
|
|
64
|
+
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
65
|
+
|
|
66
|
+
return hmac.compare_digest(expected, provided)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Behavioural tests for the Python client.
|
|
2
|
+
|
|
3
|
+
Every test drives a real httpx.MockTransport, so what is asserted is the request
|
|
4
|
+
the SDK actually built — the URL, the method, the key header, the body — plus
|
|
5
|
+
how it turns a response into a value or an exception. No mocking of our own
|
|
6
|
+
methods, which would only prove the test matches the test.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from whatsarab import DEFAULT_BASE_URL, WhatsArab, WhatsArabError, verify_webhook_signature
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_client(handler, **kwargs):
|
|
20
|
+
transport = httpx.MockTransport(handler)
|
|
21
|
+
return WhatsArab(api_key="whap_test_secret", transport=transport, **kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def envelope(data, status=200):
|
|
25
|
+
return httpx.Response(status, json={"success": True, "data": data})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── requests ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_attaches_key_and_builds_url():
|
|
32
|
+
seen = {}
|
|
33
|
+
|
|
34
|
+
def handler(request):
|
|
35
|
+
seen["url"] = str(request.url)
|
|
36
|
+
seen["method"] = request.method
|
|
37
|
+
seen["key"] = request.headers.get("x-api-key")
|
|
38
|
+
seen["body"] = json.loads(request.content)
|
|
39
|
+
return envelope({"message_id": "wamid.X"})
|
|
40
|
+
|
|
41
|
+
client = make_client(handler)
|
|
42
|
+
client.messages.send(to="201234567890", text="مرحبا")
|
|
43
|
+
|
|
44
|
+
assert seen["url"] == f"{DEFAULT_BASE_URL}/api/messages"
|
|
45
|
+
assert seen["method"] == "POST"
|
|
46
|
+
assert seen["key"] == "whap_test_secret"
|
|
47
|
+
assert seen["body"] == {"to": "201234567890", "text": "مرحبا"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_strips_trailing_slashes_from_base_url():
|
|
51
|
+
seen = {}
|
|
52
|
+
|
|
53
|
+
def handler(request):
|
|
54
|
+
seen["url"] = str(request.url)
|
|
55
|
+
return envelope([])
|
|
56
|
+
|
|
57
|
+
client = make_client(handler, base_url="https://staging.example.com///")
|
|
58
|
+
client.templates.list()
|
|
59
|
+
|
|
60
|
+
assert seen["url"] == "https://staging.example.com/api/templates"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unwraps_the_envelope():
|
|
64
|
+
client = make_client(lambda r: envelope({"request_id": "req_1", "expires_in": 300}))
|
|
65
|
+
result = client.otp.send(to="201234567890", template="otp_login")
|
|
66
|
+
assert result["request_id"] == "req_1"
|
|
67
|
+
assert result["expires_in"] == 300
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_error_envelope_becomes_whatsaraberror():
|
|
71
|
+
def handler(request):
|
|
72
|
+
return httpx.Response(
|
|
73
|
+
400,
|
|
74
|
+
json={
|
|
75
|
+
"success": False,
|
|
76
|
+
"request_id": "req_err",
|
|
77
|
+
"error": {"code": "OUTSIDE_CSW", "message": "Outside the 24-hour window."},
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
client = make_client(handler)
|
|
82
|
+
with pytest.raises(WhatsArabError) as excinfo:
|
|
83
|
+
client.messages.send(to="201234567890", text="hi")
|
|
84
|
+
|
|
85
|
+
err = excinfo.value
|
|
86
|
+
assert err.code == "OUTSIDE_CSW"
|
|
87
|
+
assert err.status == 400
|
|
88
|
+
assert err.request_id == "req_err"
|
|
89
|
+
assert "24-hour" in str(err)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_200_with_success_false_is_still_a_failure():
|
|
93
|
+
# The envelope is the authority, not the status line. A client that trusted
|
|
94
|
+
# the status would return None here and call it a send.
|
|
95
|
+
def handler(request):
|
|
96
|
+
return httpx.Response(200, json={"success": False, "error": {"code": "OVER_QUOTA"}})
|
|
97
|
+
|
|
98
|
+
client = make_client(handler)
|
|
99
|
+
with pytest.raises(WhatsArabError) as excinfo:
|
|
100
|
+
client.templates.list()
|
|
101
|
+
assert excinfo.value.code == "OVER_QUOTA"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_transport_failure_is_the_same_error_type():
|
|
105
|
+
def handler(request):
|
|
106
|
+
raise httpx.ConnectError("connection refused")
|
|
107
|
+
|
|
108
|
+
client = make_client(handler)
|
|
109
|
+
with pytest.raises(WhatsArabError) as excinfo:
|
|
110
|
+
client.events.list()
|
|
111
|
+
assert excinfo.value.code == "NETWORK_ERROR"
|
|
112
|
+
assert excinfo.value.status == 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_timeout_is_reported_as_timeout():
|
|
116
|
+
def handler(request):
|
|
117
|
+
raise httpx.ReadTimeout("timed out")
|
|
118
|
+
|
|
119
|
+
client = make_client(handler)
|
|
120
|
+
with pytest.raises(WhatsArabError) as excinfo:
|
|
121
|
+
client.events.list()
|
|
122
|
+
assert excinfo.value.code == "TIMEOUT"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_non_json_error_body_still_raises_with_a_code():
|
|
126
|
+
def handler(request):
|
|
127
|
+
return httpx.Response(502, text="<html>bad gateway</html>")
|
|
128
|
+
|
|
129
|
+
client = make_client(handler)
|
|
130
|
+
with pytest.raises(WhatsArabError) as excinfo:
|
|
131
|
+
client.templates.list()
|
|
132
|
+
assert excinfo.value.code == "HTTP_502"
|
|
133
|
+
assert excinfo.value.status == 502
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── resources ───────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_send_requires_exactly_one_kind():
|
|
140
|
+
client = make_client(lambda r: envelope({}))
|
|
141
|
+
|
|
142
|
+
with pytest.raises(ValueError, match="exactly one"):
|
|
143
|
+
client.messages.send(to="201234567890")
|
|
144
|
+
with pytest.raises(ValueError, match="exactly one"):
|
|
145
|
+
client.messages.send(to="201234567890", text="hi", template="order_shipped")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_send_passes_optional_fields_through_and_omits_the_rest():
|
|
149
|
+
seen = {}
|
|
150
|
+
|
|
151
|
+
def handler(request):
|
|
152
|
+
seen["body"] = json.loads(request.content)
|
|
153
|
+
return envelope({})
|
|
154
|
+
|
|
155
|
+
client = make_client(handler)
|
|
156
|
+
client.messages.send(
|
|
157
|
+
to="201234567890",
|
|
158
|
+
template="order_shipped",
|
|
159
|
+
language="ar",
|
|
160
|
+
components=[{"type": "body", "parameters": [{"type": "text", "text": "10428"}]}],
|
|
161
|
+
from_phone_number_id="113456789012345",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert seen["body"]["template"] == "order_shipped"
|
|
165
|
+
assert seen["body"]["language"] == "ar"
|
|
166
|
+
assert seen["body"]["from_phone_number_id"] == "113456789012345"
|
|
167
|
+
# Unset options must not be sent as nulls — the API rejects a null dry_run.
|
|
168
|
+
assert "dry_run" not in seen["body"]
|
|
169
|
+
assert "text" not in seen["body"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_path_segments_are_url_encoded():
|
|
173
|
+
# The case that matters is a RESERVED character, not a space: httpx encodes
|
|
174
|
+
# spaces on its own, so asserting on one would pass with the encoding
|
|
175
|
+
# removed. A slash does not get encoded for you — it silently becomes a
|
|
176
|
+
# path separator and addresses a different resource.
|
|
177
|
+
calls = []
|
|
178
|
+
|
|
179
|
+
def handler(request):
|
|
180
|
+
calls.append(str(request.url))
|
|
181
|
+
return httpx.Response(200, content=b"x")
|
|
182
|
+
|
|
183
|
+
client = make_client(handler)
|
|
184
|
+
client.media.download("../../secrets")
|
|
185
|
+
|
|
186
|
+
assert "%2F" in calls[0]
|
|
187
|
+
assert calls[0].endswith("/api/media/..%2F..%2Fsecrets")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_templates_edit_is_patch_on_id_and_delete_is_on_name():
|
|
191
|
+
calls = []
|
|
192
|
+
|
|
193
|
+
def handler(request):
|
|
194
|
+
calls.append((request.method, str(request.url)))
|
|
195
|
+
return envelope({})
|
|
196
|
+
|
|
197
|
+
client = make_client(handler)
|
|
198
|
+
client.templates.edit("12345", category="UTILITY")
|
|
199
|
+
client.templates.delete("order_confirmation")
|
|
200
|
+
|
|
201
|
+
assert calls[0][0] == "PATCH"
|
|
202
|
+
assert calls[0][1].endswith("/api/templates/12345")
|
|
203
|
+
assert calls[1][0] == "DELETE"
|
|
204
|
+
assert calls[1][1].endswith("/api/templates/order_confirmation")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_media_download_returns_raw_bytes():
|
|
208
|
+
payload = b"\xff\xd8\xff\xe0jpeg-ish"
|
|
209
|
+
|
|
210
|
+
def handler(request):
|
|
211
|
+
return httpx.Response(200, content=payload)
|
|
212
|
+
|
|
213
|
+
client = make_client(handler)
|
|
214
|
+
assert client.media.download("media_1") == payload
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_media_download_raises_on_an_error_body():
|
|
218
|
+
def handler(request):
|
|
219
|
+
return httpx.Response(404, json={"success": False, "error": {"code": "NOT_FOUND"}})
|
|
220
|
+
|
|
221
|
+
client = make_client(handler)
|
|
222
|
+
with pytest.raises(WhatsArabError) as excinfo:
|
|
223
|
+
client.media.download("nope")
|
|
224
|
+
assert excinfo.value.code == "NOT_FOUND"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_media_upload_is_multipart():
|
|
228
|
+
seen = {}
|
|
229
|
+
|
|
230
|
+
def handler(request):
|
|
231
|
+
seen["content_type"] = request.headers.get("content-type", "")
|
|
232
|
+
seen["body"] = request.content
|
|
233
|
+
return envelope({"media_id": "m_1"})
|
|
234
|
+
|
|
235
|
+
client = make_client(handler)
|
|
236
|
+
result = client.media.upload(b"%PDF-1.4", filename="invoice.pdf", content_type="application/pdf")
|
|
237
|
+
|
|
238
|
+
assert result["media_id"] == "m_1"
|
|
239
|
+
assert seen["content_type"].startswith("multipart/form-data")
|
|
240
|
+
assert b"invoice.pdf" in seen["body"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_events_list_sends_the_limit_as_a_query_param():
|
|
244
|
+
seen = {}
|
|
245
|
+
|
|
246
|
+
def handler(request):
|
|
247
|
+
seen["url"] = str(request.url)
|
|
248
|
+
return envelope([])
|
|
249
|
+
|
|
250
|
+
client = make_client(handler)
|
|
251
|
+
client.events.list(limit=5)
|
|
252
|
+
|
|
253
|
+
assert "limit=5" in seen["url"]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_rejects_construction_without_a_key():
|
|
257
|
+
with pytest.raises(ValueError, match="api_key is required"):
|
|
258
|
+
WhatsArab(api_key="")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_works_as_a_context_manager():
|
|
262
|
+
with make_client(lambda r: envelope([])) as client:
|
|
263
|
+
assert client.templates.list() == []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── webhook signature ───────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
SECRET = "whsec_test"
|
|
269
|
+
NOW = 1700000000.0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sign(body, timestamp, secret=SECRET):
|
|
273
|
+
body_bytes = body.encode("utf-8") if isinstance(body, str) else body
|
|
274
|
+
signed = str(timestamp).encode("utf-8") + b"." + body_bytes
|
|
275
|
+
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
276
|
+
return "sha256=" + digest
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_accepts_a_correctly_signed_delivery():
|
|
280
|
+
body = '{"event":"message.delivered"}'
|
|
281
|
+
assert verify_webhook_signature(
|
|
282
|
+
secret=SECRET,
|
|
283
|
+
raw_body=body,
|
|
284
|
+
signature_header=sign(body, "1700000000"),
|
|
285
|
+
timestamp_header="1700000000",
|
|
286
|
+
now=lambda: NOW,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_rejects_a_body_that_changed_by_one_character():
|
|
291
|
+
assert not verify_webhook_signature(
|
|
292
|
+
secret=SECRET,
|
|
293
|
+
raw_body='{"amount":99}',
|
|
294
|
+
signature_header=sign('{"amount":10}', "1700000000"),
|
|
295
|
+
timestamp_header="1700000000",
|
|
296
|
+
now=lambda: NOW,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_rejects_a_signature_from_a_different_secret():
|
|
301
|
+
body = '{"a":1}'
|
|
302
|
+
assert not verify_webhook_signature(
|
|
303
|
+
secret=SECRET,
|
|
304
|
+
raw_body=body,
|
|
305
|
+
signature_header=sign(body, "1700000000", secret="other_secret"),
|
|
306
|
+
timestamp_header="1700000000",
|
|
307
|
+
now=lambda: NOW,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_rejects_a_replay_outside_the_tolerance_but_allows_it_when_disabled():
|
|
312
|
+
body = '{"a":1}'
|
|
313
|
+
args = dict(
|
|
314
|
+
secret=SECRET,
|
|
315
|
+
raw_body=body,
|
|
316
|
+
signature_header=sign(body, "1700000000"),
|
|
317
|
+
timestamp_header="1700000000",
|
|
318
|
+
)
|
|
319
|
+
# The bytes stay valid forever; only the clock moves.
|
|
320
|
+
assert not verify_webhook_signature(**args, now=lambda: NOW + 3600)
|
|
321
|
+
assert verify_webhook_signature(**args, tolerance_seconds=0, now=lambda: NOW + 3600)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_rejects_missing_or_malformed_headers_without_raising():
|
|
325
|
+
base = dict(secret=SECRET, raw_body="{}", now=lambda: NOW)
|
|
326
|
+
assert not verify_webhook_signature(**base, signature_header=None, timestamp_header="1700000000")
|
|
327
|
+
assert not verify_webhook_signature(**base, signature_header="sha256=aa", timestamp_header=None)
|
|
328
|
+
assert not verify_webhook_signature(**base, signature_header="sha256=aa", timestamp_header="not-a-number")
|
|
329
|
+
assert not verify_webhook_signature(**base, signature_header="sha256=short", timestamp_header="1700000000")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_accepts_the_signature_with_or_without_the_prefix():
|
|
333
|
+
body = '{"a":1}'
|
|
334
|
+
with_prefix = sign(body, "1700000000")
|
|
335
|
+
base = dict(secret=SECRET, raw_body=body, timestamp_header="1700000000", now=lambda: NOW)
|
|
336
|
+
assert verify_webhook_signature(**base, signature_header=with_prefix)
|
|
337
|
+
assert verify_webhook_signature(**base, signature_header=with_prefix[len("sha256=") :])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_bytes_and_str_bodies_agree():
|
|
341
|
+
body = '{"ar":"أهلًا"}'
|
|
342
|
+
signature = sign(body, "1700000000")
|
|
343
|
+
base = dict(secret=SECRET, signature_header=signature, timestamp_header="1700000000", now=lambda: NOW)
|
|
344
|
+
assert verify_webhook_signature(**base, raw_body=body)
|
|
345
|
+
assert verify_webhook_signature(**base, raw_body=body.encode("utf-8"))
|