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.
- {wazzapi-0.1.1 → wazzapi-0.2.0}/PKG-INFO +2 -1
- {wazzapi-0.1.1 → wazzapi-0.2.0}/pyproject.toml +4 -1
- {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/smoke_test.py +1 -1
- wazzapi-0.2.0/tests/test_groups.py +285 -0
- wazzapi-0.2.0/tests/test_media.py +170 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/__init__.py +6 -2
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/client.py +2 -1
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/errors.py +4 -0
- wazzapi-0.2.0/wazzapi/media.py +147 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/__init__.py +5 -1
- wazzapi-0.2.0/wazzapi/models/groups.py +165 -0
- wazzapi-0.2.0/wazzapi/models/media.py +13 -0
- wazzapi-0.2.0/wazzapi/resources/__init__.py +6 -0
- wazzapi-0.2.0/wazzapi/resources/groups.py +266 -0
- wazzapi-0.1.1/wazzapi/resources/__init__.py +0 -5
- {wazzapi-0.1.1 → wazzapi-0.2.0}/.gitignore +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/README.md +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/create_template.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/list_contacts.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/preview_template.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/send_message.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/examples/verify_webhook.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/test_client.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/test_resources.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/tests/test_webhooks.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/base.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/contacts.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/messages.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/templates.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/models/webhooks.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/py.typed +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/base.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/contacts.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/messages.py +0 -0
- {wazzapi-0.1.1 → wazzapi-0.2.0}/wazzapi/resources/templates.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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.
|
|
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
|
|
|
@@ -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,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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|