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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ whatsapp_cloud_api