waapi 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.
- waapi-0.1.0/.github/workflows/publish.yml +40 -0
- waapi-0.1.0/.github/workflows/tests.yml +32 -0
- waapi-0.1.0/.gitignore +14 -0
- waapi-0.1.0/CONTRIBUTING.md +53 -0
- waapi-0.1.0/LICENSE +21 -0
- waapi-0.1.0/PKG-INFO +185 -0
- waapi-0.1.0/README.md +131 -0
- waapi-0.1.0/pyproject.toml +54 -0
- waapi-0.1.0/scripts/sync_actions.py +156 -0
- waapi-0.1.0/src/waapi/__init__.py +33 -0
- waapi-0.1.0/src/waapi/_actions.py +152 -0
- waapi-0.1.0/src/waapi/_generated.py +4094 -0
- waapi-0.1.0/src/waapi/_http.py +97 -0
- waapi-0.1.0/src/waapi/client.py +118 -0
- waapi-0.1.0/src/waapi/exceptions.py +60 -0
- waapi-0.1.0/src/waapi/py.typed +0 -0
- waapi-0.1.0/tests/conftest.py +54 -0
- waapi-0.1.0/tests/test_client.py +214 -0
- waapi-0.1.0/tests/test_generated_actions.py +1265 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes on a v* tag using PyPI Trusted Publishing (OIDC) — no API token is
|
|
4
|
+
# stored in the repository. Configure the publisher once at
|
|
5
|
+
# https://pypi.org/manage/project/waapi/settings/publishing/
|
|
6
|
+
# owner: WaAPIapp repository: waapi-python-sdk
|
|
7
|
+
# workflow: publish.yml environment: pypi
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
tags: ['v*']
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: '3.12'
|
|
21
|
+
- run: pip install build twine
|
|
22
|
+
- run: python -m build
|
|
23
|
+
- run: twine check dist/*
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
needs: build
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment: pypi
|
|
33
|
+
permissions:
|
|
34
|
+
id-token: write
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: pytest -q
|
|
22
|
+
|
|
23
|
+
lint:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
- uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: '3.12'
|
|
30
|
+
- run: pip install -e ".[dev]"
|
|
31
|
+
- run: ruff check .
|
|
32
|
+
- run: mypy src/waapi
|
waapi-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## The method surface is generated, not hand-written
|
|
4
|
+
|
|
5
|
+
The API exposes 122 client actions across 130 paths. The n8n node and the MCP
|
|
6
|
+
tools are generated from `storage/swagger.json`, which is why both stay
|
|
7
|
+
current, while a hand-maintained SDK falls behind — the Laravel SDK named 25 of
|
|
8
|
+
122 actions before its methods were generated.
|
|
9
|
+
|
|
10
|
+
`GenerateSdkMethods` in the proxy repository emits them, with a `python` and a
|
|
11
|
+
`python-async` flavour:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python3 scripts/sync_actions.py ../eazewhatsapp-proxy
|
|
15
|
+
# wrote 122 sync and 122 async methods, and 122 tests
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That overwrites two files whole, and nothing hand-written lives in either:
|
|
19
|
+
|
|
20
|
+
- `src/waapi/_generated.py` — `GeneratedActions` and `GeneratedAsyncActions`
|
|
21
|
+
- `tests/test_generated_actions.py` — one payload test per action
|
|
22
|
+
|
|
23
|
+
The script refuses to write if the two surfaces come out at different sizes or
|
|
24
|
+
if the generator emitted nothing, and it runs `ruff` over its own output, so a
|
|
25
|
+
file marked DO NOT EDIT never needs a human to fix its formatting.
|
|
26
|
+
|
|
27
|
+
Hand-written methods live in `src/waapi/_actions.py`, which composes the
|
|
28
|
+
generated classes in. Add one there only if it cannot come from the spec — the
|
|
29
|
+
instance endpoints are the current example, because they are ordinary REST
|
|
30
|
+
routes rather than client actions. Anything added by hand is one more place a
|
|
31
|
+
future API change has to reach.
|
|
32
|
+
|
|
33
|
+
If you do add one, mirror it in **both** `ActionsMixin` and
|
|
34
|
+
`AsyncActionsMixin` — `test_sync_and_async_expose_the_same_methods` fails
|
|
35
|
+
otherwise, on purpose.
|
|
36
|
+
|
|
37
|
+
## Conventions
|
|
38
|
+
|
|
39
|
+
- Public arguments are `snake_case`; the JSON keys they map to stay
|
|
40
|
+
`camelCase` as the API defines them.
|
|
41
|
+
- Optional arguments default to `None` and are dropped from the payload by
|
|
42
|
+
`prune()`. Do not send explicit nulls.
|
|
43
|
+
- Anything that can be checked without a request (a missing instance id, a
|
|
44
|
+
media call with no source) raises `ValueError` before the call goes out.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
pytest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
No test may touch the network. Use `httpx.MockTransport`.
|
waapi-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) WaAPI <info@waapi.app>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
waapi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: waapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the WaAPI REST API
|
|
5
|
+
Project-URL: Homepage, https://waapi.app
|
|
6
|
+
Project-URL: Documentation, https://waapi.app/docs
|
|
7
|
+
Project-URL: Source, https://github.com/WaAPIapp/waapi-python-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/WaAPIapp/waapi-python-sdk/issues
|
|
9
|
+
Author-email: WaAPI <info@waapi.app>
|
|
10
|
+
License: The MIT License (MIT)
|
|
11
|
+
|
|
12
|
+
Copyright (c) WaAPI <info@waapi.app>
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: api,automation,chatbot,messaging,sdk,waapi
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Topic :: Communications :: Chat
|
|
43
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
44
|
+
Classifier: Typing :: Typed
|
|
45
|
+
Requires-Python: >=3.9
|
|
46
|
+
Requires-Dist: httpx<1.0,>=0.24
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
51
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
52
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# WaAPI Python SDK
|
|
56
|
+
|
|
57
|
+
Official Python client for the [WaAPI](https://waapi.app) REST API — send and
|
|
58
|
+
receive WhatsApp messages, manage chats, groups and channels from Python.
|
|
59
|
+
|
|
60
|
+
[](https://pypi.org/project/waapi/)
|
|
61
|
+
[](https://pypi.org/project/waapi/)
|
|
62
|
+
[](LICENSE)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install waapi
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from waapi import WaAPI
|
|
72
|
+
|
|
73
|
+
client = WaAPI(token="YOUR_API_TOKEN", instance_id=123)
|
|
74
|
+
|
|
75
|
+
client.send_message(
|
|
76
|
+
chat_id="4915112345678@c.us",
|
|
77
|
+
message="Deployment finished.",
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Get a token at [waapi.app/user/api-tokens](https://waapi.app/user/api-tokens)
|
|
82
|
+
and create an instance connected to your number.
|
|
83
|
+
|
|
84
|
+
### The chat ID is the one thing to get right
|
|
85
|
+
|
|
86
|
+
Its suffix decides where the message lands, and a wrong suffix is accepted and
|
|
87
|
+
delivers nothing:
|
|
88
|
+
|
|
89
|
+
| Target | Format |
|
|
90
|
+
|---|---|
|
|
91
|
+
| One person | `4915112345678@c.us` |
|
|
92
|
+
| Group | `123456789-123456789@g.us` |
|
|
93
|
+
| Channel | `123456789@newsletter` |
|
|
94
|
+
|
|
95
|
+
## Async
|
|
96
|
+
|
|
97
|
+
Same method names, awaited:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from waapi import AsyncWaAPI
|
|
101
|
+
|
|
102
|
+
async with AsyncWaAPI(token="YOUR_API_TOKEN", instance_id=123) as client:
|
|
103
|
+
await client.send_message(chat_id="4915112345678@c.us", message="Hi")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Errors
|
|
107
|
+
|
|
108
|
+
A successful HTTP exchange is not proof the message was sent. The API answers
|
|
109
|
+
`200` with `{"status": "error"}` when, for example, the instance is not
|
|
110
|
+
connected — so the SDK raises on that too, rather than handing back a body that
|
|
111
|
+
looks like success.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from waapi import WaAPI, FailedActionError, AuthenticationError, RateLimitError
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
client.send_message(chat_id="4915112345678@c.us", message="Hi")
|
|
118
|
+
except AuthenticationError:
|
|
119
|
+
... # token wrong, expired, or missing scopes
|
|
120
|
+
except RateLimitError as e:
|
|
121
|
+
time.sleep(e.retry_after or 5)
|
|
122
|
+
except FailedActionError as e:
|
|
123
|
+
... # accepted but not carried out — e.response has the detail
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Exception | Raised on |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `AuthenticationError` | HTTP 401, 403 |
|
|
129
|
+
| `NotFoundError` | HTTP 404 |
|
|
130
|
+
| `ValidationError` | HTTP 422 — `.errors` holds the field errors |
|
|
131
|
+
| `RateLimitError` | HTTP 429 — `.retry_after` in seconds when the API sends it |
|
|
132
|
+
| `FailedActionError` | HTTP 400, **and HTTP 200 with `status: error`** |
|
|
133
|
+
| `ServerError` | HTTP 5xx |
|
|
134
|
+
|
|
135
|
+
All inherit from `WaAPIError`.
|
|
136
|
+
|
|
137
|
+
## Coverage
|
|
138
|
+
|
|
139
|
+
All **122 client actions** are wrapped, typed, and available on both clients:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
client.create_group(group_name="Ops", group_participants=["4915112345678@c.us"])
|
|
143
|
+
client.send_media(chat_id="4915112345678@c.us", media_url="https://example.com/report.pdf")
|
|
144
|
+
client.get_contacts()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
They are generated from the same OpenAPI specification the n8n node and the MCP
|
|
148
|
+
tools come from, so they track the API instead of drifting behind it — see
|
|
149
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
150
|
+
|
|
151
|
+
An action added to the API since the last release is still reachable by name:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
client.action("some-new-action", {"chatId": "4915112345678@c.us"})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Configuration
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
WaAPI(
|
|
161
|
+
token="...", # required
|
|
162
|
+
instance_id=123, # optional; per-call instance_id overrides it
|
|
163
|
+
base_url="https://waapi.app/api/v1",
|
|
164
|
+
timeout=30.0,
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Passing `instance_id` to the client keeps single-instance code short. Any call
|
|
169
|
+
can still override it, and a call with neither raises before a request is sent.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
python -m venv .venv && source .venv/bin/activate
|
|
175
|
+
pip install -e ".[dev]"
|
|
176
|
+
pytest
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The suite runs entirely against `httpx.MockTransport` — no network, no token,
|
|
180
|
+
no connected account.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT. Not affiliated with, endorsed or sponsored by WhatsApp LLC or Meta.
|
|
185
|
+
WhatsApp is a trademark of WhatsApp LLC.
|
waapi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# WaAPI Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [WaAPI](https://waapi.app) REST API — send and
|
|
4
|
+
receive WhatsApp messages, manage chats, groups and channels from Python.
|
|
5
|
+
|
|
6
|
+
[](https://pypi.org/project/waapi/)
|
|
7
|
+
[](https://pypi.org/project/waapi/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install waapi
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from waapi import WaAPI
|
|
18
|
+
|
|
19
|
+
client = WaAPI(token="YOUR_API_TOKEN", instance_id=123)
|
|
20
|
+
|
|
21
|
+
client.send_message(
|
|
22
|
+
chat_id="4915112345678@c.us",
|
|
23
|
+
message="Deployment finished.",
|
|
24
|
+
)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Get a token at [waapi.app/user/api-tokens](https://waapi.app/user/api-tokens)
|
|
28
|
+
and create an instance connected to your number.
|
|
29
|
+
|
|
30
|
+
### The chat ID is the one thing to get right
|
|
31
|
+
|
|
32
|
+
Its suffix decides where the message lands, and a wrong suffix is accepted and
|
|
33
|
+
delivers nothing:
|
|
34
|
+
|
|
35
|
+
| Target | Format |
|
|
36
|
+
|---|---|
|
|
37
|
+
| One person | `4915112345678@c.us` |
|
|
38
|
+
| Group | `123456789-123456789@g.us` |
|
|
39
|
+
| Channel | `123456789@newsletter` |
|
|
40
|
+
|
|
41
|
+
## Async
|
|
42
|
+
|
|
43
|
+
Same method names, awaited:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from waapi import AsyncWaAPI
|
|
47
|
+
|
|
48
|
+
async with AsyncWaAPI(token="YOUR_API_TOKEN", instance_id=123) as client:
|
|
49
|
+
await client.send_message(chat_id="4915112345678@c.us", message="Hi")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Errors
|
|
53
|
+
|
|
54
|
+
A successful HTTP exchange is not proof the message was sent. The API answers
|
|
55
|
+
`200` with `{"status": "error"}` when, for example, the instance is not
|
|
56
|
+
connected — so the SDK raises on that too, rather than handing back a body that
|
|
57
|
+
looks like success.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from waapi import WaAPI, FailedActionError, AuthenticationError, RateLimitError
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
client.send_message(chat_id="4915112345678@c.us", message="Hi")
|
|
64
|
+
except AuthenticationError:
|
|
65
|
+
... # token wrong, expired, or missing scopes
|
|
66
|
+
except RateLimitError as e:
|
|
67
|
+
time.sleep(e.retry_after or 5)
|
|
68
|
+
except FailedActionError as e:
|
|
69
|
+
... # accepted but not carried out — e.response has the detail
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Exception | Raised on |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `AuthenticationError` | HTTP 401, 403 |
|
|
75
|
+
| `NotFoundError` | HTTP 404 |
|
|
76
|
+
| `ValidationError` | HTTP 422 — `.errors` holds the field errors |
|
|
77
|
+
| `RateLimitError` | HTTP 429 — `.retry_after` in seconds when the API sends it |
|
|
78
|
+
| `FailedActionError` | HTTP 400, **and HTTP 200 with `status: error`** |
|
|
79
|
+
| `ServerError` | HTTP 5xx |
|
|
80
|
+
|
|
81
|
+
All inherit from `WaAPIError`.
|
|
82
|
+
|
|
83
|
+
## Coverage
|
|
84
|
+
|
|
85
|
+
All **122 client actions** are wrapped, typed, and available on both clients:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
client.create_group(group_name="Ops", group_participants=["4915112345678@c.us"])
|
|
89
|
+
client.send_media(chat_id="4915112345678@c.us", media_url="https://example.com/report.pdf")
|
|
90
|
+
client.get_contacts()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
They are generated from the same OpenAPI specification the n8n node and the MCP
|
|
94
|
+
tools come from, so they track the API instead of drifting behind it — see
|
|
95
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
96
|
+
|
|
97
|
+
An action added to the API since the last release is still reachable by name:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
client.action("some-new-action", {"chatId": "4915112345678@c.us"})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
WaAPI(
|
|
107
|
+
token="...", # required
|
|
108
|
+
instance_id=123, # optional; per-call instance_id overrides it
|
|
109
|
+
base_url="https://waapi.app/api/v1",
|
|
110
|
+
timeout=30.0,
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Passing `instance_id` to the client keeps single-instance code short. Any call
|
|
115
|
+
can still override it, and a call with neither raises before a request is sent.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
python -m venv .venv && source .venv/bin/activate
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
pytest
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The suite runs entirely against `httpx.MockTransport` — no network, no token,
|
|
126
|
+
no connected account.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT. Not affiliated with, endorsed or sponsored by WhatsApp LLC or Meta.
|
|
131
|
+
WhatsApp is a trademark of WhatsApp LLC.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "waapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the WaAPI REST API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "WaAPI", email = "info@waapi.app" }]
|
|
13
|
+
keywords = ["waapi", "messaging", "api", "sdk", "automation", "chatbot"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Communications :: Chat",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["httpx>=0.24,<1.0"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://waapi.app"
|
|
32
|
+
Documentation = "https://waapi.app/docs"
|
|
33
|
+
Source = "https://github.com/WaAPIapp/waapi-python-sdk"
|
|
34
|
+
Issues = "https://github.com/WaAPIapp/waapi-python-sdk/issues"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.23", "respx>=0.20", "ruff>=0.4", "mypy>=1.8"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/waapi"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
src = ["src", "tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
# PYI034 wants __enter__/__aenter__ to return typing.Self, which landed in
|
|
48
|
+
# 3.11. requires-python is 3.9, and pulling in typing_extensions as a runtime
|
|
49
|
+
# dependency for one annotation is a worse trade than the lint.
|
|
50
|
+
ignore = ["PYI034"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regenerate src/waapi/_generated.py and tests/test_generated_actions.py.
|
|
3
|
+
|
|
4
|
+
The API has 122 client actions. Writing them by hand would put every future
|
|
5
|
+
API change in four places -- the n8n node, the MCP tools, the PHP SDKs and
|
|
6
|
+
here -- so they are generated from the same OpenAPI specification the others
|
|
7
|
+
use, by `sdk:generate-methods` in the proxy repository.
|
|
8
|
+
|
|
9
|
+
Everything generated lands in its own module, which this script overwrites
|
|
10
|
+
whole. Nothing hand-written lives there, so a regeneration can never lose
|
|
11
|
+
someone's edit; the hand-written core stays in _actions.py and composes the
|
|
12
|
+
generated classes in.
|
|
13
|
+
|
|
14
|
+
python3 scripts/sync_actions.py ../eazewhatsapp-proxy
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
|
|
27
|
+
HEADER = '''"""Client actions generated from the WaAPI OpenAPI specification.
|
|
28
|
+
|
|
29
|
+
DO NOT EDIT. Regenerate with:
|
|
30
|
+
|
|
31
|
+
python3 scripts/sync_actions.py ../eazewhatsapp-proxy
|
|
32
|
+
|
|
33
|
+
Hand-written methods belong in _actions.py, which composes these classes in.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Calls:
|
|
42
|
+
"""The contract the mixins rely on, declared for the type checker only."""
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
|
|
46
|
+
def action(
|
|
47
|
+
self,
|
|
48
|
+
name: str,
|
|
49
|
+
payload: dict[str, Any] | None = None,
|
|
50
|
+
*,
|
|
51
|
+
instance_id: int | str | None = None,
|
|
52
|
+
) -> Any: ...
|
|
53
|
+
'''
|
|
54
|
+
|
|
55
|
+
TEST_HEADER = '''"""Generated payload tests -- one per client action.
|
|
56
|
+
|
|
57
|
+
DO NOT EDIT. Regenerate with:
|
|
58
|
+
|
|
59
|
+
python3 scripts/sync_actions.py ../eazewhatsapp-proxy
|
|
60
|
+
|
|
61
|
+
These methods hold no logic: they name an action and forward named arguments.
|
|
62
|
+
So their real failure modes are a wrong action string and a parameter that is
|
|
63
|
+
dropped or swapped with its neighbour, and both are visible in the request
|
|
64
|
+
that leaves the SDK. Sample values carry the parameter's own name for exactly
|
|
65
|
+
that reason -- identical values could not tell a swap from a correct call.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
'''
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def generate(proxy: Path, *args: str) -> str:
|
|
73
|
+
"""Run the generator and return only the emitted code.
|
|
74
|
+
|
|
75
|
+
The command reports how many methods it wrote on stdout as well, which is
|
|
76
|
+
useful in a terminal and a syntax error in a Python file.
|
|
77
|
+
"""
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
[sys.executable and "php", "artisan", "sdk:generate-methods", *args],
|
|
80
|
+
cwd=proxy,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
check=True,
|
|
84
|
+
)
|
|
85
|
+
body = re.sub(r"^\s*INFO\s+\d+ methods generated\.\s*$", "", result.stdout, flags=re.MULTILINE)
|
|
86
|
+
return body.rstrip() + "\n"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def count_methods(source: str) -> int:
|
|
90
|
+
return len(re.findall(r"^\s+(?:async )?def \w+\(", source, flags=re.MULTILINE))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> int:
|
|
94
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
95
|
+
parser.add_argument("proxy", type=Path, help="path to the eazewhatsapp-proxy checkout")
|
|
96
|
+
args = parser.parse_args()
|
|
97
|
+
|
|
98
|
+
proxy = args.proxy.expanduser().resolve()
|
|
99
|
+
if not (proxy / "artisan").is_file():
|
|
100
|
+
raise SystemExit(f"not a Laravel checkout: {proxy}")
|
|
101
|
+
|
|
102
|
+
sync = generate(proxy, "--flavour=python")
|
|
103
|
+
asyncronous = generate(proxy, "--flavour=python-async")
|
|
104
|
+
tests = generate(proxy, "--flavour=python", "--tests")
|
|
105
|
+
|
|
106
|
+
n_sync, n_async = count_methods(sync), count_methods(asyncronous)
|
|
107
|
+
if n_sync != n_async:
|
|
108
|
+
raise SystemExit(f"sync/async surfaces differ: {n_sync} vs {n_async}")
|
|
109
|
+
if n_sync == 0:
|
|
110
|
+
raise SystemExit("the generator emitted nothing -- check the spec path")
|
|
111
|
+
|
|
112
|
+
module = (
|
|
113
|
+
HEADER
|
|
114
|
+
+ "\n\nclass GeneratedActions(_Calls):\n"
|
|
115
|
+
+ ' """Every client action, blocking."""\n'
|
|
116
|
+
+ sync
|
|
117
|
+
+ "\n\nclass GeneratedAsyncActions(_Calls):\n"
|
|
118
|
+
+ ' """Every client action, awaited."""\n'
|
|
119
|
+
+ asyncronous
|
|
120
|
+
)
|
|
121
|
+
(ROOT / "src" / "waapi" / "_generated.py").write_text(module)
|
|
122
|
+
|
|
123
|
+
(ROOT / "tests" / "test_generated_actions.py").write_text(TEST_HEADER + tests)
|
|
124
|
+
|
|
125
|
+
written = [
|
|
126
|
+
ROOT / "src" / "waapi" / "_generated.py",
|
|
127
|
+
ROOT / "tests" / "test_generated_actions.py",
|
|
128
|
+
]
|
|
129
|
+
tidy(written)
|
|
130
|
+
|
|
131
|
+
print(f"wrote {n_sync} sync and {n_async} async methods, and {count_tests(tests)} tests")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def tidy(paths: list[Path]) -> None:
|
|
136
|
+
"""Bring the generated files up to the project's lint rules.
|
|
137
|
+
|
|
138
|
+
Emitting blank lines to PEP 8's satisfaction from a PHP string builder is
|
|
139
|
+
possible and pointless: the formatter already knows the rules, and letting
|
|
140
|
+
it run means a lint failure can never be something a human has to fix by
|
|
141
|
+
hand in a file marked DO NOT EDIT.
|
|
142
|
+
"""
|
|
143
|
+
for command in (["ruff", "check", "--fix", "--quiet"], ["ruff", "format", "--quiet"]):
|
|
144
|
+
try:
|
|
145
|
+
subprocess.run([*command, *map(str, paths)], cwd=ROOT, check=True)
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
print("ruff not on PATH -- generated files left unformatted", file=sys.stderr)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def count_tests(source: str) -> int:
|
|
152
|
+
return len(re.findall(r"^def test_", source, flags=re.MULTILINE))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|