whatsapp-python 4.3.0__tar.gz → 4.4.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.
- whatsapp_python-4.4.0/.github/workflows/python-publish.yml +46 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/LICENSE +1 -1
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/PKG-INFO +17 -11
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/README.md +10 -8
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/pyproject.toml +18 -2
- whatsapp_python-4.4.0/tests/manual/README.md +15 -0
- whatsapp_python-4.4.0/tests/test_client.py +241 -0
- whatsapp_python-4.4.0/tests/test_errors.py +28 -0
- whatsapp_python-4.4.0/tests/test_graph_api.py +60 -0
- whatsapp_python-4.4.0/tests/test_static_parsers.py +115 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/__init__.py +213 -339
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_buttons.py +110 -2
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_media.py +4 -4
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_message.py +15 -11
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_send_media.py +39 -40
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_send_others.py +2 -2
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_static.py +16 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/constants.py +5 -1
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/errors.py +61 -11
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_buttons.py +96 -2
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_media.py +4 -4
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_message.py +16 -12
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_send_media.py +39 -40
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_send_others.py +2 -2
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_static.py +16 -0
- whatsapp_python-4.4.0/whatsapp/graph_api.py +113 -0
- whatsapp_python-4.3.0/.github/workflows/python-publish.yml +0 -39
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/FUNDING.yml +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/ISSUE_TEMPLATE/custom.md +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/dependabot.yml +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/workflows/codeql.yml +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/workflows/dependency-review.yml +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.gitignore +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/CODE_OF_CONDUCT.md +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/CONTRIBUTING.md +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/SECURITY.md +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/reply_to_message_obj.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_audio.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_button.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_button_async.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_document.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_image.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_location.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_message.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_message_async.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_template_message.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_video.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/standalone_hook.py +0 -0
- /whatsapp_python-4.3.0/tests/async_tests.py → /whatsapp_python-4.4.0/tests/manual/async_smoke.py +0 -0
- /whatsapp_python-4.3.0/tests/tests.py → /whatsapp_python-4.4.0/tests/manual/sync_smoke.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_property.py +0 -0
- {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_property.py +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Upload to PyPI when a GitHub Release is published.
|
|
2
|
+
# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
|
3
|
+
|
|
4
|
+
name: Upload Python Package
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
# Re-run from Actions after workflow fixes (uses the selected branch/ref).
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
deployment:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
environment: publish
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Set up Python
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
|
|
27
|
+
- name: Install build tools
|
|
28
|
+
run: |
|
|
29
|
+
python -m pip install --upgrade pip
|
|
30
|
+
pip install build
|
|
31
|
+
|
|
32
|
+
- name: Build package
|
|
33
|
+
run: python -m build
|
|
34
|
+
|
|
35
|
+
- name: Check metadata
|
|
36
|
+
run: |
|
|
37
|
+
python -m pip install --upgrade "twine>=7"
|
|
38
|
+
twine check dist/*
|
|
39
|
+
|
|
40
|
+
# hatchling emits Metadata-Version 2.5; Twine 6 rejects it.
|
|
41
|
+
# gh-action-pypi-publish v1.14.2+ ships Twine 7 which accepts 2.5.
|
|
42
|
+
- name: Publish package
|
|
43
|
+
uses: pypa/gh-action-pypi-publish@v1.14.3
|
|
44
|
+
with:
|
|
45
|
+
user: __token__
|
|
46
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
2
|
Version 3, 19 November 2007
|
|
3
3
|
|
|
4
|
-
Copyright
|
|
4
|
+
Copyright © Filippo Romani
|
|
5
5
|
Everyone is permitted to copy and distribute verbatim copies
|
|
6
6
|
of this license document, but changing it is not allowed.
|
|
7
7
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: whatsapp-python
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.4.0
|
|
4
4
|
Summary: Open source Python wrapper for the WhatsApp Cloud API
|
|
5
5
|
Project-URL: Homepage, https://github.com/filipporomani/whatsapp-python
|
|
6
6
|
Project-URL: Docs, https://github.com/filipporomani/whatsapp-python/wiki
|
|
@@ -8,6 +8,7 @@ Project-URL: Issues Reporting, https://github.com/filipporomani/whatsapp-python/
|
|
|
8
8
|
Project-URL: Download, https://github.com/filipporomani/whatsapp-python/releases/latest
|
|
9
9
|
Project-URL: Changelog, https://github.com/filipporomani/whatsapp-python/releases
|
|
10
10
|
Author-email: Filippo Romani <mail@filipporomani.it>
|
|
11
|
+
License-File: LICENSE
|
|
11
12
|
Classifier: Development Status :: 4 - Beta
|
|
12
13
|
Classifier: Intended Audience :: Customer Service
|
|
13
14
|
Classifier: Intended Audience :: Developers
|
|
@@ -27,12 +28,15 @@ Classifier: Topic :: Software Development :: Build Tools
|
|
|
27
28
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
28
29
|
Requires-Python: >=3.10
|
|
29
30
|
Requires-Dist: aiohttp
|
|
30
|
-
Requires-Dist: asyncio
|
|
31
31
|
Requires-Dist: beautifulsoup4
|
|
32
32
|
Requires-Dist: fastapi
|
|
33
33
|
Requires-Dist: python-dotenv
|
|
34
|
+
Requires-Dist: requests
|
|
34
35
|
Requires-Dist: requests-toolbelt
|
|
35
36
|
Requires-Dist: uvicorn
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: httpx; extra == 'test'
|
|
39
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
36
40
|
Description-Content-Type: text/markdown
|
|
37
41
|
|
|
38
42
|
<div align="center">
|
|
@@ -76,24 +80,26 @@ Documentation is available in the [wiki](https://github.com/filipporomani/whatsa
|
|
|
76
80
|
- Full support for Graph API error handling
|
|
77
81
|
- Optimized for high-load workflows using asyncronous programming
|
|
78
82
|
- Always up to date
|
|
79
|
-
- All of the WhatsApp chat UI features supported
|
|
83
|
+
- All of the WhatsApp Business chat UI features supported
|
|
80
84
|
|
|
81
85
|
|
|
82
86
|
### All features
|
|
83
|
-
- Event
|
|
84
|
-
-
|
|
87
|
+
- Event listening (incoming messages)
|
|
88
|
+
- Asyncronous API calls
|
|
85
89
|
- Error handling
|
|
86
90
|
- Sending messages
|
|
87
91
|
- Sending messages from different numbers individually
|
|
88
|
-
- Marking messages as read
|
|
92
|
+
- Marking messages as read (optional typing indicator)
|
|
89
93
|
- Replying to messages
|
|
90
94
|
- Reacting to messages
|
|
91
|
-
- Sending medias (images, audios, videos, links and documents)
|
|
92
|
-
- Sending location
|
|
93
|
-
- Sending interactive buttons
|
|
95
|
+
- Sending medias (images, audios/voice notes, videos, links and documents)
|
|
96
|
+
- Sending location and location-request prompts
|
|
97
|
+
- Sending interactive buttons, lists, and CTA URL buttons
|
|
94
98
|
- Sending template messages
|
|
95
|
-
- Parsing messages and media received
|
|
99
|
+
- Parsing messages and media received (including reactions)
|
|
96
100
|
- Sending contacts
|
|
101
|
+
- Uploading media to the Facebook cloud
|
|
102
|
+
- Automatic Graph API version discovery with a safe fallback (`v27.0`)
|
|
97
103
|
|
|
98
104
|
## Obtaining the WhatsApp API credentials
|
|
99
105
|
|
|
@@ -39,24 +39,26 @@ Documentation is available in the [wiki](https://github.com/filipporomani/whatsa
|
|
|
39
39
|
- Full support for Graph API error handling
|
|
40
40
|
- Optimized for high-load workflows using asyncronous programming
|
|
41
41
|
- Always up to date
|
|
42
|
-
- All of the WhatsApp chat UI features supported
|
|
42
|
+
- All of the WhatsApp Business chat UI features supported
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
### All features
|
|
46
|
-
- Event
|
|
47
|
-
-
|
|
46
|
+
- Event listening (incoming messages)
|
|
47
|
+
- Asyncronous API calls
|
|
48
48
|
- Error handling
|
|
49
49
|
- Sending messages
|
|
50
50
|
- Sending messages from different numbers individually
|
|
51
|
-
- Marking messages as read
|
|
51
|
+
- Marking messages as read (optional typing indicator)
|
|
52
52
|
- Replying to messages
|
|
53
53
|
- Reacting to messages
|
|
54
|
-
- Sending medias (images, audios, videos, links and documents)
|
|
55
|
-
- Sending location
|
|
56
|
-
- Sending interactive buttons
|
|
54
|
+
- Sending medias (images, audios/voice notes, videos, links and documents)
|
|
55
|
+
- Sending location and location-request prompts
|
|
56
|
+
- Sending interactive buttons, lists, and CTA URL buttons
|
|
57
57
|
- Sending template messages
|
|
58
|
-
- Parsing messages and media received
|
|
58
|
+
- Parsing messages and media received (including reactions)
|
|
59
59
|
- Sending contacts
|
|
60
|
+
- Uploading media to the Facebook cloud
|
|
61
|
+
- Automatic Graph API version discovery with a safe fallback (`v27.0`)
|
|
60
62
|
|
|
61
63
|
## Obtaining the WhatsApp API credentials
|
|
62
64
|
|
|
@@ -31,7 +31,15 @@ classifiers=[
|
|
|
31
31
|
dynamic = [
|
|
32
32
|
"version"
|
|
33
33
|
]
|
|
34
|
-
dependencies = [
|
|
34
|
+
dependencies = [
|
|
35
|
+
"fastapi",
|
|
36
|
+
"uvicorn",
|
|
37
|
+
"requests",
|
|
38
|
+
"requests_toolbelt",
|
|
39
|
+
"aiohttp",
|
|
40
|
+
"python-dotenv",
|
|
41
|
+
"beautifulsoup4",
|
|
42
|
+
]
|
|
35
43
|
|
|
36
44
|
[project.urls]
|
|
37
45
|
Homepage = "https://github.com/filipporomani/whatsapp-python"
|
|
@@ -41,9 +49,17 @@ Download = "https://github.com/filipporomani/whatsapp-python/releases/latest"
|
|
|
41
49
|
Changelog = "https://github.com/filipporomani/whatsapp-python/releases"
|
|
42
50
|
|
|
43
51
|
|
|
52
|
+
[project.optional-dependencies]
|
|
53
|
+
test = ["pytest>=8.0", "httpx"]
|
|
54
|
+
|
|
44
55
|
[tool.hatch.version]
|
|
45
56
|
path = "whatsapp/constants.py"
|
|
46
57
|
variable = "VERSION"
|
|
47
58
|
|
|
48
59
|
[tool.hatch.build.targets.wheel]
|
|
49
|
-
packages = ["whatsapp"]
|
|
60
|
+
packages = ["whatsapp"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
python_files = ["test_*.py"]
|
|
65
|
+
addopts = "-q"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Manual / live API smoke tests
|
|
2
|
+
|
|
3
|
+
These scripts call the real WhatsApp Cloud API and require environment variables:
|
|
4
|
+
|
|
5
|
+
- `GRAPH_API_TOKEN`
|
|
6
|
+
- `TEST_PHONE_NUMBER_ID`
|
|
7
|
+
- `DEST_PHONE_NUMBER`
|
|
8
|
+
- `TEST_IMAGE_PATH` (async upload smoke only)
|
|
9
|
+
|
|
10
|
+
Run from the repo root after exporting those values:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
python3 tests/manual/sync_smoke.py
|
|
14
|
+
python3 tests/manual/async_smoke.py
|
|
15
|
+
```
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from whatsapp import AsyncMessage, AsyncWhatsApp, Message, WhatsApp
|
|
8
|
+
from whatsapp.errors import InvalidParameterException
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_client(cls=WhatsApp, **overrides):
|
|
12
|
+
kwargs = {
|
|
13
|
+
"token": "test-token",
|
|
14
|
+
"phone_number_id": {1: "123456789"},
|
|
15
|
+
"update_check": False,
|
|
16
|
+
"version": "v27.0",
|
|
17
|
+
"verify_token": "verify-me",
|
|
18
|
+
"logger": False,
|
|
19
|
+
"debug": False,
|
|
20
|
+
}
|
|
21
|
+
kwargs.update(overrides)
|
|
22
|
+
with patch("whatsapp.graph_api.requests.get") as discover:
|
|
23
|
+
discover.side_effect = AssertionError("should not discover for pinned version")
|
|
24
|
+
return cls(**kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_init_requires_dict_phone_number_id():
|
|
28
|
+
with pytest.raises(ValueError, match="dictionary"):
|
|
29
|
+
WhatsApp(token="x", phone_number_id="123", update_check=False, version="v27.0")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_pinned_version_sets_urls():
|
|
33
|
+
app = _make_client()
|
|
34
|
+
assert app.LATEST == "v27.0"
|
|
35
|
+
assert app.base_url == "https://graph.facebook.com/v27.0"
|
|
36
|
+
assert app.url == "https://graph.facebook.com/v27.0/123456789/messages"
|
|
37
|
+
assert app.VERSION == "4.4.0"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_create_message_and_send_payload():
|
|
41
|
+
app = _make_client()
|
|
42
|
+
msg = app.create_message(to="15550001111", content="hi there")
|
|
43
|
+
assert isinstance(msg, Message)
|
|
44
|
+
|
|
45
|
+
response = Mock()
|
|
46
|
+
response.status_code = 200
|
|
47
|
+
response.json.return_value = {"messages": [{"id": "wamid.1"}]}
|
|
48
|
+
with patch("whatsapp.requests.post", return_value=response) as post:
|
|
49
|
+
result = msg.send(sender=1)
|
|
50
|
+
assert result["messages"][0]["id"] == "wamid.1"
|
|
51
|
+
args, kwargs = post.call_args
|
|
52
|
+
assert args[0] == "https://graph.facebook.com/v27.0/123456789/messages"
|
|
53
|
+
assert kwargs["json"]["text"]["body"] == "hi there"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_mark_as_read_with_typing_indicator():
|
|
57
|
+
app = _make_client()
|
|
58
|
+
inbound = {
|
|
59
|
+
"entry": [
|
|
60
|
+
{
|
|
61
|
+
"changes": [
|
|
62
|
+
{
|
|
63
|
+
"field": "messages",
|
|
64
|
+
"value": {
|
|
65
|
+
"contacts": [
|
|
66
|
+
{
|
|
67
|
+
"wa_id": "15551234567",
|
|
68
|
+
"profile": {"name": "Ada"},
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"messages": [
|
|
72
|
+
{
|
|
73
|
+
"from": "15551234567",
|
|
74
|
+
"id": "wamid.IN",
|
|
75
|
+
"timestamp": "1",
|
|
76
|
+
"type": "text",
|
|
77
|
+
"text": {"body": "ping"},
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
msg = app.create_message(data=inbound)
|
|
87
|
+
response = Mock()
|
|
88
|
+
response.status_code = 200
|
|
89
|
+
response.json.return_value = {"success": True}
|
|
90
|
+
with patch("whatsapp.requests.post", return_value=response) as post:
|
|
91
|
+
assert msg.show_typing() == {"success": True}
|
|
92
|
+
payload = post.call_args.kwargs["json"]
|
|
93
|
+
assert payload == {
|
|
94
|
+
"messaging_product": "whatsapp",
|
|
95
|
+
"status": "read",
|
|
96
|
+
"message_id": "wamid.IN",
|
|
97
|
+
"typing_indicator": {"type": "text"},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_send_cta_url_and_location_request_payloads():
|
|
102
|
+
app = _make_client()
|
|
103
|
+
response = Mock()
|
|
104
|
+
response.status_code = 200
|
|
105
|
+
response.json.return_value = {"messages": [{"id": "wamid.x"}]}
|
|
106
|
+
|
|
107
|
+
with patch("whatsapp.ext._buttons.requests.post", return_value=response) as post:
|
|
108
|
+
app.send_cta_url(
|
|
109
|
+
body="Open our site",
|
|
110
|
+
display_text="Visit",
|
|
111
|
+
url="https://example.com",
|
|
112
|
+
recipient_id="15550001111",
|
|
113
|
+
header="Hello",
|
|
114
|
+
footer="Thanks",
|
|
115
|
+
sender=1,
|
|
116
|
+
)
|
|
117
|
+
cta = post.call_args.kwargs["json"]["interactive"]
|
|
118
|
+
assert cta["type"] == "cta_url"
|
|
119
|
+
assert cta["action"]["parameters"]["url"] == "https://example.com"
|
|
120
|
+
assert cta["header"]["text"] == "Hello"
|
|
121
|
+
|
|
122
|
+
app.send_location_request(
|
|
123
|
+
body="Share your location",
|
|
124
|
+
recipient_id="15550001111",
|
|
125
|
+
sender=1,
|
|
126
|
+
)
|
|
127
|
+
location = post.call_args.kwargs["json"]["interactive"]
|
|
128
|
+
assert location["type"] == "location_request_message"
|
|
129
|
+
assert location["action"]["name"] == "send_location"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_send_audio_voice_and_document_filename():
|
|
133
|
+
app = _make_client()
|
|
134
|
+
response = Mock()
|
|
135
|
+
response.status_code = 200
|
|
136
|
+
response.json.return_value = {"messages": [{"id": "wamid.media"}]}
|
|
137
|
+
|
|
138
|
+
with patch("whatsapp.ext._send_media.requests.post", return_value=response) as post:
|
|
139
|
+
app.send_audio("media-id", "15550001111", link=False, voice=True, sender=1)
|
|
140
|
+
audio = post.call_args.kwargs["json"]["audio"]
|
|
141
|
+
assert audio == {"id": "media-id", "voice": True}
|
|
142
|
+
|
|
143
|
+
app.send_document(
|
|
144
|
+
"https://example.com/a.pdf",
|
|
145
|
+
"15550001111",
|
|
146
|
+
caption="Invoice",
|
|
147
|
+
filename="invoice.pdf",
|
|
148
|
+
sender=1,
|
|
149
|
+
)
|
|
150
|
+
document = post.call_args.kwargs["json"]["document"]
|
|
151
|
+
assert document["filename"] == "invoice.pdf"
|
|
152
|
+
assert document["caption"] == "Invoice"
|
|
153
|
+
assert document["link"] == "https://example.com/a.pdf"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_send_template_omits_null_components():
|
|
157
|
+
app = _make_client()
|
|
158
|
+
response = Mock()
|
|
159
|
+
response.status_code = 200
|
|
160
|
+
response.json.return_value = {"messages": [{"id": "wamid.t"}]}
|
|
161
|
+
with patch("whatsapp.ext._message.requests.post", return_value=response) as post:
|
|
162
|
+
app.send_template("hello_world", "15550001111", sender=1)
|
|
163
|
+
template = post.call_args.kwargs["json"]["template"]
|
|
164
|
+
assert template == {
|
|
165
|
+
"name": "hello_world",
|
|
166
|
+
"language": {"code": "en_US"},
|
|
167
|
+
}
|
|
168
|
+
assert "components" not in template
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_error_handle_raises_typed_exception():
|
|
172
|
+
app = _make_client(cls=AsyncWhatsApp)
|
|
173
|
+
with pytest.raises(InvalidParameterException):
|
|
174
|
+
app.handle(
|
|
175
|
+
{
|
|
176
|
+
"error": {
|
|
177
|
+
"message": "Invalid parameter",
|
|
178
|
+
"code": 100,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_webhook_verification_and_message_dispatch():
|
|
185
|
+
app = _make_client()
|
|
186
|
+
received = {}
|
|
187
|
+
|
|
188
|
+
async def on_message(msg):
|
|
189
|
+
received["msg"] = msg
|
|
190
|
+
|
|
191
|
+
app.on_message(on_message)
|
|
192
|
+
client = TestClient(app.app)
|
|
193
|
+
|
|
194
|
+
ok = client.get("/", params={"hub.verify_token": "verify-me", "hub.challenge": "42"})
|
|
195
|
+
assert ok.status_code == 200
|
|
196
|
+
assert ok.json() == 42
|
|
197
|
+
|
|
198
|
+
bad = client.get("/", params={"hub.verify_token": "nope", "hub.challenge": "42"})
|
|
199
|
+
assert bad.status_code == 200
|
|
200
|
+
assert bad.json() == {"success": False}
|
|
201
|
+
|
|
202
|
+
payload = {
|
|
203
|
+
"entry": [
|
|
204
|
+
{
|
|
205
|
+
"changes": [
|
|
206
|
+
{
|
|
207
|
+
"field": "messages",
|
|
208
|
+
"value": {
|
|
209
|
+
"contacts": [
|
|
210
|
+
{
|
|
211
|
+
"wa_id": "15551234567",
|
|
212
|
+
"profile": {"name": "Ada"},
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
"messages": [
|
|
216
|
+
{
|
|
217
|
+
"from": "15551234567",
|
|
218
|
+
"id": "wamid.IN",
|
|
219
|
+
"timestamp": "1",
|
|
220
|
+
"type": "text",
|
|
221
|
+
"text": {"body": "ping"},
|
|
222
|
+
}
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
posted = client.post("/", content=json.dumps(payload))
|
|
231
|
+
assert posted.status_code == 200
|
|
232
|
+
assert posted.json() == {"success": True}
|
|
233
|
+
assert isinstance(received["msg"], Message)
|
|
234
|
+
assert received["msg"].content == "ping"
|
|
235
|
+
assert received["msg"].sender == "15551234567"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_async_client_creates_async_message():
|
|
239
|
+
app = _make_client(cls=AsyncWhatsApp)
|
|
240
|
+
msg = app.create_message(to="15550001111", content="async hi")
|
|
241
|
+
assert isinstance(msg, AsyncMessage)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from whatsapp.errors import (
|
|
4
|
+
DisplayNameApprovalException,
|
|
5
|
+
Handle,
|
|
6
|
+
MarketingOptOutException,
|
|
7
|
+
TemplateClassificationLimitException,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize(
|
|
12
|
+
"code,exc",
|
|
13
|
+
[
|
|
14
|
+
(131037, DisplayNameApprovalException),
|
|
15
|
+
(131050, MarketingOptOutException),
|
|
16
|
+
(131064, TemplateClassificationLimitException),
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
def test_new_error_mappings(code, exc):
|
|
20
|
+
with pytest.raises(exc):
|
|
21
|
+
Handle({"error": {"message": "boom", "code": code}})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_unknown_error_falls_back_to_handler():
|
|
25
|
+
from whatsapp.errors import Handler
|
|
26
|
+
|
|
27
|
+
with pytest.raises(Handler):
|
|
28
|
+
Handle({"error": {"message": "mystery", "code": 999999}})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from unittest.mock import Mock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from whatsapp.constants import DEFAULT_GRAPH_API_VERSION
|
|
6
|
+
from whatsapp.graph_api import (
|
|
7
|
+
normalize_graph_api_version,
|
|
8
|
+
resolve_graph_api_version,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_normalize_graph_api_version():
|
|
13
|
+
assert normalize_graph_api_version("v27.0") == "v27.0"
|
|
14
|
+
assert normalize_graph_api_version("27.0") == "v27.0"
|
|
15
|
+
assert normalize_graph_api_version(" latest ") == "latest"
|
|
16
|
+
with pytest.raises(ValueError):
|
|
17
|
+
normalize_graph_api_version("not-a-version")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_resolve_explicit_version_skips_network():
|
|
21
|
+
with patch("whatsapp.graph_api.requests.get") as get:
|
|
22
|
+
assert resolve_graph_api_version("v25.0") == "v25.0"
|
|
23
|
+
get.assert_not_called()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_resolve_latest_from_labeled_table():
|
|
27
|
+
html = """
|
|
28
|
+
<html><body>
|
|
29
|
+
<table><tr><td>The latest Graph API version is:</td><td>v27.0</td></tr></table>
|
|
30
|
+
</body></html>
|
|
31
|
+
"""
|
|
32
|
+
response = Mock()
|
|
33
|
+
response.text = html
|
|
34
|
+
response.raise_for_status = Mock()
|
|
35
|
+
with patch("whatsapp.graph_api.requests.get", return_value=response) as get:
|
|
36
|
+
assert resolve_graph_api_version("latest") == "v27.0"
|
|
37
|
+
assert get.call_count == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_resolve_latest_falls_back_when_pages_fail():
|
|
41
|
+
with patch(
|
|
42
|
+
"whatsapp.graph_api.requests.get", side_effect=RuntimeError("offline")
|
|
43
|
+
):
|
|
44
|
+
assert resolve_graph_api_version("latest") == DEFAULT_GRAPH_API_VERSION
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_resolve_latest_from_version_list_when_label_missing():
|
|
48
|
+
html = """
|
|
49
|
+
<html><body>
|
|
50
|
+
<table>
|
|
51
|
+
<tr><td>v26.0</td><td>July 29, 2026</td><td>TBD</td></tr>
|
|
52
|
+
<tr><td>v25.0</td><td>February 18, 2026</td><td>TBD</td></tr>
|
|
53
|
+
</table>
|
|
54
|
+
</body></html>
|
|
55
|
+
"""
|
|
56
|
+
response = Mock()
|
|
57
|
+
response.text = html
|
|
58
|
+
response.raise_for_status = Mock()
|
|
59
|
+
with patch("whatsapp.graph_api.requests.get", return_value=response):
|
|
60
|
+
assert resolve_graph_api_version("latest") == "v26.0"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from whatsapp.ext._static import (
|
|
2
|
+
changed_field,
|
|
3
|
+
get_author,
|
|
4
|
+
get_delivery,
|
|
5
|
+
get_interactive_response,
|
|
6
|
+
get_message,
|
|
7
|
+
get_message_id,
|
|
8
|
+
get_message_type,
|
|
9
|
+
get_mobile,
|
|
10
|
+
get_name,
|
|
11
|
+
get_reaction,
|
|
12
|
+
is_message,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SAMPLE_TEXT = {
|
|
17
|
+
"entry": [
|
|
18
|
+
{
|
|
19
|
+
"changes": [
|
|
20
|
+
{
|
|
21
|
+
"field": "messages",
|
|
22
|
+
"value": {
|
|
23
|
+
"contacts": [
|
|
24
|
+
{
|
|
25
|
+
"wa_id": "15551234567",
|
|
26
|
+
"profile": {"name": "Ada"},
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"messages": [
|
|
30
|
+
{
|
|
31
|
+
"from": "15551234567",
|
|
32
|
+
"id": "wamid.TEXT",
|
|
33
|
+
"timestamp": "1700000000",
|
|
34
|
+
"type": "text",
|
|
35
|
+
"text": {"body": "hello"},
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SAMPLE_REACTION = {
|
|
46
|
+
"entry": [
|
|
47
|
+
{
|
|
48
|
+
"changes": [
|
|
49
|
+
{
|
|
50
|
+
"field": "messages",
|
|
51
|
+
"value": {
|
|
52
|
+
"messages": [
|
|
53
|
+
{
|
|
54
|
+
"from": "15551234567",
|
|
55
|
+
"id": "wamid.REACT",
|
|
56
|
+
"timestamp": "1700000001",
|
|
57
|
+
"type": "reaction",
|
|
58
|
+
"reaction": {
|
|
59
|
+
"message_id": "wamid.TEXT",
|
|
60
|
+
"emoji": "👍",
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
SAMPLE_STATUS = {
|
|
72
|
+
"entry": [
|
|
73
|
+
{
|
|
74
|
+
"changes": [
|
|
75
|
+
{
|
|
76
|
+
"field": "messages",
|
|
77
|
+
"value": {
|
|
78
|
+
"statuses": [
|
|
79
|
+
{
|
|
80
|
+
"id": "wamid.OUT",
|
|
81
|
+
"status": "played",
|
|
82
|
+
"timestamp": "1700000002",
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_text_message_parsers():
|
|
94
|
+
assert is_message(SAMPLE_TEXT) is True
|
|
95
|
+
assert changed_field(SAMPLE_TEXT) == "messages"
|
|
96
|
+
assert get_mobile(SAMPLE_TEXT) == "15551234567"
|
|
97
|
+
assert get_name(SAMPLE_TEXT) == "Ada"
|
|
98
|
+
assert get_author(SAMPLE_TEXT) == "15551234567"
|
|
99
|
+
assert get_message(SAMPLE_TEXT) == "hello"
|
|
100
|
+
assert get_message_id(SAMPLE_TEXT) == "wamid.TEXT"
|
|
101
|
+
assert get_message_type(SAMPLE_TEXT) == "text"
|
|
102
|
+
assert get_interactive_response(SAMPLE_TEXT) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_reaction_parser():
|
|
106
|
+
assert get_message_type(SAMPLE_REACTION) == "reaction"
|
|
107
|
+
assert get_reaction(SAMPLE_REACTION) == {
|
|
108
|
+
"message_id": "wamid.TEXT",
|
|
109
|
+
"emoji": "👍",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_delivery_status_parser():
|
|
114
|
+
assert is_message(SAMPLE_STATUS) is False
|
|
115
|
+
assert get_delivery(SAMPLE_STATUS) == "played"
|