waba-sdk 1.0.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.
- waba_sdk/__init__.py +153 -0
- waba_sdk/_http.py +171 -0
- waba_sdk/client.py +508 -0
- waba_sdk/config.py +48 -0
- waba_sdk/errors.py +133 -0
- waba_sdk/integrations/__init__.py +5 -0
- waba_sdk/integrations/fastapi.py +89 -0
- waba_sdk/media/__init__.py +5 -0
- waba_sdk/media/client.py +125 -0
- waba_sdk/messages/__init__.py +159 -0
- waba_sdk/messages/_base.py +64 -0
- waba_sdk/messages/contacts.py +111 -0
- waba_sdk/messages/interactive/__init__.py +44 -0
- waba_sdk/messages/interactive/_base.py +137 -0
- waba_sdk/messages/interactive/buttons.py +57 -0
- waba_sdk/messages/interactive/cta_url.py +26 -0
- waba_sdk/messages/interactive/flow.py +50 -0
- waba_sdk/messages/interactive/list.py +63 -0
- waba_sdk/messages/interactive/location_request.py +23 -0
- waba_sdk/messages/interactive/products.py +100 -0
- waba_sdk/messages/location.py +33 -0
- waba_sdk/messages/media.py +84 -0
- waba_sdk/messages/reaction.py +26 -0
- waba_sdk/messages/template.py +180 -0
- waba_sdk/messages/text.py +25 -0
- waba_sdk/oauth.py +53 -0
- waba_sdk/types.py +27 -0
- waba_sdk/webhook/__init__.py +127 -0
- waba_sdk/webhook/events.py +38 -0
- waba_sdk/webhook/handler.py +182 -0
- waba_sdk/webhook/incoming.py +372 -0
- waba_sdk/webhook/payload.py +64 -0
- waba_sdk/webhook/status.py +61 -0
- waba_sdk-1.0.0.dist-info/METADATA +625 -0
- waba_sdk-1.0.0.dist-info/RECORD +38 -0
- waba_sdk-1.0.0.dist-info/WHEEL +5 -0
- waba_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- waba_sdk-1.0.0.dist-info/top_level.txt +1 -0
waba_sdk/__init__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Async Python SDK for the WhatsApp Business Cloud API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from waba_sdk import WhatsApp
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
async with WhatsApp.from_env() as client:
|
|
10
|
+
await client.send_text("+15551234567", "hello")
|
|
11
|
+
|
|
12
|
+
asyncio.run(main())
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .client import WhatsApp
|
|
16
|
+
from .errors import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
InvalidRequestError,
|
|
19
|
+
MediaError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ServerError,
|
|
22
|
+
WebhookVerificationError,
|
|
23
|
+
WhatsAppError,
|
|
24
|
+
)
|
|
25
|
+
from .media import MediaDownload, MediaInfo
|
|
26
|
+
from .messages import (
|
|
27
|
+
AudioMessage,
|
|
28
|
+
BodyComponent,
|
|
29
|
+
Button,
|
|
30
|
+
ButtonComponent,
|
|
31
|
+
ButtonParameter,
|
|
32
|
+
ButtonsMessage,
|
|
33
|
+
CatalogMessage,
|
|
34
|
+
Contact,
|
|
35
|
+
ContactAddress,
|
|
36
|
+
ContactEmail,
|
|
37
|
+
ContactName,
|
|
38
|
+
ContactOrg,
|
|
39
|
+
ContactPhone,
|
|
40
|
+
ContactsMessage,
|
|
41
|
+
ContactURL,
|
|
42
|
+
Context,
|
|
43
|
+
CTAUrlMessage,
|
|
44
|
+
CurrencyParameter,
|
|
45
|
+
CurrencyValue,
|
|
46
|
+
DateTimeParameter,
|
|
47
|
+
DateTimeValue,
|
|
48
|
+
DocumentHeader,
|
|
49
|
+
DocumentMessage,
|
|
50
|
+
DocumentParameter,
|
|
51
|
+
FlowMessage,
|
|
52
|
+
Header,
|
|
53
|
+
HeaderComponent,
|
|
54
|
+
ImageHeader,
|
|
55
|
+
ImageMessage,
|
|
56
|
+
ImageParameter,
|
|
57
|
+
InteractiveMessage,
|
|
58
|
+
ListMessage,
|
|
59
|
+
ListRow,
|
|
60
|
+
ListSection,
|
|
61
|
+
LocationMessage,
|
|
62
|
+
LocationRequestMessage,
|
|
63
|
+
Message,
|
|
64
|
+
MultiProductMessage,
|
|
65
|
+
OutboundMessage,
|
|
66
|
+
Parameter,
|
|
67
|
+
ProductSection,
|
|
68
|
+
ReactionMessage,
|
|
69
|
+
SingleProductMessage,
|
|
70
|
+
StickerMessage,
|
|
71
|
+
TemplateComponent,
|
|
72
|
+
TemplateMessage,
|
|
73
|
+
TextHeader,
|
|
74
|
+
TextMessage,
|
|
75
|
+
TextParameter,
|
|
76
|
+
VideoHeader,
|
|
77
|
+
VideoMessage,
|
|
78
|
+
VideoParameter,
|
|
79
|
+
)
|
|
80
|
+
from .types import normalize_phone
|
|
81
|
+
|
|
82
|
+
__version__ = "1.0.0"
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"__version__",
|
|
86
|
+
# client
|
|
87
|
+
"WhatsApp",
|
|
88
|
+
"normalize_phone",
|
|
89
|
+
# errors
|
|
90
|
+
"WhatsAppError",
|
|
91
|
+
"AuthenticationError",
|
|
92
|
+
"InvalidRequestError",
|
|
93
|
+
"RateLimitError",
|
|
94
|
+
"ServerError",
|
|
95
|
+
"MediaError",
|
|
96
|
+
"WebhookVerificationError",
|
|
97
|
+
# media value objects
|
|
98
|
+
"MediaInfo",
|
|
99
|
+
"MediaDownload",
|
|
100
|
+
# messages
|
|
101
|
+
"OutboundMessage",
|
|
102
|
+
"Context",
|
|
103
|
+
"Message",
|
|
104
|
+
"TextMessage",
|
|
105
|
+
"ImageMessage",
|
|
106
|
+
"VideoMessage",
|
|
107
|
+
"AudioMessage",
|
|
108
|
+
"DocumentMessage",
|
|
109
|
+
"StickerMessage",
|
|
110
|
+
"LocationMessage",
|
|
111
|
+
"ContactsMessage",
|
|
112
|
+
"Contact",
|
|
113
|
+
"ContactName",
|
|
114
|
+
"ContactPhone",
|
|
115
|
+
"ContactEmail",
|
|
116
|
+
"ContactAddress",
|
|
117
|
+
"ContactURL",
|
|
118
|
+
"ContactOrg",
|
|
119
|
+
"ReactionMessage",
|
|
120
|
+
"TemplateMessage",
|
|
121
|
+
"Parameter",
|
|
122
|
+
"TextParameter",
|
|
123
|
+
"CurrencyParameter",
|
|
124
|
+
"CurrencyValue",
|
|
125
|
+
"DateTimeParameter",
|
|
126
|
+
"DateTimeValue",
|
|
127
|
+
"ImageParameter",
|
|
128
|
+
"VideoParameter",
|
|
129
|
+
"DocumentParameter",
|
|
130
|
+
"HeaderComponent",
|
|
131
|
+
"BodyComponent",
|
|
132
|
+
"ButtonComponent",
|
|
133
|
+
"ButtonParameter",
|
|
134
|
+
"TemplateComponent",
|
|
135
|
+
"InteractiveMessage",
|
|
136
|
+
"Header",
|
|
137
|
+
"TextHeader",
|
|
138
|
+
"ImageHeader",
|
|
139
|
+
"VideoHeader",
|
|
140
|
+
"DocumentHeader",
|
|
141
|
+
"ButtonsMessage",
|
|
142
|
+
"Button",
|
|
143
|
+
"ListMessage",
|
|
144
|
+
"ListSection",
|
|
145
|
+
"ListRow",
|
|
146
|
+
"CTAUrlMessage",
|
|
147
|
+
"FlowMessage",
|
|
148
|
+
"SingleProductMessage",
|
|
149
|
+
"MultiProductMessage",
|
|
150
|
+
"CatalogMessage",
|
|
151
|
+
"ProductSection",
|
|
152
|
+
"LocationRequestMessage",
|
|
153
|
+
]
|
waba_sdk/_http.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Internal HTTP client with lazy session, retries, and Graph error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
from typing import Any, Mapping, Optional
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from .errors import WhatsAppError, build_error
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("waba_sdk.http")
|
|
15
|
+
|
|
16
|
+
_DEFAULT_RETRY_STATUSES = (429, 500, 502, 503, 504)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpClient:
|
|
20
|
+
"""Owns the shared :class:`aiohttp.ClientSession` and HTTP retry logic.
|
|
21
|
+
|
|
22
|
+
The session is created lazily on first request and reused for all
|
|
23
|
+
subsequent calls. Use ``await client.close()`` or ``async with`` to release
|
|
24
|
+
it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
token: str,
|
|
31
|
+
base_url: str,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
retry_statuses: tuple[int, ...] = _DEFAULT_RETRY_STATUSES,
|
|
35
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not token:
|
|
38
|
+
raise ValueError("token is required")
|
|
39
|
+
self._token = token
|
|
40
|
+
self._base_url = base_url.rstrip("/")
|
|
41
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
42
|
+
self._max_retries = max_retries
|
|
43
|
+
self._retry_statuses = retry_statuses
|
|
44
|
+
self._session = session
|
|
45
|
+
self._owns_session = session is None
|
|
46
|
+
self._lock = asyncio.Lock()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def base_url(self) -> str:
|
|
50
|
+
return self._base_url
|
|
51
|
+
|
|
52
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
53
|
+
if self._session is not None and not self._session.closed:
|
|
54
|
+
return self._session
|
|
55
|
+
async with self._lock:
|
|
56
|
+
if self._session is None or self._session.closed:
|
|
57
|
+
connector = aiohttp.TCPConnector(limit=100, ttl_dns_cache=300)
|
|
58
|
+
self._session = aiohttp.ClientSession(
|
|
59
|
+
timeout=self._timeout,
|
|
60
|
+
connector=connector,
|
|
61
|
+
)
|
|
62
|
+
self._owns_session = True
|
|
63
|
+
return self._session
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
if (
|
|
67
|
+
self._session is not None
|
|
68
|
+
and self._owns_session
|
|
69
|
+
and not self._session.closed
|
|
70
|
+
):
|
|
71
|
+
await self._session.close()
|
|
72
|
+
self._session = None
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self) -> "HttpClient":
|
|
75
|
+
await self._ensure_session()
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
79
|
+
await self.close()
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _backoff(attempt: int) -> float:
|
|
83
|
+
return min(0.5 * (2**attempt), 30.0) + random.uniform(0, 0.25)
|
|
84
|
+
|
|
85
|
+
async def request(
|
|
86
|
+
self,
|
|
87
|
+
method: str,
|
|
88
|
+
path: str,
|
|
89
|
+
*,
|
|
90
|
+
json: Optional[Mapping[str, Any]] = None,
|
|
91
|
+
data: Any = None,
|
|
92
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
93
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
94
|
+
absolute_url: Optional[str] = None,
|
|
95
|
+
) -> aiohttp.ClientResponse:
|
|
96
|
+
"""Perform a request and return the open response.
|
|
97
|
+
|
|
98
|
+
Caller is responsible for ``await response.read()`` / ``response.json()``
|
|
99
|
+
and releasing it (use ``async with`` on the return value).
|
|
100
|
+
"""
|
|
101
|
+
session = await self._ensure_session()
|
|
102
|
+
url = absolute_url or f"{self._base_url}{path}"
|
|
103
|
+
merged_headers = {"Authorization": f"Bearer {self._token}"}
|
|
104
|
+
if headers:
|
|
105
|
+
merged_headers.update(headers)
|
|
106
|
+
|
|
107
|
+
attempt = 0
|
|
108
|
+
while True:
|
|
109
|
+
try:
|
|
110
|
+
resp = await session.request(
|
|
111
|
+
method,
|
|
112
|
+
url,
|
|
113
|
+
json=json,
|
|
114
|
+
data=data,
|
|
115
|
+
params=params,
|
|
116
|
+
headers=merged_headers,
|
|
117
|
+
)
|
|
118
|
+
except aiohttp.ClientError as exc:
|
|
119
|
+
if attempt >= self._max_retries:
|
|
120
|
+
raise WhatsAppError(f"network error: {exc}") from exc
|
|
121
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
122
|
+
attempt += 1
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if resp.status in self._retry_statuses and attempt < self._max_retries:
|
|
126
|
+
retry_after_header = resp.headers.get("Retry-After")
|
|
127
|
+
try:
|
|
128
|
+
retry_after = (
|
|
129
|
+
float(retry_after_header) if retry_after_header else 0.0
|
|
130
|
+
)
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
retry_after = 0.0
|
|
133
|
+
wait = retry_after if retry_after > 0 else self._backoff(attempt)
|
|
134
|
+
resp.release()
|
|
135
|
+
await asyncio.sleep(wait)
|
|
136
|
+
attempt += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if resp.status >= 400:
|
|
140
|
+
await self._raise_for_status(resp)
|
|
141
|
+
|
|
142
|
+
return resp
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
async def _raise_for_status(resp: aiohttp.ClientResponse) -> None:
|
|
146
|
+
try:
|
|
147
|
+
data: Any = await resp.json()
|
|
148
|
+
except Exception:
|
|
149
|
+
try:
|
|
150
|
+
data = {"raw": await resp.text()}
|
|
151
|
+
except Exception:
|
|
152
|
+
data = None
|
|
153
|
+
raise build_error(resp.status, data, headers=dict(resp.headers))
|
|
154
|
+
|
|
155
|
+
async def post_json(self, path: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
156
|
+
resp = await self.request("POST", path, json=payload)
|
|
157
|
+
async with resp:
|
|
158
|
+
return await resp.json()
|
|
159
|
+
|
|
160
|
+
async def get_json(
|
|
161
|
+
self,
|
|
162
|
+
path: str,
|
|
163
|
+
*,
|
|
164
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
resp = await self.request("GET", path, params=params)
|
|
167
|
+
async with resp:
|
|
168
|
+
return await resp.json()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["HttpClient"]
|