wazzapi 0.1.1__tar.gz → 0.2.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 (36) hide show
  1. {wazzapi-0.1.1 → wazzapi-0.2.0}/PKG-INFO +2 -1
  2. {wazzapi-0.1.1 → wazzapi-0.2.0}/pyproject.toml +4 -1
  3. {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/smoke_test.py +1 -1
  4. wazzapi-0.2.0/tests/test_groups.py +285 -0
  5. wazzapi-0.2.0/tests/test_media.py +170 -0
  6. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/__init__.py +6 -2
  7. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/client.py +2 -1
  8. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/errors.py +4 -0
  9. wazzapi-0.2.0/wazzapi/media.py +147 -0
  10. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/__init__.py +5 -1
  11. wazzapi-0.2.0/wazzapi/models/groups.py +165 -0
  12. wazzapi-0.2.0/wazzapi/models/media.py +13 -0
  13. wazzapi-0.2.0/wazzapi/resources/__init__.py +6 -0
  14. wazzapi-0.2.0/wazzapi/resources/groups.py +266 -0
  15. wazzapi-0.1.1/wazzapi/resources/__init__.py +0 -5
  16. {wazzapi-0.1.1 → wazzapi-0.2.0}/.gitignore +0 -0
  17. {wazzapi-0.1.1 → wazzapi-0.2.0}/README.md +0 -0
  18. {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/create_template.py +0 -0
  19. {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/list_contacts.py +0 -0
  20. {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/preview_template.py +0 -0
  21. {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/send_message.py +0 -0
  22. {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/verify_webhook.py +0 -0
  23. {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/test_client.py +0 -0
  24. {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/test_resources.py +0 -0
  25. {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/test_webhooks.py +0 -0
  26. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/base.py +0 -0
  27. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/contacts.py +0 -0
  28. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/messages.py +0 -0
  29. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/templates.py +0 -0
  30. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/webhooks.py +0 -0
  31. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/py.typed +0 -0
  32. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/base.py +0 -0
  33. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/contacts.py +0 -0
  34. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/messages.py +0 -0
  35. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/templates.py +0 -0
  36. {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wazzapi
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Typed Python SDK for WazzAPI
5
5
  Project-URL: Homepage, https://github.com/wazzapihq/wazzapi-py
6
6
  Project-URL: API, https://api.wazzapi.com
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
+ Requires-Dist: cryptography<45.0,>=44.0
20
21
  Requires-Dist: httpx<1.0,>=0.28
21
22
  Requires-Dist: pydantic<3.0,>=2.11
22
23
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wazzapi"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Typed Python SDK for WazzAPI"
5
5
  readme = { file = "README.md", content-type = "text/markdown" }
6
6
  requires-python = ">=3.10"
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Typing :: Typed",
20
20
  ]
21
21
  dependencies = [
22
+ "cryptography>=44.0,<45.0",
22
23
  "httpx>=0.28,<1.0",
23
24
  "pydantic>=2.11,<3.0",
24
25
  ]
@@ -30,6 +31,7 @@ API = "https://api.wazzapi.com"
30
31
  [dependency-groups]
31
32
  dev = [
32
33
  "pytest>=8.3,<9.0",
34
+ "pytest-asyncio>=0.25,<1.0",
33
35
  "twine>=6.1,<7.0",
34
36
  ]
35
37
 
@@ -52,3 +54,4 @@ include = [
52
54
  [tool.pytest]
53
55
  python_files = ["test_*.py"]
54
56
  testpaths = ["tests"]
57
+ asyncio_mode = "auto"
@@ -10,7 +10,7 @@ from wazzapi import (
10
10
 
11
11
 
12
12
  def main() -> None:
13
- assert __version__ == "0.1.1"
13
+ assert __version__ == "0.2.0"
14
14
 
15
15
  client = WazzapiClient(api_key="smoke-test-token")
16
16
  try:
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from wazzapi import WazzapiClient, models
10
+
11
+
12
+ @pytest.mark.parametrize(
13
+ ("call_factory", "expected_method", "expected_path", "expected_response_type", "response_json"),
14
+ [
15
+ (
16
+ lambda client: client.groups.list(session_name="main", limit=25),
17
+ "GET",
18
+ "/api/v1/groups",
19
+ models.GroupListResponse,
20
+ {"groups": [], "total": 0},
21
+ ),
22
+ (
23
+ lambda client: client.groups.get("123@g.us", session_name="main"),
24
+ "GET",
25
+ "/api/v1/groups/123@g.us",
26
+ models.GroupDetailResponse,
27
+ {"id": "123@g.us", "name": "Test Group"},
28
+ ),
29
+ (
30
+ lambda client: client.groups.get_participants("123@g.us", session_name="main"),
31
+ "GET",
32
+ "/api/v1/groups/123@g.us/participants",
33
+ models.GroupParticipantsResponse,
34
+ {"participants": [{"id": "6281234567890@s.whatsapp.net", "is_admin": True}]},
35
+ ),
36
+ (
37
+ lambda client: client.groups.create({"session_name": "main", "name": "New Group", "participants": ["6281234567890"]}),
38
+ "POST",
39
+ "/api/v1/groups/create",
40
+ models.CreateGroupResponseModel,
41
+ {"success": True, "jid": "456@g.us", "name": "New Group"},
42
+ ),
43
+ (
44
+ lambda client: client.groups.send_text({"session_name": "main", "group_jid": "123@g.us", "text": "hello"}),
45
+ "POST",
46
+ "/api/v1/groups/send",
47
+ models.SendGroupResponse,
48
+ {"success": True, "message_id": "msg_1", "status": "queued"},
49
+ ),
50
+ (
51
+ lambda client: client.groups.send_media({
52
+ "session_name": "main",
53
+ "group_jid": "123@g.us",
54
+ "media_url": "https://example.com/img.jpg",
55
+ "media_type": "image",
56
+ }),
57
+ "POST",
58
+ "/api/v1/groups/send/media",
59
+ models.SendGroupResponse,
60
+ {"success": True, "message_id": "msg_2", "status": "queued"},
61
+ ),
62
+ (
63
+ lambda client: client.groups.update_participants({
64
+ "session_name": "main",
65
+ "group_jid": "123@g.us",
66
+ "action": "promote",
67
+ "participants": ["6281234567890@s.whatsapp.net"],
68
+ }),
69
+ "POST",
70
+ "/api/v1/groups/participants",
71
+ models.GroupOperationResponseModel,
72
+ {"success": True, "details": "done"},
73
+ ),
74
+ (
75
+ lambda client: client.groups.add_participant("123@g.us", session_name="main", participant_jid="6281234567890@s.whatsapp.net"),
76
+ "POST",
77
+ "/api/v1/groups/123@g.us/participants/add",
78
+ models.GroupOperationResponseModel,
79
+ {"success": True, "details": "added"},
80
+ ),
81
+ (
82
+ lambda client: client.groups.remove_participant("123@g.us", session_name="main", participant_jid="6281234567890@s.whatsapp.net"),
83
+ "POST",
84
+ "/api/v1/groups/123@g.us/participants/remove",
85
+ models.GroupOperationResponseModel,
86
+ {"success": True, "details": "removed"},
87
+ ),
88
+ (
89
+ lambda client: client.groups.get_invite_link("123@g.us", session_name="main"),
90
+ "POST",
91
+ "/api/v1/groups/123@g.us/invite-link",
92
+ models.GroupInviteLinkResponseModel,
93
+ {"success": True, "invite_link": "https://chat.whatsapp.com/abc"},
94
+ ),
95
+ (
96
+ lambda client: client.groups.get_invite_info({"session_name": "main", "invite_link": "https://chat.whatsapp.com/abc"}),
97
+ "POST",
98
+ "/api/v1/groups/invite-info",
99
+ models.GroupInviteInfoResponseModel,
100
+ {"success": True, "jid": "789@g.us", "name": "Invited Group"},
101
+ ),
102
+ (
103
+ lambda client: client.groups.join({"session_name": "main", "invite_link": "https://chat.whatsapp.com/abc"}),
104
+ "POST",
105
+ "/api/v1/groups/join",
106
+ models.GroupOperationResponseModel,
107
+ {"success": True, "details": "joined"},
108
+ ),
109
+ (
110
+ lambda client: client.groups.leave("123@g.us", session_name="main"),
111
+ "POST",
112
+ "/api/v1/groups/123@g.us/leave",
113
+ models.GroupOperationResponseModel,
114
+ {"success": True, "details": "left"},
115
+ ),
116
+ (
117
+ lambda client: client.groups.set_name("123@g.us", {"session_name": "main", "name": "Renamed"}),
118
+ "POST",
119
+ "/api/v1/groups/123@g.us/name",
120
+ models.GroupOperationResponseModel,
121
+ {"success": True, "details": "name updated"},
122
+ ),
123
+ (
124
+ lambda client: client.groups.set_topic("123@g.us", {"session_name": "main", "topic": "new topic"}),
125
+ "POST",
126
+ "/api/v1/groups/123@g.us/topic",
127
+ models.GroupOperationResponseModel,
128
+ {"success": True, "details": "topic updated"},
129
+ ),
130
+ (
131
+ lambda client: client.groups.set_photo("123@g.us", {"session_name": "main", "image_data_uri": "data:image/png;base64,abc"}),
132
+ "POST",
133
+ "/api/v1/groups/123@g.us/photo",
134
+ models.GroupOperationResponseModel,
135
+ {"success": True, "details": "photo updated"},
136
+ ),
137
+ (
138
+ lambda client: client.groups.set_announce("123@g.us", {"session_name": "main", "announce": True}),
139
+ "POST",
140
+ "/api/v1/groups/123@g.us/announce",
141
+ models.GroupOperationResponseModel,
142
+ {"success": True, "details": "announce set"},
143
+ ),
144
+ (
145
+ lambda client: client.groups.set_locked("123@g.us", {"session_name": "main", "locked": True}),
146
+ "POST",
147
+ "/api/v1/groups/123@g.us/locked",
148
+ models.GroupOperationResponseModel,
149
+ {"success": True, "details": "locked set"},
150
+ ),
151
+ (
152
+ lambda client: client.groups.set_ephemeral("123@g.us", {"session_name": "main", "duration": "24h"}),
153
+ "POST",
154
+ "/api/v1/groups/123@g.us/ephemeral",
155
+ models.GroupOperationResponseModel,
156
+ {"success": True, "details": "ephemeral set"},
157
+ ),
158
+ ],
159
+ )
160
+ def test_groups_methods_hit_expected_routes(
161
+ call_factory: Callable[[WazzapiClient], object],
162
+ expected_method: str,
163
+ expected_path: str,
164
+ expected_response_type: type[object],
165
+ response_json: dict[str, object],
166
+ ) -> None:
167
+ seen: dict[str, object] = {}
168
+
169
+ def handler(request: httpx.Request) -> httpx.Response:
170
+ seen["method"] = request.method
171
+ seen["path"] = request.url.path
172
+ seen["query"] = dict(request.url.params)
173
+ seen["body"] = json.loads(request.content.decode("utf-8")) if request.content else None
174
+ status_code = 201 if request.method == "POST" else 200
175
+ return httpx.Response(status_code, json=response_json)
176
+
177
+ transport = httpx.MockTransport(handler)
178
+ with WazzapiClient(base_url="https://api.example.com", transport=transport) as client:
179
+ result = call_factory(client)
180
+
181
+ assert seen["method"] == expected_method
182
+ assert seen["path"] == expected_path
183
+ assert isinstance(result, expected_response_type)
184
+
185
+
186
+ def test_groups_remove_photo_returns_operation_response() -> None:
187
+ seen: dict[str, object] = {}
188
+
189
+ def handler(request: httpx.Request) -> httpx.Response:
190
+ seen["method"] = request.method
191
+ seen["path"] = request.url.path
192
+ seen["query"] = dict(request.url.params)
193
+ return httpx.Response(
194
+ 200,
195
+ json={"success": True, "details": "photo removed"},
196
+ )
197
+
198
+ transport = httpx.MockTransport(handler)
199
+ with WazzapiClient(base_url="https://api.example.com", transport=transport) as client:
200
+ result = client.groups.remove_photo("123@g.us", session_name="main")
201
+
202
+ assert seen["method"] == "DELETE"
203
+ assert seen["path"] == "/api/v1/groups/123@g.us/photo"
204
+ assert seen["query"] == {"session_name": "main"}
205
+ assert isinstance(result, models.GroupOperationResponseModel)
206
+ assert result.success is True
207
+
208
+
209
+ def test_groups_list_sends_query_params() -> None:
210
+ seen: dict[str, object] = {}
211
+
212
+ def handler(request: httpx.Request) -> httpx.Response:
213
+ seen["query"] = dict(request.url.params)
214
+ return httpx.Response(200, json={"groups": [], "total": 0})
215
+
216
+ transport = httpx.MockTransport(handler)
217
+ with WazzapiClient(base_url="https://api.example.com", transport=transport) as client:
218
+ client.groups.list(session_name="main", limit=10, offset=5)
219
+
220
+ assert seen["query"] == {"session_name": "main", "limit": "10", "offset": "5"}
221
+
222
+
223
+ def test_groups_get_sends_session_name_query() -> None:
224
+ seen: dict[str, object] = {}
225
+
226
+ def handler(request: httpx.Request) -> httpx.Response:
227
+ seen["query"] = dict(request.url.params)
228
+ return httpx.Response(200, json={"id": "123@g.us", "name": "Test"})
229
+
230
+ transport = httpx.MockTransport(handler)
231
+ with WazzapiClient(base_url="https://api.example.com", transport=transport) as client:
232
+ client.groups.get("123@g.us", session_name="main")
233
+
234
+ assert seen["query"] == {"session_name": "main"}
235
+
236
+
237
+ def test_groups_add_participant_sends_query_params() -> None:
238
+ seen: dict[str, object] = {}
239
+
240
+ def handler(request: httpx.Request) -> httpx.Response:
241
+ seen["query"] = dict(request.url.params)
242
+ return httpx.Response(200, json={"success": True, "details": "added"})
243
+
244
+ transport = httpx.MockTransport(handler)
245
+ with WazzapiClient(base_url="https://api.example.com", transport=transport) as client:
246
+ client.groups.add_participant(
247
+ "123@g.us",
248
+ session_name="main",
249
+ participant_jid="6281234567890@s.whatsapp.net",
250
+ )
251
+
252
+ assert seen["query"] == {
253
+ "session_name": "main",
254
+ "participant_jid": "6281234567890@s.whatsapp.net",
255
+ }
256
+
257
+
258
+ def test_groups_models_are_importable() -> None:
259
+ from wazzapi.models import (
260
+ CreateGroupRequest,
261
+ CreateGroupResponseModel,
262
+ GroupDetailResponse,
263
+ GroupInviteInfoResponseModel,
264
+ GroupInviteLinkResponseModel,
265
+ GroupListItem,
266
+ GroupListResponse,
267
+ GroupOperationResponseModel,
268
+ GroupParticipantItem,
269
+ GroupParticipantsResponse,
270
+ InviteInfoRequest,
271
+ JoinGroupRequest,
272
+ SendGroupMediaRequest,
273
+ SendGroupResponse,
274
+ SendGroupTextRequest,
275
+ SetGroupAnnounceRequest,
276
+ SetGroupEphemeralRequest,
277
+ SetGroupLockedRequest,
278
+ SetGroupNameRequest,
279
+ SetGroupPhotoRequest,
280
+ SetGroupTopicRequest,
281
+ UpdateParticipantsRequest,
282
+ )
283
+
284
+ assert GroupListItem is not None
285
+ assert CreateGroupResponseModel is not None
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ from unittest.mock import AsyncMock
6
+
7
+ import httpx
8
+ import pytest
9
+ from cryptography.hazmat.primitives import hashes
10
+ from cryptography.hazmat.primitives import hmac as crypto_hmac
11
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
12
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
13
+
14
+ from wazzapi.errors import WazzapiMediaError
15
+ from wazzapi.media import _decrypt_wa_media, _media_info_bytes, download_media
16
+
17
+ PLAINTEXT = b"hello world this is a test file"
18
+ MIMETYPE = "application/pdf"
19
+ _MEDIA_KEY_BYTES = b"\x00" * 32
20
+ MEDIA_KEY = base64.b64encode(_MEDIA_KEY_BYTES).decode()
21
+
22
+
23
+ def _encrypt_wa_media(plaintext: bytes, media_key: bytes, mimetype: str) -> bytes:
24
+ info = _media_info_bytes(mimetype)
25
+ hkdf = HKDF(algorithm=hashes.SHA256(), length=112, salt=None, info=info)
26
+ expanded = hkdf.derive(media_key)
27
+ iv = expanded[:16]
28
+ aes_key = expanded[16:48]
29
+ mac_key = expanded[48:80]
30
+
31
+ pad_len = 16 - (len(plaintext) % 16)
32
+ padded = plaintext + bytes([pad_len] * pad_len)
33
+
34
+ cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
35
+ encryptor = cipher.encryptor()
36
+ ciphertext = encryptor.update(padded) + encryptor.finalize()
37
+
38
+ h = crypto_hmac.HMAC(mac_key, hashes.SHA256())
39
+ h.update(iv + ciphertext)
40
+ mac = h.finalize()[:10]
41
+
42
+ return ciphertext + mac
43
+
44
+
45
+ ENC_BLOB = _encrypt_wa_media(PLAINTEXT, _MEDIA_KEY_BYTES, MIMETYPE)
46
+ FILE_SHA256 = base64.b64encode(hashlib.sha256(PLAINTEXT).digest()).decode()
47
+ ENC_SHA256 = base64.b64encode(hashlib.sha256(ENC_BLOB).digest()).decode()
48
+
49
+
50
+ def _mock_client(enc_data: bytes, status_code: int = 200) -> AsyncMock:
51
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
52
+ request = httpx.Request("GET", "https://cdn.example.com/file")
53
+ mock_client.get.return_value = httpx.Response(status_code, content=enc_data, request=request)
54
+ mock_client.aclose = AsyncMock()
55
+ return mock_client
56
+
57
+
58
+ def test_decrypt_wa_media_success() -> None:
59
+ result = _decrypt_wa_media(MEDIA_KEY, ENC_BLOB, MIMETYPE)
60
+ assert result == PLAINTEXT
61
+
62
+
63
+ def test_decrypt_wa_media_mac_failure() -> None:
64
+ bad_blob = ENC_BLOB[:-10] + b"\x00" * 10
65
+ with pytest.raises(WazzapiMediaError, match="MAC verification failed"):
66
+ _decrypt_wa_media(MEDIA_KEY, bad_blob, MIMETYPE)
67
+
68
+
69
+ def test_decrypt_wa_media_short_data() -> None:
70
+ with pytest.raises(WazzapiMediaError, match="Encrypted data too short"):
71
+ _decrypt_wa_media(MEDIA_KEY, b"123", MIMETYPE)
72
+
73
+
74
+ def test_decrypt_wa_media_bad_base64_key() -> None:
75
+ with pytest.raises(WazzapiMediaError, match="Invalid media_key base64"):
76
+ _decrypt_wa_media("not-valid-b64!!!", ENC_BLOB, MIMETYPE)
77
+
78
+
79
+ def test_media_info_bytes_mapping() -> None:
80
+ assert _media_info_bytes("image/jpeg") == b"WhatsApp Image Keys"
81
+ assert _media_info_bytes("video/mp4") == b"WhatsApp Video Keys"
82
+ assert _media_info_bytes("audio/ogg") == b"WhatsApp Audio Keys"
83
+ assert _media_info_bytes("application/pdf") == b"WhatsApp Document Keys"
84
+
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_download_media_success() -> None:
88
+ mock_client = _mock_client(ENC_BLOB)
89
+ result = await download_media(
90
+ url="https://cdn.example.com/file",
91
+ media_key=MEDIA_KEY,
92
+ mimetype=MIMETYPE,
93
+ file_sha256=FILE_SHA256,
94
+ file_enc_sha256=ENC_SHA256,
95
+ file_name="test.pdf",
96
+ http_client=mock_client,
97
+ )
98
+ assert result.content == PLAINTEXT
99
+ assert result.mimetype == MIMETYPE
100
+ assert result.file_name == "test.pdf"
101
+ assert result.file_size == len(PLAINTEXT)
102
+ mock_client.get.assert_awaited_once_with("https://cdn.example.com/file")
103
+ mock_client.aclose.assert_not_awaited()
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_download_media_http_error() -> None:
108
+ mock_client = _mock_client(b"not found", status_code=404)
109
+ with pytest.raises(WazzapiMediaError, match="CDN fetch failed: 404"):
110
+ await download_media(
111
+ url="https://cdn.example.com/file",
112
+ media_key=MEDIA_KEY,
113
+ mimetype=MIMETYPE,
114
+ http_client=mock_client,
115
+ )
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_download_media_network_error() -> None:
120
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
121
+ mock_client.get.side_effect = httpx.RequestError(
122
+ "connection refused", request=httpx.Request("GET", "https://cdn.example.com/file")
123
+ )
124
+ mock_client.aclose = AsyncMock()
125
+ with pytest.raises(WazzapiMediaError, match="CDN fetch error:"):
126
+ await download_media(
127
+ url="https://cdn.example.com/file",
128
+ media_key=MEDIA_KEY,
129
+ mimetype=MIMETYPE,
130
+ http_client=mock_client,
131
+ )
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_download_media_enc_sha_mismatch() -> None:
136
+ mock_client = _mock_client(ENC_BLOB)
137
+ with pytest.raises(WazzapiMediaError, match="file_enc_sha256 mismatch"):
138
+ await download_media(
139
+ url="https://cdn.example.com/file",
140
+ media_key=MEDIA_KEY,
141
+ mimetype=MIMETYPE,
142
+ file_enc_sha256=base64.b64encode(hashlib.sha256(b"wrong").digest()).decode(),
143
+ http_client=mock_client,
144
+ )
145
+
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_download_media_plain_sha_mismatch() -> None:
149
+ mock_client = _mock_client(ENC_BLOB)
150
+ with pytest.raises(WazzapiMediaError, match="file_sha256 mismatch after decryption"):
151
+ await download_media(
152
+ url="https://cdn.example.com/file",
153
+ media_key=MEDIA_KEY,
154
+ mimetype=MIMETYPE,
155
+ file_sha256=base64.b64encode(hashlib.sha256(b"wrong").digest()).decode(),
156
+ http_client=mock_client,
157
+ )
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_download_media_no_checksums() -> None:
162
+ mock_client = _mock_client(ENC_BLOB)
163
+ result = await download_media(
164
+ url="https://cdn.example.com/file",
165
+ media_key=MEDIA_KEY,
166
+ mimetype=MIMETYPE,
167
+ http_client=mock_client,
168
+ )
169
+ assert result.content == PLAINTEXT
170
+ assert result.file_name == "media"
@@ -1,6 +1,7 @@
1
1
  from . import models
2
2
  from .client import WazzapiClient
3
- from .errors import WazzapiAPIError, WazzapiError
3
+ from .errors import WazzapiAPIError, WazzapiError, WazzapiMediaError
4
+ from .media import MediaDownloadResult, download_media
4
5
  from .models import *
5
6
  from .webhooks import (
6
7
  EVENT_HEADER,
@@ -15,18 +16,21 @@ from .webhooks import (
15
16
  verify_webhook_signature,
16
17
  )
17
18
 
18
- __version__ = "0.1.1"
19
+ __version__ = "0.2.0"
19
20
 
20
21
  __all__ = [
21
22
  "EVENT_HEADER",
22
23
  "EVENT_ID_HEADER",
23
24
  "SIGNATURE_HEADER",
24
25
  "__version__",
26
+ "download_media",
25
27
  "generate_webhook_signature",
26
28
  "parse_webhook",
29
+ "MediaDownloadResult",
27
30
  "WazzapiAPIError",
28
31
  "WazzapiClient",
29
32
  "WazzapiError",
33
+ "WazzapiMediaError",
30
34
  "WazzapiWebhookError",
31
35
  "WazzapiWebhookParseError",
32
36
  "WazzapiWebhookVerificationError",
@@ -7,7 +7,7 @@ import httpx
7
7
  from pydantic import BaseModel, TypeAdapter
8
8
 
9
9
  from .errors import WazzapiAPIError
10
- from .resources import ContactsResource, MessagesResource, TemplatesResource
10
+ from .resources import ContactsResource, GroupsResource, MessagesResource, TemplatesResource
11
11
 
12
12
  DEFAULT_BASE_URL = "https://api.wazzapi.com"
13
13
 
@@ -39,6 +39,7 @@ class WazzapiClient:
39
39
  )
40
40
 
41
41
  self.contacts = ContactsResource(self)
42
+ self.groups = GroupsResource(self)
42
43
  self.messages = MessagesResource(self)
43
44
  self.templates = TemplatesResource(self)
44
45
 
@@ -58,3 +58,7 @@ class WazzapiAPIError(WazzapiError):
58
58
  details=details,
59
59
  response_text=response.text,
60
60
  )
61
+
62
+
63
+ class WazzapiMediaError(WazzapiError):
64
+ """Raised when media download or decryption fails."""
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import logging
6
+
7
+ import httpx
8
+ from cryptography.hazmat.primitives import hashes
9
+ from cryptography.hazmat.primitives import hmac as crypto_hmac
10
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
11
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
12
+
13
+ from .errors import WazzapiMediaError
14
+ from .models.media import MediaDownloadResult
15
+
16
+ logger = logging.getLogger("wazzapi.media")
17
+
18
+ # https://github.com/tgalal/python-axolotl/wiki/WhatsApp-media-encryption
19
+ _WA_MEDIA_INFO: dict[str, bytes] = {
20
+ "image": b"WhatsApp Image Keys",
21
+ "video": b"WhatsApp Video Keys",
22
+ "audio": b"WhatsApp Audio Keys",
23
+ "ptt": b"WhatsApp Audio Keys",
24
+ "document": b"WhatsApp Document Keys",
25
+ "sticker": b"WhatsApp Image Keys",
26
+ "history": b"WhatsApp History Keys",
27
+ }
28
+
29
+
30
+ def _media_info_bytes(mimetype: str) -> bytes:
31
+ mt = mimetype.lower()
32
+ if mt.startswith("image/"):
33
+ return _WA_MEDIA_INFO["image"]
34
+ if mt.startswith("video/"):
35
+ return _WA_MEDIA_INFO["video"]
36
+ if mt.startswith("audio/"):
37
+ return _WA_MEDIA_INFO["audio"]
38
+ return _WA_MEDIA_INFO["document"]
39
+
40
+
41
+ def _decrypt_wa_media(media_key_b64: str, enc_data: bytes, mimetype: str) -> bytes:
42
+ if len(enc_data) < 10:
43
+ raise WazzapiMediaError("Encrypted data too short")
44
+
45
+ try:
46
+ media_key = base64.b64decode(media_key_b64)
47
+ except Exception as exc:
48
+ raise WazzapiMediaError("Invalid media_key base64") from exc
49
+
50
+ info = _media_info_bytes(mimetype)
51
+
52
+ hkdf = HKDF(algorithm=hashes.SHA256(), length=112, salt=None, info=info)
53
+ expanded = hkdf.derive(media_key)
54
+
55
+ iv = expanded[:16]
56
+ aes_key = expanded[16:48]
57
+ mac_key = expanded[48:80]
58
+
59
+ ciphertext = enc_data[:-10]
60
+ mac = enc_data[-10:]
61
+
62
+ h = crypto_hmac.HMAC(mac_key, hashes.SHA256())
63
+ h.update(iv + ciphertext)
64
+ computed_mac = h.finalize()[:10]
65
+ if computed_mac != mac:
66
+ raise WazzapiMediaError(
67
+ "MAC verification failed — media may be corrupted or mediaKey is wrong"
68
+ )
69
+
70
+ cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
71
+ decryptor = cipher.decryptor()
72
+ plaintext_padded = decryptor.update(ciphertext) + decryptor.finalize()
73
+
74
+ if not plaintext_padded:
75
+ raise WazzapiMediaError("Decrypted plaintext is empty")
76
+
77
+ pad_len = plaintext_padded[-1]
78
+ if pad_len > 16 or pad_len == 0 or pad_len > len(plaintext_padded):
79
+ raise WazzapiMediaError("Invalid PKCS7 padding")
80
+ if any(b != pad_len for b in plaintext_padded[-pad_len:]):
81
+ raise WazzapiMediaError("Invalid PKCS7 padding")
82
+
83
+ return plaintext_padded[:-pad_len]
84
+
85
+
86
+ async def download_media(
87
+ url: str,
88
+ media_key: str,
89
+ mimetype: str,
90
+ *,
91
+ file_sha256: str | None = None,
92
+ file_enc_sha256: str | None = None,
93
+ file_name: str | None = None,
94
+ http_client: httpx.AsyncClient | None = None,
95
+ ) -> MediaDownloadResult:
96
+ logger.info("Downloading media url=%s mimetype=%s", url, mimetype)
97
+
98
+ client = http_client or httpx.AsyncClient(follow_redirects=True, timeout=30)
99
+ owns_client = http_client is None
100
+
101
+ try:
102
+ resp = await client.get(url)
103
+ resp.raise_for_status()
104
+ except httpx.HTTPStatusError as exc:
105
+ logger.error("CDN fetch failed: %s", exc)
106
+ raise WazzapiMediaError(f"CDN fetch failed: {exc.response.status_code}") from exc
107
+ except httpx.RequestError as exc:
108
+ logger.error("CDN network error: %s", exc)
109
+ raise WazzapiMediaError(f"CDN fetch error: {exc}") from exc
110
+ finally:
111
+ if owns_client:
112
+ await client.aclose()
113
+
114
+ enc_data = resp.content
115
+
116
+ if file_enc_sha256:
117
+ expected_enc = base64.b64decode(file_enc_sha256)
118
+ actual_enc = hashlib.sha256(enc_data).digest()
119
+ if actual_enc != expected_enc:
120
+ raise WazzapiMediaError(
121
+ f"file_enc_sha256 mismatch: CDN returned wrong data (got {len(enc_data)} bytes)"
122
+ )
123
+
124
+ plaintext = _decrypt_wa_media(media_key, enc_data, mimetype)
125
+
126
+ if file_sha256:
127
+ expected = base64.b64decode(file_sha256)
128
+ actual = hashlib.sha256(plaintext).digest()
129
+ if actual != expected:
130
+ raise WazzapiMediaError("file_sha256 mismatch after decryption")
131
+
132
+ result = MediaDownloadResult(
133
+ content=plaintext,
134
+ mimetype=mimetype,
135
+ file_name=file_name or "media",
136
+ file_size=len(plaintext),
137
+ )
138
+ logger.info("Media download success url=%s size=%d", url, result.file_size)
139
+ return result
140
+
141
+
142
+ __all__ = [
143
+ "MediaDownloadResult",
144
+ "_decrypt_wa_media",
145
+ "_media_info_bytes",
146
+ "download_media",
147
+ ]
@@ -1,6 +1,10 @@
1
1
  from .base import WazzapiModel
2
2
  from .contacts import *
3
3
  from .contacts import __all__ as contacts_all
4
+ from .groups import *
5
+ from .groups import __all__ as groups_all
6
+ from .media import *
7
+ from .media import __all__ as media_all
4
8
  from .messages import *
5
9
  from .messages import __all__ as messages_all
6
10
  from .templates import *
@@ -8,4 +12,4 @@ from .templates import __all__ as templates_all
8
12
  from .webhooks import *
9
13
  from .webhooks import __all__ as webhooks_all
10
14
 
11
- __all__ = ["WazzapiModel", *contacts_all, *messages_all, *templates_all, *webhooks_all]
15
+ __all__ = ["WazzapiModel", *contacts_all, *groups_all, *media_all, *messages_all, *templates_all, *webhooks_all]
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from .base import WazzapiModel
7
+
8
+
9
+ class GroupListItem(WazzapiModel):
10
+ id: str
11
+ name: str
12
+ description: str | None = None
13
+ owner: str | None = None
14
+ participants_count: int = 0
15
+
16
+
17
+ class GroupListResponse(WazzapiModel):
18
+ groups: list[GroupListItem]
19
+ total: int
20
+
21
+
22
+ class GroupDetailResponse(WazzapiModel):
23
+ id: str
24
+ name: str
25
+ description: str | None = None
26
+ owner: str | None = None
27
+ participants_count: int = 0
28
+
29
+
30
+ class GroupParticipantItem(WazzapiModel):
31
+ id: str
32
+ is_admin: bool = False
33
+ is_super_admin: bool = False
34
+
35
+
36
+ class GroupParticipantsResponse(WazzapiModel):
37
+ participants: list[GroupParticipantItem]
38
+
39
+
40
+ class GroupOperationResponseModel(WazzapiModel):
41
+ success: bool
42
+ details: str
43
+
44
+
45
+ class CreateGroupRequest(WazzapiModel):
46
+ session_name: str
47
+ name: str
48
+ participants: list[str]
49
+
50
+
51
+ class CreateGroupResponseModel(WazzapiModel):
52
+ success: bool
53
+ jid: str
54
+ name: str
55
+ owner_jid: str | None = None
56
+ group_created: str | None = None
57
+
58
+
59
+ class SendGroupTextRequest(WazzapiModel):
60
+ session_name: str
61
+ group_jid: str
62
+ text: str
63
+ message_id: str | None = None
64
+
65
+
66
+ class SendGroupMediaRequest(WazzapiModel):
67
+ session_name: str
68
+ group_jid: str
69
+ media_url: str
70
+ media_type: str
71
+ caption: str | None = None
72
+ filename: str | None = None
73
+
74
+
75
+ class SendGroupResponse(WazzapiModel):
76
+ success: bool
77
+ message_id: str
78
+ status: str
79
+ run_id: str | None = None
80
+
81
+
82
+ class UpdateParticipantsRequest(WazzapiModel):
83
+ session_name: str
84
+ group_jid: str
85
+ action: str
86
+ participants: list[str]
87
+
88
+
89
+ class GroupInviteLinkResponseModel(WazzapiModel):
90
+ success: bool
91
+ invite_link: str
92
+
93
+
94
+ class GroupInviteInfoResponseModel(WazzapiModel):
95
+ success: bool
96
+ jid: str
97
+ name: str
98
+ participants: int = 0
99
+ owner_jid: str | None = None
100
+
101
+
102
+ class InviteInfoRequest(WazzapiModel):
103
+ session_name: str
104
+ invite_link: str
105
+
106
+
107
+ class JoinGroupRequest(WazzapiModel):
108
+ session_name: str
109
+ invite_link: str
110
+
111
+
112
+ class SetGroupNameRequest(WazzapiModel):
113
+ session_name: str
114
+ name: str
115
+
116
+
117
+ class SetGroupTopicRequest(WazzapiModel):
118
+ session_name: str
119
+ topic: str
120
+
121
+
122
+ class SetGroupPhotoRequest(WazzapiModel):
123
+ session_name: str
124
+ image_data_uri: str
125
+
126
+
127
+ class SetGroupAnnounceRequest(WazzapiModel):
128
+ session_name: str
129
+ announce: bool
130
+
131
+
132
+ class SetGroupLockedRequest(WazzapiModel):
133
+ session_name: str
134
+ locked: bool
135
+
136
+
137
+ class SetGroupEphemeralRequest(WazzapiModel):
138
+ session_name: str
139
+ duration: str
140
+
141
+
142
+ __all__ = [
143
+ "CreateGroupRequest",
144
+ "CreateGroupResponseModel",
145
+ "GroupDetailResponse",
146
+ "GroupInviteInfoResponseModel",
147
+ "GroupInviteLinkResponseModel",
148
+ "GroupListItem",
149
+ "GroupListResponse",
150
+ "GroupOperationResponseModel",
151
+ "GroupParticipantItem",
152
+ "GroupParticipantsResponse",
153
+ "InviteInfoRequest",
154
+ "JoinGroupRequest",
155
+ "SendGroupMediaRequest",
156
+ "SendGroupResponse",
157
+ "SendGroupTextRequest",
158
+ "SetGroupAnnounceRequest",
159
+ "SetGroupEphemeralRequest",
160
+ "SetGroupLockedRequest",
161
+ "SetGroupNameRequest",
162
+ "SetGroupPhotoRequest",
163
+ "SetGroupTopicRequest",
164
+ "UpdateParticipantsRequest",
165
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import WazzapiModel
4
+
5
+
6
+ class MediaDownloadResult(WazzapiModel):
7
+ content: bytes
8
+ mimetype: str
9
+ file_name: str
10
+ file_size: int
11
+
12
+
13
+ __all__ = ["MediaDownloadResult"]
@@ -0,0 +1,6 @@
1
+ from .contacts import ContactsResource
2
+ from .groups import GroupsResource
3
+ from .messages import MessagesResource
4
+ from .templates import TemplatesResource
5
+
6
+ __all__ = ["ContactsResource", "GroupsResource", "MessagesResource", "TemplatesResource"]
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .. import models
6
+ from .base import BaseResource
7
+
8
+
9
+ class GroupsResource(BaseResource):
10
+ def list(
11
+ self,
12
+ *,
13
+ session_name: str,
14
+ limit: int | None = None,
15
+ offset: int | None = None,
16
+ ) -> models.GroupListResponse:
17
+ return self._client._request(
18
+ "GET",
19
+ "/api/v1/groups",
20
+ params={
21
+ "session_name": session_name,
22
+ "limit": limit,
23
+ "offset": offset,
24
+ },
25
+ response_model=models.GroupListResponse,
26
+ )
27
+
28
+ def get(
29
+ self,
30
+ group_jid: str,
31
+ *,
32
+ session_name: str,
33
+ ) -> models.GroupDetailResponse:
34
+ return self._client._request(
35
+ "GET",
36
+ f"/api/v1/groups/{group_jid}",
37
+ params={"session_name": session_name},
38
+ response_model=models.GroupDetailResponse,
39
+ )
40
+
41
+ def get_participants(
42
+ self,
43
+ group_jid: str,
44
+ *,
45
+ session_name: str,
46
+ ) -> models.GroupParticipantsResponse:
47
+ return self._client._request(
48
+ "GET",
49
+ f"/api/v1/groups/{group_jid}/participants",
50
+ params={"session_name": session_name},
51
+ response_model=models.GroupParticipantsResponse,
52
+ )
53
+
54
+ def create(
55
+ self,
56
+ payload: models.CreateGroupRequest | dict[str, Any],
57
+ ) -> models.CreateGroupResponseModel:
58
+ return self._client._request(
59
+ "POST",
60
+ "/api/v1/groups/create",
61
+ json_body=payload,
62
+ response_model=models.CreateGroupResponseModel,
63
+ )
64
+
65
+ def send_text(
66
+ self,
67
+ payload: models.SendGroupTextRequest | dict[str, Any],
68
+ ) -> models.SendGroupResponse:
69
+ return self._client._request(
70
+ "POST",
71
+ "/api/v1/groups/send",
72
+ json_body=payload,
73
+ response_model=models.SendGroupResponse,
74
+ )
75
+
76
+ def send_media(
77
+ self,
78
+ payload: models.SendGroupMediaRequest | dict[str, Any],
79
+ ) -> models.SendGroupResponse:
80
+ return self._client._request(
81
+ "POST",
82
+ "/api/v1/groups/send/media",
83
+ json_body=payload,
84
+ response_model=models.SendGroupResponse,
85
+ )
86
+
87
+ def update_participants(
88
+ self,
89
+ payload: models.UpdateParticipantsRequest | dict[str, Any],
90
+ ) -> models.GroupOperationResponseModel:
91
+ return self._client._request(
92
+ "POST",
93
+ "/api/v1/groups/participants",
94
+ json_body=payload,
95
+ response_model=models.GroupOperationResponseModel,
96
+ )
97
+
98
+ def add_participant(
99
+ self,
100
+ group_jid: str,
101
+ *,
102
+ session_name: str,
103
+ participant_jid: str,
104
+ ) -> models.GroupOperationResponseModel:
105
+ return self._client._request(
106
+ "POST",
107
+ f"/api/v1/groups/{group_jid}/participants/add",
108
+ params={
109
+ "session_name": session_name,
110
+ "participant_jid": participant_jid,
111
+ },
112
+ response_model=models.GroupOperationResponseModel,
113
+ )
114
+
115
+ def remove_participant(
116
+ self,
117
+ group_jid: str,
118
+ *,
119
+ session_name: str,
120
+ participant_jid: str,
121
+ ) -> models.GroupOperationResponseModel:
122
+ return self._client._request(
123
+ "POST",
124
+ f"/api/v1/groups/{group_jid}/participants/remove",
125
+ params={
126
+ "session_name": session_name,
127
+ "participant_jid": participant_jid,
128
+ },
129
+ response_model=models.GroupOperationResponseModel,
130
+ )
131
+
132
+ def get_invite_link(
133
+ self,
134
+ group_jid: str,
135
+ *,
136
+ session_name: str,
137
+ ) -> models.GroupInviteLinkResponseModel:
138
+ return self._client._request(
139
+ "POST",
140
+ f"/api/v1/groups/{group_jid}/invite-link",
141
+ params={"session_name": session_name},
142
+ response_model=models.GroupInviteLinkResponseModel,
143
+ )
144
+
145
+ def get_invite_info(
146
+ self,
147
+ payload: models.InviteInfoRequest | dict[str, Any],
148
+ ) -> models.GroupInviteInfoResponseModel:
149
+ return self._client._request(
150
+ "POST",
151
+ "/api/v1/groups/invite-info",
152
+ json_body=payload,
153
+ response_model=models.GroupInviteInfoResponseModel,
154
+ )
155
+
156
+ def join(
157
+ self,
158
+ payload: models.JoinGroupRequest | dict[str, Any],
159
+ ) -> models.GroupOperationResponseModel:
160
+ return self._client._request(
161
+ "POST",
162
+ "/api/v1/groups/join",
163
+ json_body=payload,
164
+ response_model=models.GroupOperationResponseModel,
165
+ )
166
+
167
+ def leave(
168
+ self,
169
+ group_jid: str,
170
+ *,
171
+ session_name: str,
172
+ ) -> models.GroupOperationResponseModel:
173
+ return self._client._request(
174
+ "POST",
175
+ f"/api/v1/groups/{group_jid}/leave",
176
+ params={"session_name": session_name},
177
+ response_model=models.GroupOperationResponseModel,
178
+ )
179
+
180
+ def set_name(
181
+ self,
182
+ group_jid: str,
183
+ payload: models.SetGroupNameRequest | dict[str, Any],
184
+ ) -> models.GroupOperationResponseModel:
185
+ return self._client._request(
186
+ "POST",
187
+ f"/api/v1/groups/{group_jid}/name",
188
+ json_body=payload,
189
+ response_model=models.GroupOperationResponseModel,
190
+ )
191
+
192
+ def set_topic(
193
+ self,
194
+ group_jid: str,
195
+ payload: models.SetGroupTopicRequest | dict[str, Any],
196
+ ) -> models.GroupOperationResponseModel:
197
+ return self._client._request(
198
+ "POST",
199
+ f"/api/v1/groups/{group_jid}/topic",
200
+ json_body=payload,
201
+ response_model=models.GroupOperationResponseModel,
202
+ )
203
+
204
+ def set_photo(
205
+ self,
206
+ group_jid: str,
207
+ payload: models.SetGroupPhotoRequest | dict[str, Any],
208
+ ) -> models.GroupOperationResponseModel:
209
+ return self._client._request(
210
+ "POST",
211
+ f"/api/v1/groups/{group_jid}/photo",
212
+ json_body=payload,
213
+ response_model=models.GroupOperationResponseModel,
214
+ )
215
+
216
+ def remove_photo(
217
+ self,
218
+ group_jid: str,
219
+ *,
220
+ session_name: str,
221
+ ) -> models.GroupOperationResponseModel:
222
+ return self._client._request(
223
+ "DELETE",
224
+ f"/api/v1/groups/{group_jid}/photo",
225
+ params={"session_name": session_name},
226
+ response_model=models.GroupOperationResponseModel,
227
+ )
228
+
229
+ def set_announce(
230
+ self,
231
+ group_jid: str,
232
+ payload: models.SetGroupAnnounceRequest | dict[str, Any],
233
+ ) -> models.GroupOperationResponseModel:
234
+ return self._client._request(
235
+ "POST",
236
+ f"/api/v1/groups/{group_jid}/announce",
237
+ json_body=payload,
238
+ response_model=models.GroupOperationResponseModel,
239
+ )
240
+
241
+ def set_locked(
242
+ self,
243
+ group_jid: str,
244
+ payload: models.SetGroupLockedRequest | dict[str, Any],
245
+ ) -> models.GroupOperationResponseModel:
246
+ return self._client._request(
247
+ "POST",
248
+ f"/api/v1/groups/{group_jid}/locked",
249
+ json_body=payload,
250
+ response_model=models.GroupOperationResponseModel,
251
+ )
252
+
253
+ def set_ephemeral(
254
+ self,
255
+ group_jid: str,
256
+ payload: models.SetGroupEphemeralRequest | dict[str, Any],
257
+ ) -> models.GroupOperationResponseModel:
258
+ return self._client._request(
259
+ "POST",
260
+ f"/api/v1/groups/{group_jid}/ephemeral",
261
+ json_body=payload,
262
+ response_model=models.GroupOperationResponseModel,
263
+ )
264
+
265
+
266
+ __all__ = ["GroupsResource"]
@@ -1,5 +0,0 @@
1
- from .contacts import ContactsResource
2
- from .messages import MessagesResource
3
- from .templates import TemplatesResource
4
-
5
- __all__ = ["ContactsResource", "MessagesResource", "TemplatesResource"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes