rmyndharis-openwa 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.
- rmyndharis_openwa-0.1.0/PKG-INFO +94 -0
- rmyndharis_openwa-0.1.0/README.md +78 -0
- rmyndharis_openwa-0.1.0/openwa/__init__.py +49 -0
- rmyndharis_openwa-0.1.0/openwa/_http.py +115 -0
- rmyndharis_openwa-0.1.0/openwa/client.py +148 -0
- rmyndharis_openwa-0.1.0/openwa/errors.py +99 -0
- rmyndharis_openwa-0.1.0/openwa/py.typed +0 -0
- rmyndharis_openwa-0.1.0/openwa/resources/__init__.py +35 -0
- rmyndharis_openwa-0.1.0/openwa/resources/catalog.py +58 -0
- rmyndharis_openwa-0.1.0/openwa/resources/channels.py +41 -0
- rmyndharis_openwa-0.1.0/openwa/resources/chats.py +46 -0
- rmyndharis_openwa-0.1.0/openwa/resources/contacts.py +53 -0
- rmyndharis_openwa-0.1.0/openwa/resources/groups.py +87 -0
- rmyndharis_openwa-0.1.0/openwa/resources/health.py +27 -0
- rmyndharis_openwa-0.1.0/openwa/resources/labels.py +40 -0
- rmyndharis_openwa-0.1.0/openwa/resources/messages.py +109 -0
- rmyndharis_openwa-0.1.0/openwa/resources/py.typed +0 -0
- rmyndharis_openwa-0.1.0/openwa/resources/sessions.py +56 -0
- rmyndharis_openwa-0.1.0/openwa/resources/status.py +38 -0
- rmyndharis_openwa-0.1.0/openwa/resources/templates.py +34 -0
- rmyndharis_openwa-0.1.0/openwa/resources/webhooks.py +41 -0
- rmyndharis_openwa-0.1.0/openwa/types.py +673 -0
- rmyndharis_openwa-0.1.0/pyproject.toml +40 -0
- rmyndharis_openwa-0.1.0/rmyndharis_openwa.egg-info/PKG-INFO +94 -0
- rmyndharis_openwa-0.1.0/rmyndharis_openwa.egg-info/SOURCES.txt +28 -0
- rmyndharis_openwa-0.1.0/rmyndharis_openwa.egg-info/dependency_links.txt +1 -0
- rmyndharis_openwa-0.1.0/rmyndharis_openwa.egg-info/requires.txt +5 -0
- rmyndharis_openwa-0.1.0/rmyndharis_openwa.egg-info/top_level.txt +1 -0
- rmyndharis_openwa-0.1.0/setup.cfg +4 -0
- rmyndharis_openwa-0.1.0/tests/test_sdk.py +499 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rmyndharis-openwa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for OpenWA WhatsApp API Gateway
|
|
5
|
+
Author: OpenWA Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rmyndharis/OpenWA
|
|
8
|
+
Project-URL: Repository, https://github.com/rmyndharis/OpenWA
|
|
9
|
+
Keywords: whatsapp,api,sdk,openwa,gateway
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx<1.0,>=0.25.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# rmyndharis-openwa
|
|
18
|
+
|
|
19
|
+
Official Python SDK for the [OpenWA](https://github.com/rmyndharis/OpenWA) WhatsApp API Gateway.
|
|
20
|
+
|
|
21
|
+
A synchronous client built on [httpx](https://www.python-httpx.org/), with bundled type hints (PEP 561).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
> **Not yet published to PyPI.** Until the first release, install from a checkout
|
|
26
|
+
> of the repo (`cd sdk/python && pip install -e .`) or from git.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install rmyndharis-openwa # once published
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.9+. The importable module is `openwa`.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from openwa import OpenWAClient
|
|
38
|
+
|
|
39
|
+
client = OpenWAClient(
|
|
40
|
+
base_url="https://your-gateway.example.com",
|
|
41
|
+
api_key="owa_k1_…",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
client.sessions.start("my-session")
|
|
45
|
+
|
|
46
|
+
result = client.messages.send_text("my-session", {
|
|
47
|
+
"chatId": "628123456789@c.us",
|
|
48
|
+
"text": "Hello from the OpenWA Python SDK!",
|
|
49
|
+
})
|
|
50
|
+
print(result["messageId"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The client is also a context manager (it closes the underlying connection pool on exit):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
with OpenWAClient(base_url="…", api_key="…") as client:
|
|
57
|
+
client.messages.send_text("my-session", {"chatId": "…@c.us", "text": "hi"})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For tests, pass an httpx transport — no global monkey-patching required:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import httpx
|
|
64
|
+
client = OpenWAClient(base_url="…", api_key="…", transport=httpx.MockTransport(handler))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Errors
|
|
68
|
+
|
|
69
|
+
A non-2xx response raises a typed `OpenWAApiError` subclass — `OpenWAAuthError` (401),
|
|
70
|
+
`OpenWAForbiddenError` (403), `OpenWANotFoundError` (404), `OpenWAConflictError` (409),
|
|
71
|
+
`OpenWARateLimitError` (429), `OpenWANotImplementedError` (501) — each carrying `.status`
|
|
72
|
+
and the parsed `.body`. A timeout raises `OpenWATimeoutError`.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from openwa import OpenWANotFoundError
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
client.sessions.get("missing")
|
|
79
|
+
except OpenWANotFoundError as e:
|
|
80
|
+
print(e.status) # 404
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- **Use HTTPS in production** — the API key is sent as `X-API-Key` and is bearer-equivalent.
|
|
86
|
+
- The SDK does **not** retry, and **never follows redirects** (so the key is never re-sent to
|
|
87
|
+
a redirect target). Path segments are percent-encoded; a base-URL path prefix (e.g. behind a
|
|
88
|
+
reverse proxy) is preserved.
|
|
89
|
+
- Escape hatch for endpoints the SDK does not wrap:
|
|
90
|
+
`client.request(method, path, query=…, body=…)`.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rmyndharis-openwa
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [OpenWA](https://github.com/rmyndharis/OpenWA) WhatsApp API Gateway.
|
|
4
|
+
|
|
5
|
+
A synchronous client built on [httpx](https://www.python-httpx.org/), with bundled type hints (PEP 561).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
> **Not yet published to PyPI.** Until the first release, install from a checkout
|
|
10
|
+
> of the repo (`cd sdk/python && pip install -e .`) or from git.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install rmyndharis-openwa # once published
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+. The importable module is `openwa`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from openwa import OpenWAClient
|
|
22
|
+
|
|
23
|
+
client = OpenWAClient(
|
|
24
|
+
base_url="https://your-gateway.example.com",
|
|
25
|
+
api_key="owa_k1_…",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
client.sessions.start("my-session")
|
|
29
|
+
|
|
30
|
+
result = client.messages.send_text("my-session", {
|
|
31
|
+
"chatId": "628123456789@c.us",
|
|
32
|
+
"text": "Hello from the OpenWA Python SDK!",
|
|
33
|
+
})
|
|
34
|
+
print(result["messageId"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The client is also a context manager (it closes the underlying connection pool on exit):
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
with OpenWAClient(base_url="…", api_key="…") as client:
|
|
41
|
+
client.messages.send_text("my-session", {"chatId": "…@c.us", "text": "hi"})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For tests, pass an httpx transport — no global monkey-patching required:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import httpx
|
|
48
|
+
client = OpenWAClient(base_url="…", api_key="…", transport=httpx.MockTransport(handler))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Errors
|
|
52
|
+
|
|
53
|
+
A non-2xx response raises a typed `OpenWAApiError` subclass — `OpenWAAuthError` (401),
|
|
54
|
+
`OpenWAForbiddenError` (403), `OpenWANotFoundError` (404), `OpenWAConflictError` (409),
|
|
55
|
+
`OpenWARateLimitError` (429), `OpenWANotImplementedError` (501) — each carrying `.status`
|
|
56
|
+
and the parsed `.body`. A timeout raises `OpenWATimeoutError`.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from openwa import OpenWANotFoundError
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
client.sessions.get("missing")
|
|
63
|
+
except OpenWANotFoundError as e:
|
|
64
|
+
print(e.status) # 404
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
|
|
69
|
+
- **Use HTTPS in production** — the API key is sent as `X-API-Key` and is bearer-equivalent.
|
|
70
|
+
- The SDK does **not** retry, and **never follows redirects** (so the key is never re-sent to
|
|
71
|
+
a redirect target). Path segments are percent-encoded; a base-URL path prefix (e.g. behind a
|
|
72
|
+
reverse proxy) is preserved.
|
|
73
|
+
- Escape hatch for endpoints the SDK does not wrap:
|
|
74
|
+
`client.request(method, path, query=…, body=…)`.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenWA Python SDK.
|
|
3
|
+
|
|
4
|
+
Official client library for the OpenWA WhatsApp API Gateway.
|
|
5
|
+
|
|
6
|
+
Example usage::
|
|
7
|
+
|
|
8
|
+
from openwa import OpenWAClient
|
|
9
|
+
|
|
10
|
+
client = OpenWAClient(
|
|
11
|
+
base_url="http://localhost:2785",
|
|
12
|
+
api_key="owa_k1_…",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
client.sessions.start("my-session")
|
|
16
|
+
result = client.messages.send_text("my-session", {
|
|
17
|
+
"chatId": "628123456789@c.us",
|
|
18
|
+
"text": "Hello from the OpenWA Python SDK!",
|
|
19
|
+
})
|
|
20
|
+
print(result["messageId"])
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .client import OpenWAClient
|
|
26
|
+
from .errors import (
|
|
27
|
+
OpenWAApiError,
|
|
28
|
+
OpenWAAuthError,
|
|
29
|
+
OpenWAConflictError,
|
|
30
|
+
OpenWAError,
|
|
31
|
+
OpenWAForbiddenError,
|
|
32
|
+
OpenWANotFoundError,
|
|
33
|
+
OpenWANotImplementedError,
|
|
34
|
+
OpenWARateLimitError,
|
|
35
|
+
OpenWATimeoutError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"OpenWAClient",
|
|
40
|
+
"OpenWAError",
|
|
41
|
+
"OpenWAApiError",
|
|
42
|
+
"OpenWAAuthError",
|
|
43
|
+
"OpenWAForbiddenError",
|
|
44
|
+
"OpenWANotFoundError",
|
|
45
|
+
"OpenWAConflictError",
|
|
46
|
+
"OpenWARateLimitError",
|
|
47
|
+
"OpenWANotImplementedError",
|
|
48
|
+
"OpenWATimeoutError",
|
|
49
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""HTTP transport wrapper for the OpenWA Python SDK.
|
|
2
|
+
|
|
3
|
+
The client never builds a bare :class:`httpx.Client` with a hard-coded
|
|
4
|
+
transport. Instead it accepts an optional ``transport`` (an ``httpx.BaseTransport``)
|
|
5
|
+
that overrides the default. This makes the SDK trivially testable — a test
|
|
6
|
+
passes ``httpx.MockTransport(handler)`` instead of monkey-patching — and lets
|
|
7
|
+
consumers intercept/observability-wrap outbound calls.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import OpenWAApiError, OpenWATimeoutError, classify
|
|
18
|
+
|
|
19
|
+
HttpMethod = str # "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def quote_segment(segment: Any) -> str:
|
|
23
|
+
"""Percent-encode a single path segment so a value containing ``/``, ``#`` or
|
|
24
|
+
``?`` can't break out of its path position. WhatsApp-id characters that are
|
|
25
|
+
already path-safe (``@``, ``:``, ``+``) are kept readable.
|
|
26
|
+
"""
|
|
27
|
+
return quote(str(segment), safe="@:+")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_url(base_url: str, path: str, query: Mapping[str, Any] | None = None) -> str:
|
|
31
|
+
"""Build a URL, serializing query params and skipping ``None`` values."""
|
|
32
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
33
|
+
if not query:
|
|
34
|
+
return url
|
|
35
|
+
|
|
36
|
+
def _serialize(v: Any) -> str:
|
|
37
|
+
# Booleans must be lowercase: the backend reads query flags as `=== 'true'`,
|
|
38
|
+
# so Python's default str(True) == 'True' would be silently ignored.
|
|
39
|
+
if v is True:
|
|
40
|
+
return "true"
|
|
41
|
+
if v is False:
|
|
42
|
+
return "false"
|
|
43
|
+
return str(v)
|
|
44
|
+
|
|
45
|
+
params = {k: _serialize(v) for k, v in query.items() if v is not None}
|
|
46
|
+
if not params:
|
|
47
|
+
return url
|
|
48
|
+
req = httpx.Request("GET", url, params=params)
|
|
49
|
+
# httpx.Request already encoded params into the URL string.
|
|
50
|
+
return str(req.url)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HttpExecutor:
|
|
54
|
+
"""Owns the :class:`httpx.Client` and performs JSON requests.
|
|
55
|
+
|
|
56
|
+
Constructed once per :class:`OpenWAClient`; the transport is taken from
|
|
57
|
+
the client config so all requests share one connection pool.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
base_url: str,
|
|
63
|
+
api_key: str,
|
|
64
|
+
timeout: float = 30.0,
|
|
65
|
+
default_headers: Mapping[str, str] | None = None,
|
|
66
|
+
transport: httpx.BaseTransport | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
# Caller-supplied default headers are applied FIRST so the auth/JSON
|
|
69
|
+
# headers below always win and can never be clobbered (mirrors the JS SDK).
|
|
70
|
+
headers: dict[str, str] = {}
|
|
71
|
+
if default_headers:
|
|
72
|
+
headers.update(default_headers)
|
|
73
|
+
headers["Content-Type"] = "application/json"
|
|
74
|
+
headers["X-API-Key"] = api_key
|
|
75
|
+
client_kwargs: dict[str, Any] = {
|
|
76
|
+
"base_url": base_url.rstrip("/"),
|
|
77
|
+
"headers": headers,
|
|
78
|
+
"timeout": timeout,
|
|
79
|
+
# Never auto-follow redirects: doing so would re-send the X-API-Key
|
|
80
|
+
# header to the redirect target (potentially a different origin).
|
|
81
|
+
# (This is also httpx's default; set explicitly so it can't regress.)
|
|
82
|
+
"follow_redirects": False,
|
|
83
|
+
}
|
|
84
|
+
if transport is not None:
|
|
85
|
+
client_kwargs["transport"] = transport
|
|
86
|
+
self._client = httpx.Client(**client_kwargs)
|
|
87
|
+
self._timeout = timeout
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
self._client.close()
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> "HttpExecutor":
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, *exc: Any) -> None:
|
|
96
|
+
self.close()
|
|
97
|
+
|
|
98
|
+
def request(self, method: HttpMethod, path: str, *, query: Mapping[str, Any] | None = None, body: Any = None) -> Any:
|
|
99
|
+
"""Perform one request and return the parsed JSON (or ``None`` for 204)."""
|
|
100
|
+
url = build_url("", path, query)
|
|
101
|
+
try:
|
|
102
|
+
res = self._client.request(method, url, json=body if body is not None else None)
|
|
103
|
+
except httpx.TimeoutException as e:
|
|
104
|
+
raise OpenWATimeoutError(self._timeout) from e
|
|
105
|
+
if res.status_code >= 400:
|
|
106
|
+
context = f"{method} {path}"
|
|
107
|
+
raise OpenWAApiError.from_response(res.status_code, res.text, context)
|
|
108
|
+
if res.status_code == 204 or not res.content:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return res.json()
|
|
112
|
+
except ValueError:
|
|
113
|
+
# A 2xx body that isn't JSON surfaces as text rather than a raw
|
|
114
|
+
# JSONDecodeError (mirrors the JS and PHP transports).
|
|
115
|
+
return res.text
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""OpenWA Python SDK — client core.
|
|
2
|
+
|
|
3
|
+
The :class:`OpenWAClient` is the single entry point. It owns an
|
|
4
|
+
:class:`HttpExecutor` (which wraps :class:`httpx.Client` with an injectable
|
|
5
|
+
transport) and exposes domain resources as properties::
|
|
6
|
+
|
|
7
|
+
from openwa import OpenWAClient
|
|
8
|
+
|
|
9
|
+
client = OpenWAClient(
|
|
10
|
+
base_url="http://localhost:2785",
|
|
11
|
+
api_key="owa_k1_…",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
client.sessions.start("my-session")
|
|
15
|
+
client.messages.send_text("my-session", {
|
|
16
|
+
"chatId": "628123456789@c.us",
|
|
17
|
+
"text": "Hello from the OpenWA SDK!",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
Pass ``transport=httpx.MockTransport(handler)`` for testability — no global
|
|
21
|
+
monkey-patching required.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
from typing import Any, Mapping
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from ._http import HttpExecutor, HttpMethod
|
|
32
|
+
from .resources import (
|
|
33
|
+
CatalogResource,
|
|
34
|
+
ChannelsResource,
|
|
35
|
+
ChatsResource,
|
|
36
|
+
ContactsResource,
|
|
37
|
+
GroupsResource,
|
|
38
|
+
HealthResource,
|
|
39
|
+
LabelsResource,
|
|
40
|
+
MessagesResource,
|
|
41
|
+
SessionsResource,
|
|
42
|
+
StatusResource,
|
|
43
|
+
TemplatesResource,
|
|
44
|
+
WebhooksResource,
|
|
45
|
+
)
|
|
46
|
+
from .types import AuthValidateResponse
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OpenWAClient:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str,
|
|
53
|
+
api_key: str,
|
|
54
|
+
*,
|
|
55
|
+
timeout: float = 30.0,
|
|
56
|
+
default_headers: Mapping[str, str] | None = None,
|
|
57
|
+
transport: httpx.BaseTransport | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if not base_url:
|
|
60
|
+
raise ValueError("OpenWAClient: base_url is required")
|
|
61
|
+
if not api_key:
|
|
62
|
+
raise ValueError("OpenWAClient: api_key is required")
|
|
63
|
+
self._http = HttpExecutor(
|
|
64
|
+
base_url=base_url,
|
|
65
|
+
api_key=api_key,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
default_headers=default_headers,
|
|
68
|
+
transport=transport,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ── Resources ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def sessions(self) -> SessionsResource:
|
|
75
|
+
return SessionsResource(self._http)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def messages(self) -> MessagesResource:
|
|
79
|
+
return MessagesResource(self._http)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def contacts(self) -> ContactsResource:
|
|
83
|
+
return ContactsResource(self._http)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def groups(self) -> GroupsResource:
|
|
87
|
+
return GroupsResource(self._http)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def webhooks(self) -> WebhooksResource:
|
|
91
|
+
return WebhooksResource(self._http)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def chats(self) -> ChatsResource:
|
|
95
|
+
return ChatsResource(self._http)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def status(self) -> StatusResource:
|
|
99
|
+
return StatusResource(self._http)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def health(self) -> HealthResource:
|
|
103
|
+
return HealthResource(self._http)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def labels(self) -> LabelsResource:
|
|
107
|
+
return LabelsResource(self._http)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def channels(self) -> ChannelsResource:
|
|
111
|
+
return ChannelsResource(self._http)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def catalog(self) -> CatalogResource:
|
|
115
|
+
return CatalogResource(self._http)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def templates(self) -> TemplatesResource:
|
|
119
|
+
return TemplatesResource(self._http)
|
|
120
|
+
|
|
121
|
+
# ── Auth ─────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def auth(self) -> AuthValidateResponse:
|
|
124
|
+
return self._http.request("POST", "/api/auth/validate")
|
|
125
|
+
|
|
126
|
+
# ── Raw request escape hatch ─────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def request(
|
|
129
|
+
self,
|
|
130
|
+
method: str,
|
|
131
|
+
path: str,
|
|
132
|
+
*,
|
|
133
|
+
query: Mapping[str, Any] | None = None,
|
|
134
|
+
body: Any = None,
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""Issue a raw request against the API (advanced use). ``path`` begins with ``/``."""
|
|
137
|
+
return self._http.request(method, path, query=query, body=body)
|
|
138
|
+
|
|
139
|
+
# ── Lifecycle ────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
self._http.close()
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "OpenWAClient":
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *exc: Any) -> None:
|
|
148
|
+
self.close()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Typed error hierarchy for the OpenWA Python SDK.
|
|
2
|
+
|
|
3
|
+
The OpenWA API returns NestJS-default errors of the shape::
|
|
4
|
+
|
|
5
|
+
{"statusCode": int, "message": str | list[str], "error": str}
|
|
6
|
+
|
|
7
|
+
This module maps that to a typed, ergonomic error tree so callers can
|
|
8
|
+
``isinstance``-check or branch on ``.status``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenWAError(Exception):
|
|
17
|
+
"""Base class for every error raised by the SDK."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenWAApiError(OpenWAError):
|
|
21
|
+
"""Raised when the API responds with a non-2xx status.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
status: HTTP status code.
|
|
25
|
+
body: Parsed JSON body if available, otherwise the raw text.
|
|
26
|
+
error_kind: Value of the ``error`` field in the NestJS envelope.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, status: int, body: Any = None, error_kind: str | None = None) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status = status
|
|
32
|
+
self.body = body
|
|
33
|
+
self.error_kind = error_kind
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_response(cls, status_code: int, text: str, context: str) -> "OpenWAApiError":
|
|
37
|
+
import json
|
|
38
|
+
|
|
39
|
+
body: Any = None
|
|
40
|
+
if text:
|
|
41
|
+
try:
|
|
42
|
+
body = json.loads(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
body = text
|
|
45
|
+
envelope = body if isinstance(body, dict) and "statusCode" in body else None
|
|
46
|
+
raw_message = envelope.get("message") if envelope else body
|
|
47
|
+
if isinstance(raw_message, list):
|
|
48
|
+
message_text = ", ".join(str(m) for m in raw_message)
|
|
49
|
+
elif isinstance(raw_message, str):
|
|
50
|
+
message_text = raw_message
|
|
51
|
+
else:
|
|
52
|
+
message_text = str(raw_message)
|
|
53
|
+
message = f"OpenWA API {status_code} — {context}: {message_text}"
|
|
54
|
+
return classify(status_code, message, body, envelope.get("error") if envelope else None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OpenWAAuthError(OpenWAApiError):
|
|
58
|
+
"""401 Unauthorized — missing or invalid API key."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OpenWAForbiddenError(OpenWAApiError):
|
|
62
|
+
"""403 Forbidden — insufficient role."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OpenWANotFoundError(OpenWAApiError):
|
|
66
|
+
"""404 Not Found."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenWAConflictError(OpenWAApiError):
|
|
70
|
+
"""409 Conflict — typically an engine-not-ready condition."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OpenWARateLimitError(OpenWAApiError):
|
|
74
|
+
"""429 Too Many Requests."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OpenWANotImplementedError(OpenWAApiError):
|
|
78
|
+
"""501 Not Implemented — the active engine does not support this operation."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OpenWATimeoutError(OpenWAError):
|
|
82
|
+
"""Raised when a request exceeds the configured timeout."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, timeout: float) -> None:
|
|
85
|
+
super().__init__(f"Request timed out after {timeout}s")
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def classify(status: int, message: str, body: Any, error_kind: str | None) -> OpenWAApiError:
|
|
90
|
+
"""Pick the most specific :class:`OpenWAApiError` subclass for a status."""
|
|
91
|
+
cls = {
|
|
92
|
+
401: OpenWAAuthError,
|
|
93
|
+
403: OpenWAForbiddenError,
|
|
94
|
+
404: OpenWANotFoundError,
|
|
95
|
+
409: OpenWAConflictError,
|
|
96
|
+
429: OpenWARateLimitError,
|
|
97
|
+
501: OpenWANotImplementedError,
|
|
98
|
+
}.get(status, OpenWAApiError)
|
|
99
|
+
return cls(message, status, body, error_kind)
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Resource modules for the OpenWA Python SDK.
|
|
2
|
+
|
|
3
|
+
Each module defines a small ``_*Resource`` class whose methods map 1:1 to an
|
|
4
|
+
API path group. They are constructed by :class:`openwa.client.OpenWAClient`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .catalog import CatalogResource
|
|
10
|
+
from .channels import ChannelsResource
|
|
11
|
+
from .chats import ChatsResource
|
|
12
|
+
from .contacts import ContactsResource
|
|
13
|
+
from .groups import GroupsResource
|
|
14
|
+
from .health import HealthResource
|
|
15
|
+
from .labels import LabelsResource
|
|
16
|
+
from .messages import MessagesResource
|
|
17
|
+
from .sessions import SessionsResource
|
|
18
|
+
from .status import StatusResource
|
|
19
|
+
from .templates import TemplatesResource
|
|
20
|
+
from .webhooks import WebhooksResource
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CatalogResource",
|
|
24
|
+
"ChannelsResource",
|
|
25
|
+
"ChatsResource",
|
|
26
|
+
"ContactsResource",
|
|
27
|
+
"GroupsResource",
|
|
28
|
+
"HealthResource",
|
|
29
|
+
"LabelsResource",
|
|
30
|
+
"MessagesResource",
|
|
31
|
+
"SessionsResource",
|
|
32
|
+
"StatusResource",
|
|
33
|
+
"TemplatesResource",
|
|
34
|
+
"WebhooksResource",
|
|
35
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Catalog resource — WhatsApp Business catalog, products, and product/catalog sends.
|
|
2
|
+
|
|
3
|
+
Backed by ``src/modules/catalog/catalog.controller.ts``
|
|
4
|
+
(``@Controller('sessions/:sessionId')``). NOTE: the catalog controller is
|
|
5
|
+
mounted under the session root, so catalog reads are ``/catalog...`` while
|
|
6
|
+
product/catalog SENDS share the messages namespace
|
|
7
|
+
(``/messages/send-product``, ``/messages/send-catalog``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from .._http import quote_segment
|
|
15
|
+
from ..types import (
|
|
16
|
+
CatalogInfo,
|
|
17
|
+
CatalogProduct,
|
|
18
|
+
CatalogProductsQuery,
|
|
19
|
+
MessageResponse,
|
|
20
|
+
PaginatedProducts,
|
|
21
|
+
SendCatalogRequest,
|
|
22
|
+
SendProductRequest,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .._http import HttpExecutor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CatalogResource:
|
|
30
|
+
def __init__(self, http: "HttpExecutor") -> None:
|
|
31
|
+
self._http = http
|
|
32
|
+
|
|
33
|
+
def info(self, session_id: str) -> CatalogInfo:
|
|
34
|
+
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/catalog")
|
|
35
|
+
|
|
36
|
+
def products(
|
|
37
|
+
self, session_id: str, query: CatalogProductsQuery | None = None
|
|
38
|
+
) -> PaginatedProducts:
|
|
39
|
+
return self._http.request(
|
|
40
|
+
"GET", f"/api/sessions/{quote_segment(session_id)}/catalog/products", query=query
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def product(self, session_id: str, product_id: str) -> CatalogProduct:
|
|
44
|
+
return self._http.request(
|
|
45
|
+
"GET", f"/api/sessions/{quote_segment(session_id)}/catalog/products/{quote_segment(product_id)}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def send_product(self, session_id: str, body: SendProductRequest) -> MessageResponse:
|
|
49
|
+
"""Send a product message. Requires an OPERATOR-level key. Shares the messages path."""
|
|
50
|
+
return self._http.request(
|
|
51
|
+
"POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-product", body=body
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def send_catalog(self, session_id: str, body: SendCatalogRequest) -> MessageResponse:
|
|
55
|
+
"""Send a catalog link message. Requires an OPERATOR-level key. Shares the messages path."""
|
|
56
|
+
return self._http.request(
|
|
57
|
+
"POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-catalog", body=body
|
|
58
|
+
)
|