whatsapp-cloud-api-client 0.2.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.
- whatsapp_cloud_api/__init__.py +21 -0
- whatsapp_cloud_api/client.py +534 -0
- whatsapp_cloud_api/exceptions.py +26 -0
- whatsapp_cloud_api/models.py +40 -0
- whatsapp_cloud_api/webhook.py +34 -0
- whatsapp_cloud_api_client-0.2.0.dist-info/METADATA +184 -0
- whatsapp_cloud_api_client-0.2.0.dist-info/RECORD +9 -0
- whatsapp_cloud_api_client-0.2.0.dist-info/WHEEL +5 -0
- whatsapp_cloud_api_client-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .client import AsyncWhatsAppClient, WhatsAppClient
|
|
2
|
+
from .exceptions import WhatsAppAPIError
|
|
3
|
+
from .models import (
|
|
4
|
+
MarkAsReadResponse,
|
|
5
|
+
MediaInfoResponse,
|
|
6
|
+
MediaUploadResponse,
|
|
7
|
+
SendMessageResponse,
|
|
8
|
+
)
|
|
9
|
+
from .webhook import verify_webhook_challenge, verify_webhook_signature
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AsyncWhatsAppClient",
|
|
13
|
+
"WhatsAppClient",
|
|
14
|
+
"WhatsAppAPIError",
|
|
15
|
+
"SendMessageResponse",
|
|
16
|
+
"MediaUploadResponse",
|
|
17
|
+
"MediaInfoResponse",
|
|
18
|
+
"MarkAsReadResponse",
|
|
19
|
+
"verify_webhook_challenge",
|
|
20
|
+
"verify_webhook_signature",
|
|
21
|
+
]
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import mimetypes
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .exceptions import WhatsAppAPIError
|
|
14
|
+
from .models import MarkAsReadResponse, MediaInfoResponse, MediaUploadResponse, SendMessageResponse
|
|
15
|
+
|
|
16
|
+
DEFAULT_RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
17
|
+
DEFAULT_RETRY_METHODS = frozenset({"GET"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _raise_for_api_error(response: httpx.Response, data: Any) -> None:
|
|
21
|
+
if not response.is_error:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
err = data.get("error", {}) if isinstance(data, dict) else {}
|
|
25
|
+
raise WhatsAppAPIError(
|
|
26
|
+
message=err.get("message") or f"HTTP {response.status_code}",
|
|
27
|
+
status_code=response.status_code,
|
|
28
|
+
error_type=err.get("type"),
|
|
29
|
+
code=err.get("code"),
|
|
30
|
+
error_subcode=err.get("error_subcode"),
|
|
31
|
+
fbtrace_id=err.get("fbtrace_id"),
|
|
32
|
+
details=data if isinstance(data, dict) else {"raw": data},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_json_response(response: httpx.Response) -> dict[str, Any]:
|
|
37
|
+
try:
|
|
38
|
+
data = response.json()
|
|
39
|
+
except ValueError:
|
|
40
|
+
data = {}
|
|
41
|
+
|
|
42
|
+
_raise_for_api_error(response, data)
|
|
43
|
+
return data if isinstance(data, dict) else {"raw": data}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_media_payload(
|
|
47
|
+
*,
|
|
48
|
+
media_id: str | None,
|
|
49
|
+
link: str | None,
|
|
50
|
+
caption: str | None,
|
|
51
|
+
filename: str | None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
if not media_id and not link:
|
|
54
|
+
raise ValueError("media_id or link is required")
|
|
55
|
+
if media_id and link:
|
|
56
|
+
raise ValueError("use only one of media_id or link")
|
|
57
|
+
|
|
58
|
+
media_payload: dict[str, Any] = {}
|
|
59
|
+
if media_id:
|
|
60
|
+
media_payload["id"] = media_id
|
|
61
|
+
if link:
|
|
62
|
+
media_payload["link"] = link
|
|
63
|
+
if caption:
|
|
64
|
+
media_payload["caption"] = caption
|
|
65
|
+
if filename:
|
|
66
|
+
media_payload["filename"] = filename
|
|
67
|
+
return media_payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_methods(methods: Iterable[str] | None) -> frozenset[str]:
|
|
71
|
+
if methods is None:
|
|
72
|
+
return DEFAULT_RETRY_METHODS
|
|
73
|
+
return frozenset(method.upper() for method in methods)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_retry_after_seconds(retry_after: str | None) -> float | None:
|
|
77
|
+
if not retry_after:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
retry_after = retry_after.strip()
|
|
81
|
+
try:
|
|
82
|
+
seconds = float(retry_after)
|
|
83
|
+
return seconds if seconds > 0 else None
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
parsed_date = parsedate_to_datetime(retry_after)
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
if parsed_date.tzinfo is None:
|
|
93
|
+
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
|
94
|
+
now = datetime.now(timezone.utc)
|
|
95
|
+
seconds = (parsed_date - now).total_seconds()
|
|
96
|
+
return seconds if seconds > 0 else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _RetryMixin:
|
|
100
|
+
max_retries: int
|
|
101
|
+
backoff_factor: float
|
|
102
|
+
max_backoff: float
|
|
103
|
+
retry_status_codes: frozenset[int]
|
|
104
|
+
retry_methods: frozenset[str]
|
|
105
|
+
|
|
106
|
+
def _should_retry_method(self, method: str) -> bool:
|
|
107
|
+
return method.upper() in self.retry_methods
|
|
108
|
+
|
|
109
|
+
def _get_retry_delay(self, attempt: int, retry_after: str | None) -> float:
|
|
110
|
+
header_delay = _parse_retry_after_seconds(retry_after)
|
|
111
|
+
if header_delay is not None:
|
|
112
|
+
return min(self.max_backoff, header_delay)
|
|
113
|
+
|
|
114
|
+
exponential_delay = self.backoff_factor * (2**attempt)
|
|
115
|
+
return min(self.max_backoff, exponential_delay)
|
|
116
|
+
|
|
117
|
+
def _should_retry_response(self, response: httpx.Response) -> bool:
|
|
118
|
+
return response.status_code in self.retry_status_codes
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WhatsAppClient(_RetryMixin):
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
access_token: str,
|
|
125
|
+
phone_number_id: str,
|
|
126
|
+
*,
|
|
127
|
+
api_version: str = "v20.0",
|
|
128
|
+
timeout: float = 20.0,
|
|
129
|
+
base_url: str = "https://graph.facebook.com",
|
|
130
|
+
http_client: httpx.Client | None = None,
|
|
131
|
+
max_retries: int = 3,
|
|
132
|
+
backoff_factor: float = 0.5,
|
|
133
|
+
max_backoff: float = 8.0,
|
|
134
|
+
retry_status_codes: Iterable[int] | None = None,
|
|
135
|
+
retry_methods: Iterable[str] | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
if not access_token:
|
|
138
|
+
raise ValueError("access_token is required")
|
|
139
|
+
if not phone_number_id:
|
|
140
|
+
raise ValueError("phone_number_id is required")
|
|
141
|
+
if max_retries < 0:
|
|
142
|
+
raise ValueError("max_retries must be >= 0")
|
|
143
|
+
|
|
144
|
+
self.access_token = access_token
|
|
145
|
+
self.phone_number_id = phone_number_id
|
|
146
|
+
self.api_version = api_version
|
|
147
|
+
self.base_url = base_url.rstrip("/")
|
|
148
|
+
self.timeout = timeout
|
|
149
|
+
self.max_retries = max_retries
|
|
150
|
+
self.backoff_factor = backoff_factor
|
|
151
|
+
self.max_backoff = max_backoff
|
|
152
|
+
self.retry_status_codes = frozenset(retry_status_codes or DEFAULT_RETRY_STATUS_CODES)
|
|
153
|
+
self.retry_methods = _normalize_methods(retry_methods)
|
|
154
|
+
|
|
155
|
+
self._owns_client = http_client is None
|
|
156
|
+
self._client = http_client or httpx.Client(
|
|
157
|
+
base_url=f"{self.base_url}/{self.api_version}",
|
|
158
|
+
timeout=self.timeout,
|
|
159
|
+
headers={
|
|
160
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
if self._owns_client:
|
|
167
|
+
self._client.close()
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> "WhatsAppClient":
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
173
|
+
self.close()
|
|
174
|
+
|
|
175
|
+
def send_text(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
to: str,
|
|
179
|
+
body: str,
|
|
180
|
+
preview_url: bool = False,
|
|
181
|
+
context_message_id: str | None = None,
|
|
182
|
+
) -> SendMessageResponse:
|
|
183
|
+
payload: dict[str, Any] = {
|
|
184
|
+
"messaging_product": "whatsapp",
|
|
185
|
+
"to": to,
|
|
186
|
+
"type": "text",
|
|
187
|
+
"text": {"body": body, "preview_url": preview_url},
|
|
188
|
+
}
|
|
189
|
+
if context_message_id:
|
|
190
|
+
payload["context"] = {"message_id": context_message_id}
|
|
191
|
+
data = self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
192
|
+
return SendMessageResponse.model_validate(data)
|
|
193
|
+
|
|
194
|
+
def send_template(
|
|
195
|
+
self,
|
|
196
|
+
*,
|
|
197
|
+
to: str,
|
|
198
|
+
name: str,
|
|
199
|
+
language_code: str = "pt_BR",
|
|
200
|
+
components: list[dict[str, Any]] | None = None,
|
|
201
|
+
) -> SendMessageResponse:
|
|
202
|
+
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
|
|
203
|
+
if components:
|
|
204
|
+
template["components"] = components
|
|
205
|
+
|
|
206
|
+
payload = {
|
|
207
|
+
"messaging_product": "whatsapp",
|
|
208
|
+
"to": to,
|
|
209
|
+
"type": "template",
|
|
210
|
+
"template": template,
|
|
211
|
+
}
|
|
212
|
+
data = self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
213
|
+
return SendMessageResponse.model_validate(data)
|
|
214
|
+
|
|
215
|
+
def send_media(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
to: str,
|
|
219
|
+
media_type: str,
|
|
220
|
+
media_id: str | None = None,
|
|
221
|
+
link: str | None = None,
|
|
222
|
+
caption: str | None = None,
|
|
223
|
+
filename: str | None = None,
|
|
224
|
+
) -> SendMessageResponse:
|
|
225
|
+
media_payload = _build_media_payload(
|
|
226
|
+
media_id=media_id,
|
|
227
|
+
link=link,
|
|
228
|
+
caption=caption,
|
|
229
|
+
filename=filename,
|
|
230
|
+
)
|
|
231
|
+
payload = {
|
|
232
|
+
"messaging_product": "whatsapp",
|
|
233
|
+
"to": to,
|
|
234
|
+
"type": media_type,
|
|
235
|
+
media_type: media_payload,
|
|
236
|
+
}
|
|
237
|
+
data = self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
238
|
+
return SendMessageResponse.model_validate(data)
|
|
239
|
+
|
|
240
|
+
def upload_media(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
file_path: str | Path,
|
|
244
|
+
mime_type: str | None = None,
|
|
245
|
+
filename: str | None = None,
|
|
246
|
+
) -> MediaUploadResponse:
|
|
247
|
+
path = Path(file_path)
|
|
248
|
+
if not path.exists():
|
|
249
|
+
raise FileNotFoundError(f"file not found: {path}")
|
|
250
|
+
|
|
251
|
+
guessed_mime, _ = mimetypes.guess_type(str(path))
|
|
252
|
+
final_mime = mime_type or guessed_mime or "application/octet-stream"
|
|
253
|
+
final_name = filename or path.name
|
|
254
|
+
|
|
255
|
+
with path.open("rb") as file_handle:
|
|
256
|
+
files = {
|
|
257
|
+
"file": (final_name, file_handle, final_mime),
|
|
258
|
+
"messaging_product": (None, "whatsapp"),
|
|
259
|
+
}
|
|
260
|
+
data = self._request(
|
|
261
|
+
"POST",
|
|
262
|
+
f"/{self.phone_number_id}/media",
|
|
263
|
+
files=files,
|
|
264
|
+
content_type_json=False,
|
|
265
|
+
)
|
|
266
|
+
return MediaUploadResponse.model_validate(data)
|
|
267
|
+
|
|
268
|
+
def get_media(self, *, media_id: str) -> MediaInfoResponse:
|
|
269
|
+
data = self._request("GET", f"/{media_id}")
|
|
270
|
+
return MediaInfoResponse.model_validate(data)
|
|
271
|
+
|
|
272
|
+
def mark_as_read(self, *, message_id: str) -> MarkAsReadResponse:
|
|
273
|
+
payload = {
|
|
274
|
+
"messaging_product": "whatsapp",
|
|
275
|
+
"status": "read",
|
|
276
|
+
"message_id": message_id,
|
|
277
|
+
}
|
|
278
|
+
data = self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
279
|
+
return MarkAsReadResponse.model_validate(data)
|
|
280
|
+
|
|
281
|
+
def _request(
|
|
282
|
+
self,
|
|
283
|
+
method: str,
|
|
284
|
+
path: str,
|
|
285
|
+
*,
|
|
286
|
+
json: dict[str, Any] | None = None,
|
|
287
|
+
files: dict[str, Any] | None = None,
|
|
288
|
+
content_type_json: bool = True,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
headers = None
|
|
291
|
+
if not content_type_json:
|
|
292
|
+
headers = {"Authorization": f"Bearer {self.access_token}"}
|
|
293
|
+
|
|
294
|
+
attempt = 0
|
|
295
|
+
can_retry_method = self._should_retry_method(method)
|
|
296
|
+
|
|
297
|
+
while True:
|
|
298
|
+
try:
|
|
299
|
+
response = self._client.request(
|
|
300
|
+
method=method,
|
|
301
|
+
url=path,
|
|
302
|
+
json=json,
|
|
303
|
+
files=files,
|
|
304
|
+
headers=headers,
|
|
305
|
+
)
|
|
306
|
+
except httpx.HTTPError as exc:
|
|
307
|
+
if not can_retry_method or attempt >= self.max_retries:
|
|
308
|
+
raise WhatsAppAPIError(message=f"HTTP communication error: {exc}") from exc
|
|
309
|
+
delay = self._get_retry_delay(attempt, None)
|
|
310
|
+
if delay > 0:
|
|
311
|
+
time.sleep(delay)
|
|
312
|
+
attempt += 1
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if (
|
|
316
|
+
can_retry_method
|
|
317
|
+
and attempt < self.max_retries
|
|
318
|
+
and self._should_retry_response(response)
|
|
319
|
+
):
|
|
320
|
+
delay = self._get_retry_delay(attempt, response.headers.get("Retry-After"))
|
|
321
|
+
if delay > 0:
|
|
322
|
+
time.sleep(delay)
|
|
323
|
+
attempt += 1
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
return _parse_json_response(response)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class AsyncWhatsAppClient(_RetryMixin):
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
access_token: str,
|
|
333
|
+
phone_number_id: str,
|
|
334
|
+
*,
|
|
335
|
+
api_version: str = "v20.0",
|
|
336
|
+
timeout: float = 20.0,
|
|
337
|
+
base_url: str = "https://graph.facebook.com",
|
|
338
|
+
http_client: httpx.AsyncClient | None = None,
|
|
339
|
+
max_retries: int = 3,
|
|
340
|
+
backoff_factor: float = 0.5,
|
|
341
|
+
max_backoff: float = 8.0,
|
|
342
|
+
retry_status_codes: Iterable[int] | None = None,
|
|
343
|
+
retry_methods: Iterable[str] | None = None,
|
|
344
|
+
) -> None:
|
|
345
|
+
if not access_token:
|
|
346
|
+
raise ValueError("access_token is required")
|
|
347
|
+
if not phone_number_id:
|
|
348
|
+
raise ValueError("phone_number_id is required")
|
|
349
|
+
if max_retries < 0:
|
|
350
|
+
raise ValueError("max_retries must be >= 0")
|
|
351
|
+
|
|
352
|
+
self.access_token = access_token
|
|
353
|
+
self.phone_number_id = phone_number_id
|
|
354
|
+
self.api_version = api_version
|
|
355
|
+
self.base_url = base_url.rstrip("/")
|
|
356
|
+
self.timeout = timeout
|
|
357
|
+
self.max_retries = max_retries
|
|
358
|
+
self.backoff_factor = backoff_factor
|
|
359
|
+
self.max_backoff = max_backoff
|
|
360
|
+
self.retry_status_codes = frozenset(retry_status_codes or DEFAULT_RETRY_STATUS_CODES)
|
|
361
|
+
self.retry_methods = _normalize_methods(retry_methods)
|
|
362
|
+
|
|
363
|
+
self._owns_client = http_client is None
|
|
364
|
+
self._client = http_client or httpx.AsyncClient(
|
|
365
|
+
base_url=f"{self.base_url}/{self.api_version}",
|
|
366
|
+
timeout=self.timeout,
|
|
367
|
+
headers={
|
|
368
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
369
|
+
"Content-Type": "application/json",
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def aclose(self) -> None:
|
|
374
|
+
if self._owns_client:
|
|
375
|
+
await self._client.aclose()
|
|
376
|
+
|
|
377
|
+
async def __aenter__(self) -> "AsyncWhatsAppClient":
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
381
|
+
await self.aclose()
|
|
382
|
+
|
|
383
|
+
async def send_text(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
to: str,
|
|
387
|
+
body: str,
|
|
388
|
+
preview_url: bool = False,
|
|
389
|
+
context_message_id: str | None = None,
|
|
390
|
+
) -> SendMessageResponse:
|
|
391
|
+
payload: dict[str, Any] = {
|
|
392
|
+
"messaging_product": "whatsapp",
|
|
393
|
+
"to": to,
|
|
394
|
+
"type": "text",
|
|
395
|
+
"text": {"body": body, "preview_url": preview_url},
|
|
396
|
+
}
|
|
397
|
+
if context_message_id:
|
|
398
|
+
payload["context"] = {"message_id": context_message_id}
|
|
399
|
+
data = await self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
400
|
+
return SendMessageResponse.model_validate(data)
|
|
401
|
+
|
|
402
|
+
async def send_template(
|
|
403
|
+
self,
|
|
404
|
+
*,
|
|
405
|
+
to: str,
|
|
406
|
+
name: str,
|
|
407
|
+
language_code: str = "pt_BR",
|
|
408
|
+
components: list[dict[str, Any]] | None = None,
|
|
409
|
+
) -> SendMessageResponse:
|
|
410
|
+
template: dict[str, Any] = {"name": name, "language": {"code": language_code}}
|
|
411
|
+
if components:
|
|
412
|
+
template["components"] = components
|
|
413
|
+
|
|
414
|
+
payload = {
|
|
415
|
+
"messaging_product": "whatsapp",
|
|
416
|
+
"to": to,
|
|
417
|
+
"type": "template",
|
|
418
|
+
"template": template,
|
|
419
|
+
}
|
|
420
|
+
data = await self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
421
|
+
return SendMessageResponse.model_validate(data)
|
|
422
|
+
|
|
423
|
+
async def send_media(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
to: str,
|
|
427
|
+
media_type: str,
|
|
428
|
+
media_id: str | None = None,
|
|
429
|
+
link: str | None = None,
|
|
430
|
+
caption: str | None = None,
|
|
431
|
+
filename: str | None = None,
|
|
432
|
+
) -> SendMessageResponse:
|
|
433
|
+
media_payload = _build_media_payload(
|
|
434
|
+
media_id=media_id,
|
|
435
|
+
link=link,
|
|
436
|
+
caption=caption,
|
|
437
|
+
filename=filename,
|
|
438
|
+
)
|
|
439
|
+
payload = {
|
|
440
|
+
"messaging_product": "whatsapp",
|
|
441
|
+
"to": to,
|
|
442
|
+
"type": media_type,
|
|
443
|
+
media_type: media_payload,
|
|
444
|
+
}
|
|
445
|
+
data = await self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
446
|
+
return SendMessageResponse.model_validate(data)
|
|
447
|
+
|
|
448
|
+
async def upload_media(
|
|
449
|
+
self,
|
|
450
|
+
*,
|
|
451
|
+
file_path: str | Path,
|
|
452
|
+
mime_type: str | None = None,
|
|
453
|
+
filename: str | None = None,
|
|
454
|
+
) -> MediaUploadResponse:
|
|
455
|
+
path = Path(file_path)
|
|
456
|
+
if not path.exists():
|
|
457
|
+
raise FileNotFoundError(f"file not found: {path}")
|
|
458
|
+
|
|
459
|
+
guessed_mime, _ = mimetypes.guess_type(str(path))
|
|
460
|
+
final_mime = mime_type or guessed_mime or "application/octet-stream"
|
|
461
|
+
final_name = filename or path.name
|
|
462
|
+
|
|
463
|
+
with path.open("rb") as file_handle:
|
|
464
|
+
files = {
|
|
465
|
+
"file": (final_name, file_handle, final_mime),
|
|
466
|
+
"messaging_product": (None, "whatsapp"),
|
|
467
|
+
}
|
|
468
|
+
data = await self._request(
|
|
469
|
+
"POST",
|
|
470
|
+
f"/{self.phone_number_id}/media",
|
|
471
|
+
files=files,
|
|
472
|
+
content_type_json=False,
|
|
473
|
+
)
|
|
474
|
+
return MediaUploadResponse.model_validate(data)
|
|
475
|
+
|
|
476
|
+
async def get_media(self, *, media_id: str) -> MediaInfoResponse:
|
|
477
|
+
data = await self._request("GET", f"/{media_id}")
|
|
478
|
+
return MediaInfoResponse.model_validate(data)
|
|
479
|
+
|
|
480
|
+
async def mark_as_read(self, *, message_id: str) -> MarkAsReadResponse:
|
|
481
|
+
payload = {
|
|
482
|
+
"messaging_product": "whatsapp",
|
|
483
|
+
"status": "read",
|
|
484
|
+
"message_id": message_id,
|
|
485
|
+
}
|
|
486
|
+
data = await self._request("POST", f"/{self.phone_number_id}/messages", json=payload)
|
|
487
|
+
return MarkAsReadResponse.model_validate(data)
|
|
488
|
+
|
|
489
|
+
async def _request(
|
|
490
|
+
self,
|
|
491
|
+
method: str,
|
|
492
|
+
path: str,
|
|
493
|
+
*,
|
|
494
|
+
json: dict[str, Any] | None = None,
|
|
495
|
+
files: dict[str, Any] | None = None,
|
|
496
|
+
content_type_json: bool = True,
|
|
497
|
+
) -> dict[str, Any]:
|
|
498
|
+
headers = None
|
|
499
|
+
if not content_type_json:
|
|
500
|
+
headers = {"Authorization": f"Bearer {self.access_token}"}
|
|
501
|
+
|
|
502
|
+
attempt = 0
|
|
503
|
+
can_retry_method = self._should_retry_method(method)
|
|
504
|
+
|
|
505
|
+
while True:
|
|
506
|
+
try:
|
|
507
|
+
response = await self._client.request(
|
|
508
|
+
method=method,
|
|
509
|
+
url=path,
|
|
510
|
+
json=json,
|
|
511
|
+
files=files,
|
|
512
|
+
headers=headers,
|
|
513
|
+
)
|
|
514
|
+
except httpx.HTTPError as exc:
|
|
515
|
+
if not can_retry_method or attempt >= self.max_retries:
|
|
516
|
+
raise WhatsAppAPIError(message=f"HTTP communication error: {exc}") from exc
|
|
517
|
+
delay = self._get_retry_delay(attempt, None)
|
|
518
|
+
if delay > 0:
|
|
519
|
+
await asyncio.sleep(delay)
|
|
520
|
+
attempt += 1
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
if (
|
|
524
|
+
can_retry_method
|
|
525
|
+
and attempt < self.max_retries
|
|
526
|
+
and self._should_retry_response(response)
|
|
527
|
+
):
|
|
528
|
+
delay = self._get_retry_delay(attempt, response.headers.get("Retry-After"))
|
|
529
|
+
if delay > 0:
|
|
530
|
+
await asyncio.sleep(delay)
|
|
531
|
+
attempt += 1
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
return _parse_json_response(response)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class WhatsAppAPIError(Exception):
|
|
8
|
+
message: str
|
|
9
|
+
status_code: int | None = None
|
|
10
|
+
error_type: str | None = None
|
|
11
|
+
code: int | None = None
|
|
12
|
+
error_subcode: int | None = None
|
|
13
|
+
fbtrace_id: str | None = None
|
|
14
|
+
details: dict | None = None
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
parts = [self.message]
|
|
18
|
+
if self.status_code is not None:
|
|
19
|
+
parts.append(f"status={self.status_code}")
|
|
20
|
+
if self.code is not None:
|
|
21
|
+
parts.append(f"code={self.code}")
|
|
22
|
+
if self.error_subcode is not None:
|
|
23
|
+
parts.append(f"subcode={self.error_subcode}")
|
|
24
|
+
if self.error_type:
|
|
25
|
+
parts.append(f"type={self.error_type}")
|
|
26
|
+
return " | ".join(parts)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseResponseModel(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="allow")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Contact(BaseResponseModel):
|
|
11
|
+
input: str | None = None
|
|
12
|
+
wa_id: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Message(BaseResponseModel):
|
|
16
|
+
id: str
|
|
17
|
+
message_status: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SendMessageResponse(BaseResponseModel):
|
|
21
|
+
messaging_product: str = "whatsapp"
|
|
22
|
+
contacts: list[Contact] = Field(default_factory=list)
|
|
23
|
+
messages: list[Message] = Field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MediaUploadResponse(BaseResponseModel):
|
|
27
|
+
id: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MediaInfoResponse(BaseResponseModel):
|
|
31
|
+
id: str | None = None
|
|
32
|
+
messaging_product: str | None = None
|
|
33
|
+
url: str | None = None
|
|
34
|
+
mime_type: str | None = None
|
|
35
|
+
sha256: str | None = None
|
|
36
|
+
file_size: int | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MarkAsReadResponse(BaseResponseModel):
|
|
40
|
+
success: bool = False
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def verify_webhook_challenge(
|
|
8
|
+
*,
|
|
9
|
+
mode: str | None,
|
|
10
|
+
token: str | None,
|
|
11
|
+
challenge: str | None,
|
|
12
|
+
verify_token: str,
|
|
13
|
+
) -> tuple[bool, str]:
|
|
14
|
+
if mode == "subscribe" and token == verify_token and challenge is not None:
|
|
15
|
+
return True, challenge
|
|
16
|
+
return False, ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_webhook_signature(
|
|
20
|
+
*,
|
|
21
|
+
app_secret: str,
|
|
22
|
+
raw_body: bytes,
|
|
23
|
+
x_hub_signature_256: str,
|
|
24
|
+
) -> bool:
|
|
25
|
+
if not x_hub_signature_256.startswith("sha256="):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
expected = hmac.new(
|
|
29
|
+
app_secret.encode("utf-8"),
|
|
30
|
+
raw_body,
|
|
31
|
+
hashlib.sha256,
|
|
32
|
+
).hexdigest()
|
|
33
|
+
received = x_hub_signature_256.split("=", 1)[1]
|
|
34
|
+
return hmac.compare_digest(expected, received)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whatsapp-cloud-api-client
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Biblioteca Python para integração com a API Oficial do WhatsApp (Cloud API).
|
|
5
|
+
Author: Seu Nome
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/seu-usuario/whatsapp-cloud-api-client
|
|
8
|
+
Project-URL: Documentation, https://github.com/seu-usuario/whatsapp-cloud-api-client#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/seu-usuario/whatsapp-cloud-api-client/issues
|
|
10
|
+
Keywords: whatsapp,whatsapp cloud api,meta,messaging,python
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
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
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
23
|
+
Requires-Dist: pydantic<3.0.0,>=2.7.0
|
|
24
|
+
Requires-Dist: eval-type-backport>=0.2.0; python_version < "3.10"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest<9.0.0,>=8.2.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio<1.0.0,>=0.23.7; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# WhatsApp Cloud API Client (Python)
|
|
30
|
+
|
|
31
|
+
Biblioteca Python para integracao com a API Oficial do WhatsApp (Cloud API), com foco em simplicidade de uso em apps.
|
|
32
|
+
|
|
33
|
+
## Instalacao
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install whatsapp-cloud-api-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Para desenvolvimento local:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e ".[dev]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Uso rapido (sync)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from whatsapp_cloud_api import WhatsAppClient
|
|
49
|
+
|
|
50
|
+
client = WhatsAppClient(
|
|
51
|
+
access_token="SEU_TOKEN",
|
|
52
|
+
phone_number_id="SEU_PHONE_NUMBER_ID",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
resp = client.send_text(
|
|
56
|
+
to="5511999999999",
|
|
57
|
+
body="Ola! Mensagem enviada via Cloud API.",
|
|
58
|
+
)
|
|
59
|
+
print(resp.model_dump())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Uso rapido (async)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
from whatsapp_cloud_api import AsyncWhatsAppClient
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def main() -> None:
|
|
70
|
+
async with AsyncWhatsAppClient(
|
|
71
|
+
access_token="SEU_TOKEN",
|
|
72
|
+
phone_number_id="SEU_PHONE_NUMBER_ID",
|
|
73
|
+
) as client:
|
|
74
|
+
resp = await client.send_text(
|
|
75
|
+
to="5511999999999",
|
|
76
|
+
body="Mensagem async",
|
|
77
|
+
)
|
|
78
|
+
print(resp.model_dump())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
asyncio.run(main())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Modelos tipados (Pydantic)
|
|
85
|
+
|
|
86
|
+
Os metodos retornam modelos Pydantic:
|
|
87
|
+
|
|
88
|
+
- `SendMessageResponse`
|
|
89
|
+
- `MediaUploadResponse`
|
|
90
|
+
- `MediaInfoResponse`
|
|
91
|
+
- `MarkAsReadResponse`
|
|
92
|
+
|
|
93
|
+
## Retry, backoff e rate limit
|
|
94
|
+
|
|
95
|
+
O cliente possui retry configuravel para erros transientes (`429`, `500`, `502`, `503`, `504`).
|
|
96
|
+
|
|
97
|
+
Por padrao, retry roda apenas para `GET` para evitar duplicidade em envio de mensagem (`POST`).
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from whatsapp_cloud_api import WhatsAppClient
|
|
101
|
+
|
|
102
|
+
client = WhatsAppClient(
|
|
103
|
+
access_token="SEU_TOKEN",
|
|
104
|
+
phone_number_id="SEU_PHONE_NUMBER_ID",
|
|
105
|
+
max_retries=3,
|
|
106
|
+
backoff_factor=0.5,
|
|
107
|
+
max_backoff=8.0,
|
|
108
|
+
retry_methods={"GET", "POST"}, # habilite POST se quiser retry em envio
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Se a API retornar `Retry-After`, esse valor sera respeitado.
|
|
113
|
+
|
|
114
|
+
## Funcionalidades implementadas
|
|
115
|
+
|
|
116
|
+
- Envio de mensagem de texto
|
|
117
|
+
- Envio de mensagem template
|
|
118
|
+
- Envio de midia por `media_id` ou `link` (imagem, documento, video, audio, sticker)
|
|
119
|
+
- Upload de midia
|
|
120
|
+
- Marcar mensagem como lida
|
|
121
|
+
- Busca de informacoes de midia
|
|
122
|
+
- Validacao de assinatura de webhook (`X-Hub-Signature-256`)
|
|
123
|
+
- Cliente sincrono e assincrono
|
|
124
|
+
|
|
125
|
+
## Exemplo com Flask (webhook)
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from flask import Flask, request, jsonify
|
|
129
|
+
from whatsapp_cloud_api import verify_webhook_signature, verify_webhook_challenge
|
|
130
|
+
|
|
131
|
+
app = Flask(__name__)
|
|
132
|
+
APP_SECRET = "SEU_APP_SECRET"
|
|
133
|
+
VERIFY_TOKEN = "SEU_VERIFY_TOKEN"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/webhook")
|
|
137
|
+
def webhook_verify():
|
|
138
|
+
ok, challenge = verify_webhook_challenge(
|
|
139
|
+
mode=request.args.get("hub.mode"),
|
|
140
|
+
token=request.args.get("hub.verify_token"),
|
|
141
|
+
challenge=request.args.get("hub.challenge"),
|
|
142
|
+
verify_token=VERIFY_TOKEN,
|
|
143
|
+
)
|
|
144
|
+
if not ok:
|
|
145
|
+
return "forbidden", 403
|
|
146
|
+
return challenge, 200
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.post("/webhook")
|
|
150
|
+
def webhook_receive():
|
|
151
|
+
if not verify_webhook_signature(
|
|
152
|
+
app_secret=APP_SECRET,
|
|
153
|
+
raw_body=request.get_data(),
|
|
154
|
+
x_hub_signature_256=request.headers.get("X-Hub-Signature-256", ""),
|
|
155
|
+
):
|
|
156
|
+
return "invalid signature", 401
|
|
157
|
+
|
|
158
|
+
data = request.get_json(silent=True) or {}
|
|
159
|
+
return jsonify({"ok": True, "received": bool(data)}), 200
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Rodar testes
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## CI e publicacao
|
|
169
|
+
|
|
170
|
+
- CI: `.github/workflows/ci.yml`
|
|
171
|
+
- Criacao de tag/release: `.github/workflows/release.yml`
|
|
172
|
+
- Publicacao PyPI: `.github/workflows/publish.yml`
|
|
173
|
+
|
|
174
|
+
Fluxo recomendado:
|
|
175
|
+
|
|
176
|
+
1. Execute o workflow `Create Release` e informe a tag (ex: `v0.2.0`).
|
|
177
|
+
2. O release publicado dispara `Publish to PyPI`.
|
|
178
|
+
3. Configure Trusted Publisher no PyPI para este repositorio (OIDC), sem token manual.
|
|
179
|
+
|
|
180
|
+
Opcional: se preferir token, adapte o workflow para usar `PYPI_API_TOKEN`.
|
|
181
|
+
|
|
182
|
+
## Licenca
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
whatsapp_cloud_api/__init__.py,sha256=5f7TuE8zrUgyhGEZLIx6D6OIjMkYQEJk5ZviBtdBHB0,547
|
|
2
|
+
whatsapp_cloud_api/client.py,sha256=zn1hHqNp4DJSVf_u9FxLaZgrBmL04to-tMz5Avv-4JM,17568
|
|
3
|
+
whatsapp_cloud_api/exceptions.py,sha256=C7G-wWYcSS_WhK0U1Xh9ZEDjynU3gkVod1Aqkdsk5k0,787
|
|
4
|
+
whatsapp_cloud_api/models.py,sha256=zLdPVuQPVCUJ326gn6bLJr33IoRwQaKrsC79kIbrlXw,922
|
|
5
|
+
whatsapp_cloud_api/webhook.py,sha256=lV24A1Kh96k2Ep2UBMGEbWDx6KfBBbE_pw81dHG2myU,774
|
|
6
|
+
whatsapp_cloud_api_client-0.2.0.dist-info/METADATA,sha256=Jnkc-9zXXxPsgWljVj786r4EL7EiPgwRN9MarDP6qOM,4944
|
|
7
|
+
whatsapp_cloud_api_client-0.2.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
8
|
+
whatsapp_cloud_api_client-0.2.0.dist-info/top_level.txt,sha256=2Zz2K8CKiOtHiZE3M5qAHpHd8KF8a5kpJbo2FnB5DpU,19
|
|
9
|
+
whatsapp_cloud_api_client-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
whatsapp_cloud_api
|