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.
Files changed (54) hide show
  1. whatsapp_python-4.4.0/.github/workflows/python-publish.yml +46 -0
  2. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/LICENSE +1 -1
  3. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/PKG-INFO +17 -11
  4. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/README.md +10 -8
  5. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/pyproject.toml +18 -2
  6. whatsapp_python-4.4.0/tests/manual/README.md +15 -0
  7. whatsapp_python-4.4.0/tests/test_client.py +241 -0
  8. whatsapp_python-4.4.0/tests/test_errors.py +28 -0
  9. whatsapp_python-4.4.0/tests/test_graph_api.py +60 -0
  10. whatsapp_python-4.4.0/tests/test_static_parsers.py +115 -0
  11. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/__init__.py +213 -339
  12. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_buttons.py +110 -2
  13. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_media.py +4 -4
  14. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_message.py +15 -11
  15. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_send_media.py +39 -40
  16. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_send_others.py +2 -2
  17. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_static.py +16 -0
  18. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/constants.py +5 -1
  19. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/errors.py +61 -11
  20. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_buttons.py +96 -2
  21. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_media.py +4 -4
  22. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_message.py +16 -12
  23. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_send_media.py +39 -40
  24. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_send_others.py +2 -2
  25. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/ext/_static.py +16 -0
  26. whatsapp_python-4.4.0/whatsapp/graph_api.py +113 -0
  27. whatsapp_python-4.3.0/.github/workflows/python-publish.yml +0 -39
  28. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/FUNDING.yml +0 -0
  29. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  30. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/ISSUE_TEMPLATE/custom.md +0 -0
  31. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  32. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/dependabot.yml +0 -0
  33. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/workflows/codeql.yml +0 -0
  34. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.github/workflows/dependency-review.yml +0 -0
  35. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/.gitignore +0 -0
  36. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/CODE_OF_CONDUCT.md +0 -0
  37. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/CONTRIBUTING.md +0 -0
  38. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/SECURITY.md +0 -0
  39. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/reply_to_message_obj.py +0 -0
  40. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_audio.py +0 -0
  41. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_button.py +0 -0
  42. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_button_async.py +0 -0
  43. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_document.py +0 -0
  44. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_image.py +0 -0
  45. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_location.py +0 -0
  46. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_message.py +0 -0
  47. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_message_async.py +0 -0
  48. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_template_message.py +0 -0
  49. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/sending_video.py +0 -0
  50. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/examples/standalone_hook.py +0 -0
  51. /whatsapp_python-4.3.0/tests/async_tests.py → /whatsapp_python-4.4.0/tests/manual/async_smoke.py +0 -0
  52. /whatsapp_python-4.3.0/tests/tests.py → /whatsapp_python-4.4.0/tests/manual/sync_smoke.py +0 -0
  53. {whatsapp_python-4.3.0 → whatsapp_python-4.4.0}/whatsapp/async_ext/_property.py +0 -0
  54. {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 (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.3
1
+ Metadata-Version: 2.5
2
2
  Name: whatsapp-python
3
- Version: 4.3.0
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 listener (incoming messages)
84
- - Async ready
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 listener (incoming messages)
47
- - Async ready
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 = ["fastapi", "uvicorn", "requests_toolbelt", "asyncio", "aiohttp", "python-dotenv", "BeautifulSoup4"]
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"