blueticks 3.3.0__tar.gz → 3.3.2__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.
- {blueticks-3.3.0/src/blueticks.egg-info → blueticks-3.3.2}/PKG-INFO +1 -1
- blueticks-3.3.2/src/blueticks/_version.py +1 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/audiences.py +9 -3
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/chats.py +39 -16
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/groups.py +22 -15
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/scheduled_messages.py +7 -4
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/webhooks.py +9 -3
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/__init__.py +26 -0
- blueticks-3.3.2/src/blueticks/types/_deleted_resource.py +20 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/chats.py +67 -0
- {blueticks-3.3.0 → blueticks-3.3.2/src/blueticks.egg-info}/PKG-INFO +1 -1
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/SOURCES.txt +3 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_audiences.py +4 -2
- blueticks-3.3.2/tests/test_chats.py +270 -0
- blueticks-3.3.2/tests/test_groups.py +175 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_scheduled_messages.py +9 -3
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_webhooks_resource.py +5 -3
- blueticks-3.3.0/src/blueticks/_version.py +0 -1
- {blueticks-3.3.0 → blueticks-3.3.2}/LICENSE +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/README.md +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/pyproject.toml +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/setup.cfg +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/__init__.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_base_resource.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_client.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_errors.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_transport.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/py.typed +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/__init__.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/account.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/campaigns.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/contacts.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/engines.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/messages.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/ping.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/utils.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/account.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/audiences.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/campaigns.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/contacts.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/engines.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/groups.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/messages.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/page.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/ping.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/scheduled_messages.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/utils.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/webhooks.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/webhooks.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/dependency_links.txt +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/requires.txt +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/top_level.txt +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_account.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_campaigns.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_client.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_errors.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_messages.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_ping.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_transport.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_types.py +0 -0
- {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_webhook_verify.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.3.2"
|
|
@@ -4,6 +4,7 @@ import builtins
|
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from blueticks._base_resource import BaseResource
|
|
7
|
+
from blueticks.types._deleted_resource import DeletedResource
|
|
7
8
|
from blueticks.types.audiences import AppendContactsResult, Audience, Contact
|
|
8
9
|
from blueticks.types.page import Page
|
|
9
10
|
|
|
@@ -47,9 +48,14 @@ class AudiencesResource(BaseResource):
|
|
|
47
48
|
data = self._client._request("PATCH", f"/v1/audiences/{audience_id}", body={"name": name})
|
|
48
49
|
return Audience.model_validate(data)
|
|
49
50
|
|
|
50
|
-
def delete(self, audience_id: str) ->
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
def delete(self, audience_id: str) -> DeletedResource:
|
|
52
|
+
"""Delete audience.
|
|
53
|
+
|
|
54
|
+
Soft-delete an audience. 409 if it`s referenced by an active campaign.
|
|
55
|
+
Returns the deleted ref. Requires ``audiences:write``.
|
|
56
|
+
"""
|
|
57
|
+
data = self._client._request("DELETE", f"/v1/audiences/{audience_id}")
|
|
58
|
+
return DeletedResource.model_validate(data)
|
|
53
59
|
|
|
54
60
|
def append_contacts(
|
|
55
61
|
self,
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Sequence # avoids shadowing by `ChatsResource.list`
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from blueticks._base_resource import BaseResource
|
|
6
|
-
from blueticks.types.chats import
|
|
7
|
+
from blueticks.types.chats import (
|
|
8
|
+
BatchMessageAcksResponse,
|
|
9
|
+
Chat,
|
|
10
|
+
ChatMedia,
|
|
11
|
+
ChatMessage,
|
|
12
|
+
ChatRef,
|
|
13
|
+
LoadOlderMessagesResponse,
|
|
14
|
+
MediaUrlResponse,
|
|
15
|
+
MessageAck,
|
|
16
|
+
MessageType,
|
|
17
|
+
OkResponse,
|
|
18
|
+
Participant,
|
|
19
|
+
)
|
|
7
20
|
from blueticks.types.page import Page
|
|
8
21
|
|
|
9
22
|
|
|
@@ -49,13 +62,15 @@ class ChatsResource(BaseResource):
|
|
|
49
62
|
)
|
|
50
63
|
return Page[Participant].model_validate(data)
|
|
51
64
|
|
|
52
|
-
def mark_read(self, chat_id: str) ->
|
|
65
|
+
def mark_read(self, chat_id: str) -> OkResponse:
|
|
53
66
|
"""Mark a chat as read (sends read receipts if enabled)."""
|
|
54
|
-
|
|
67
|
+
data = self._client._request("POST", f"/v1/chats/{chat_id}/mark_read")
|
|
68
|
+
return OkResponse.model_validate(data)
|
|
55
69
|
|
|
56
|
-
def open(self, chat_id: str) ->
|
|
70
|
+
def open(self, chat_id: str) -> ChatRef:
|
|
57
71
|
"""Open a chat on the engine (useful for UI-assisted workflows)."""
|
|
58
|
-
|
|
72
|
+
data = self._client._request("POST", f"/v1/chats/{chat_id}/open")
|
|
73
|
+
return ChatRef.model_validate(data)
|
|
59
74
|
|
|
60
75
|
def list_messages(
|
|
61
76
|
self,
|
|
@@ -99,35 +114,43 @@ class ChatsResource(BaseResource):
|
|
|
99
114
|
data = self._client._request("GET", f"/v1/chats/{chat_id}/messages/{key}")
|
|
100
115
|
return ChatMessage.model_validate(data)
|
|
101
116
|
|
|
102
|
-
def get_message_ack(self, chat_id: str, key: str) ->
|
|
117
|
+
def get_message_ack(self, chat_id: str, key: str) -> MessageAck:
|
|
103
118
|
"""Fetch ACK state for a single message."""
|
|
104
|
-
|
|
119
|
+
data = self._client._request("GET", f"/v1/chats/{chat_id}/messages/{key}/ack")
|
|
120
|
+
return MessageAck.model_validate(data)
|
|
105
121
|
|
|
106
|
-
def react(self, chat_id: str, key: str, *, emoji: str) ->
|
|
107
|
-
"""Add or clear an emoji reaction on a message.
|
|
108
|
-
|
|
122
|
+
def react(self, chat_id: str, key: str, *, emoji: str) -> OkResponse:
|
|
123
|
+
"""Add or clear an emoji reaction on a message.
|
|
124
|
+
|
|
125
|
+
Pass an empty string for ``emoji`` to clear an existing reaction.
|
|
126
|
+
"""
|
|
127
|
+
data = self._client._request(
|
|
109
128
|
"POST",
|
|
110
129
|
f"/v1/chats/{chat_id}/messages/{key}/reactions",
|
|
111
130
|
body={"emoji": emoji},
|
|
112
131
|
)
|
|
132
|
+
return OkResponse.model_validate(data)
|
|
113
133
|
|
|
114
|
-
def load_older_messages(self, chat_id: str) ->
|
|
134
|
+
def load_older_messages(self, chat_id: str) -> LoadOlderMessagesResponse:
|
|
115
135
|
"""Pull older messages from the phone into the engine's local store."""
|
|
116
|
-
|
|
136
|
+
data = self._client._request("POST", f"/v1/chats/{chat_id}/messages/load_older")
|
|
137
|
+
return LoadOlderMessagesResponse.model_validate(data)
|
|
117
138
|
|
|
118
139
|
def get_media(self, chat_id: str, key: str) -> ChatMedia:
|
|
119
140
|
"""Download message media (may be returned as base64)."""
|
|
120
141
|
data = self._client._request("GET", f"/v1/chats/{chat_id}/messages/{key}/media")
|
|
121
142
|
return ChatMedia.model_validate(data)
|
|
122
143
|
|
|
123
|
-
def get_media_url(self, chat_id: str, key: str) ->
|
|
144
|
+
def get_media_url(self, chat_id: str, key: str) -> MediaUrlResponse:
|
|
124
145
|
"""Get a short-lived URL for message media, if available."""
|
|
125
|
-
|
|
146
|
+
data = self._client._request("GET", f"/v1/chats/{chat_id}/messages/{key}/media_url")
|
|
147
|
+
return MediaUrlResponse.model_validate(data)
|
|
126
148
|
|
|
127
|
-
def batch_message_acks(self, *, message_keys:
|
|
149
|
+
def batch_message_acks(self, *, message_keys: Sequence[str]) -> BatchMessageAcksResponse:
|
|
128
150
|
"""Batch-fetch ACK data for up to 200 message keys at once."""
|
|
129
|
-
|
|
151
|
+
data = self._client._request(
|
|
130
152
|
"POST",
|
|
131
153
|
"/v1/chats/message_acks",
|
|
132
154
|
body={"message_keys": message_keys},
|
|
133
155
|
)
|
|
156
|
+
return BatchMessageAcksResponse.model_validate(data)
|
|
@@ -25,32 +25,37 @@ class GroupsResource(BaseResource):
|
|
|
25
25
|
*,
|
|
26
26
|
name: str | None = None,
|
|
27
27
|
settings: dict[str, bool] | None = None,
|
|
28
|
-
) ->
|
|
28
|
+
) -> Group:
|
|
29
29
|
"""Rename the group and/or update admin-only settings."""
|
|
30
30
|
body: dict[str, Any] = {}
|
|
31
31
|
if name is not None:
|
|
32
32
|
body["name"] = name
|
|
33
33
|
if settings is not None:
|
|
34
34
|
body["settings"] = settings
|
|
35
|
-
|
|
35
|
+
data = self._client._request("PATCH", f"/v1/groups/{group_id}", body=body)
|
|
36
|
+
return Group.model_validate(data)
|
|
36
37
|
|
|
37
|
-
def add_member(self, group_id: str, *, chat_id: str) ->
|
|
38
|
+
def add_member(self, group_id: str, *, chat_id: str) -> Group:
|
|
38
39
|
"""Invite a contact to the group."""
|
|
39
|
-
|
|
40
|
+
data = self._client._request(
|
|
40
41
|
"POST", f"/v1/groups/{group_id}/members", body={"chat_id": chat_id}
|
|
41
42
|
)
|
|
43
|
+
return Group.model_validate(data)
|
|
42
44
|
|
|
43
|
-
def remove_member(self, group_id: str, chat_id: str) ->
|
|
45
|
+
def remove_member(self, group_id: str, chat_id: str) -> Group:
|
|
44
46
|
"""Remove a participant from the group."""
|
|
45
|
-
|
|
47
|
+
data = self._client._request("DELETE", f"/v1/groups/{group_id}/members/{chat_id}")
|
|
48
|
+
return Group.model_validate(data)
|
|
46
49
|
|
|
47
|
-
def promote_admin(self, group_id: str, chat_id: str) ->
|
|
50
|
+
def promote_admin(self, group_id: str, chat_id: str) -> Group:
|
|
48
51
|
"""Grant admin rights to a participant."""
|
|
49
|
-
|
|
52
|
+
data = self._client._request("POST", f"/v1/groups/{group_id}/members/{chat_id}/admin")
|
|
53
|
+
return Group.model_validate(data)
|
|
50
54
|
|
|
51
|
-
def demote_admin(self, group_id: str, chat_id: str) ->
|
|
55
|
+
def demote_admin(self, group_id: str, chat_id: str) -> Group:
|
|
52
56
|
"""Revoke admin rights from a participant."""
|
|
53
|
-
|
|
57
|
+
data = self._client._request("DELETE", f"/v1/groups/{group_id}/members/{chat_id}/admin")
|
|
58
|
+
return Group.model_validate(data)
|
|
54
59
|
|
|
55
60
|
def set_picture(
|
|
56
61
|
self,
|
|
@@ -59,15 +64,17 @@ class GroupsResource(BaseResource):
|
|
|
59
64
|
file_data_url: str,
|
|
60
65
|
file_name: str | None = None,
|
|
61
66
|
file_mime_type: str | None = None,
|
|
62
|
-
) ->
|
|
67
|
+
) -> Group:
|
|
63
68
|
"""Upload a new group avatar (base64 data URL)."""
|
|
64
69
|
body: dict[str, Any] = {"file_data_url": file_data_url}
|
|
65
70
|
if file_name is not None:
|
|
66
71
|
body["file_name"] = file_name
|
|
67
72
|
if file_mime_type is not None:
|
|
68
73
|
body["file_mime_type"] = file_mime_type
|
|
69
|
-
|
|
74
|
+
data = self._client._request("PUT", f"/v1/groups/{group_id}/picture", body=body)
|
|
75
|
+
return Group.model_validate(data)
|
|
70
76
|
|
|
71
|
-
def leave(self, group_id: str) ->
|
|
72
|
-
"""Leave the group (as the authenticated user)."""
|
|
73
|
-
|
|
77
|
+
def leave(self, group_id: str) -> None:
|
|
78
|
+
"""Leave the group (as the authenticated user). Returns 204 No Content."""
|
|
79
|
+
self._client._request("DELETE", f"/v1/groups/{group_id}/members/me")
|
|
80
|
+
return None
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
from blueticks._base_resource import BaseResource
|
|
6
|
+
from blueticks.types._deleted_resource import DeletedResource
|
|
6
7
|
from blueticks.types.page import Page
|
|
7
8
|
from blueticks.types.scheduled_messages import ScheduledMessage
|
|
8
9
|
|
|
@@ -61,10 +62,12 @@ class ScheduledMessagesResource(BaseResource):
|
|
|
61
62
|
)
|
|
62
63
|
return ScheduledMessage.model_validate(data)
|
|
63
64
|
|
|
64
|
-
def delete(self, scheduled_message_id: str) ->
|
|
65
|
+
def delete(self, scheduled_message_id: str) -> DeletedResource:
|
|
65
66
|
"""Cancel scheduled message.
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
Cancel a queued scheduled message before it fires. Soft-deletes the row
|
|
69
|
+
(still queryable in audit logs). Returns the deleted ref. Requires
|
|
70
|
+
``messages:write``.
|
|
68
71
|
"""
|
|
69
|
-
self._client._request("DELETE", f"/v1/scheduled-messages/{scheduled_message_id}")
|
|
70
|
-
return
|
|
72
|
+
data = self._client._request("DELETE", f"/v1/scheduled-messages/{scheduled_message_id}")
|
|
73
|
+
return DeletedResource.model_validate(data)
|
|
@@ -4,6 +4,7 @@ import builtins
|
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from blueticks._base_resource import BaseResource
|
|
7
|
+
from blueticks.types._deleted_resource import DeletedResource
|
|
7
8
|
from blueticks.types.page import Page
|
|
8
9
|
from blueticks.types.webhooks import Webhook, WebhookCreateResult
|
|
9
10
|
|
|
@@ -66,9 +67,14 @@ class WebhooksResource(BaseResource):
|
|
|
66
67
|
data = self._client._request("PATCH", f"/v1/webhooks/{webhook_id}", body=body)
|
|
67
68
|
return Webhook.model_validate(data)
|
|
68
69
|
|
|
69
|
-
def delete(self, webhook_id: str) ->
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
def delete(self, webhook_id: str) -> DeletedResource:
|
|
71
|
+
"""Delete webhook.
|
|
72
|
+
|
|
73
|
+
Delete a webhook subscription. Returns the deleted ref. Requires
|
|
74
|
+
``webhooks:write``.
|
|
75
|
+
"""
|
|
76
|
+
data = self._client._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
|
77
|
+
return DeletedResource.model_validate(data)
|
|
72
78
|
|
|
73
79
|
def rotate_secret(self, webhook_id: str) -> WebhookCreateResult:
|
|
74
80
|
data = self._client._request("POST", f"/v1/webhooks/{webhook_id}/rotate-secret")
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
from blueticks.types._deleted_resource import DeletedResource
|
|
1
2
|
from blueticks.types.account import Account
|
|
2
3
|
from blueticks.types.audiences import AppendContactsResult, Audience, Contact
|
|
3
4
|
from blueticks.types.campaigns import Campaign
|
|
5
|
+
from blueticks.types.chats import (
|
|
6
|
+
BatchMessageAckEntry,
|
|
7
|
+
BatchMessageAcksResponse,
|
|
8
|
+
Chat,
|
|
9
|
+
ChatMedia,
|
|
10
|
+
ChatMessage,
|
|
11
|
+
ChatRef,
|
|
12
|
+
LoadOlderMessagesResponse,
|
|
13
|
+
MediaUrlResponse,
|
|
14
|
+
MessageAck,
|
|
15
|
+
OkResponse,
|
|
16
|
+
Participant,
|
|
17
|
+
)
|
|
4
18
|
from blueticks.types.groups import Group, GroupParticipant
|
|
5
19
|
from blueticks.types.messages import Message
|
|
6
20
|
from blueticks.types.page import Page
|
|
@@ -12,12 +26,24 @@ __all__ = [
|
|
|
12
26
|
"Account",
|
|
13
27
|
"AppendContactsResult",
|
|
14
28
|
"Audience",
|
|
29
|
+
"BatchMessageAckEntry",
|
|
30
|
+
"BatchMessageAcksResponse",
|
|
15
31
|
"Campaign",
|
|
32
|
+
"Chat",
|
|
33
|
+
"ChatMedia",
|
|
34
|
+
"ChatMessage",
|
|
35
|
+
"ChatRef",
|
|
16
36
|
"Contact",
|
|
37
|
+
"DeletedResource",
|
|
17
38
|
"Group",
|
|
18
39
|
"GroupParticipant",
|
|
40
|
+
"LoadOlderMessagesResponse",
|
|
41
|
+
"MediaUrlResponse",
|
|
19
42
|
"Message",
|
|
43
|
+
"MessageAck",
|
|
44
|
+
"OkResponse",
|
|
20
45
|
"Page",
|
|
46
|
+
"Participant",
|
|
21
47
|
"Ping",
|
|
22
48
|
"ScheduledMessage",
|
|
23
49
|
"Webhook",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DeletedResource(BaseModel):
|
|
9
|
+
"""Response envelope returned by DELETE endpoints.
|
|
10
|
+
|
|
11
|
+
Returned by:
|
|
12
|
+
- DELETE /v1/audiences/{id}
|
|
13
|
+
- DELETE /v1/scheduled-messages/{id}
|
|
14
|
+
- DELETE /v1/webhooks/{id}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="ignore")
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
deleted: Literal[True]
|
|
@@ -106,3 +106,70 @@ class ChatMedia(BaseModel):
|
|
|
106
106
|
"/ location / vcard."
|
|
107
107
|
),
|
|
108
108
|
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OkResponse(BaseModel):
|
|
112
|
+
"""Generic ok envelope returned by mark_read and react endpoints."""
|
|
113
|
+
|
|
114
|
+
model_config = ConfigDict(extra="ignore")
|
|
115
|
+
|
|
116
|
+
ok: Literal[True]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ChatRef(BaseModel):
|
|
120
|
+
"""Response from POST /v1/chats/{chat_id}/open."""
|
|
121
|
+
|
|
122
|
+
model_config = ConfigDict(extra="ignore")
|
|
123
|
+
|
|
124
|
+
chat_id: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MessageAck(BaseModel):
|
|
128
|
+
"""Response from GET /v1/chats/{chat_id}/messages/{key}/ack.
|
|
129
|
+
|
|
130
|
+
`ack` codes: -1=error, 0=pending, 1=server, 2=device, 3=read, 4=played;
|
|
131
|
+
None when no engine response.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
model_config = ConfigDict(extra="ignore")
|
|
135
|
+
|
|
136
|
+
ack: Optional[int] = None # noqa: UP045
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class LoadOlderMessagesResponse(BaseModel):
|
|
140
|
+
"""Response from POST /v1/chats/{chat_id}/messages/load_older."""
|
|
141
|
+
|
|
142
|
+
model_config = ConfigDict(extra="ignore")
|
|
143
|
+
|
|
144
|
+
total_messages: Optional[int] = None # noqa: UP045
|
|
145
|
+
added: Optional[int] = None # noqa: UP045
|
|
146
|
+
can_load_more: bool
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class MediaUrlResponse(BaseModel):
|
|
150
|
+
"""Response from GET /v1/chats/{chat_id}/messages/{key}/media_url."""
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(extra="ignore")
|
|
153
|
+
|
|
154
|
+
url: Optional[str] = None # noqa: UP045
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class BatchMessageAckEntry(BaseModel):
|
|
158
|
+
"""One entry in :class:`BatchMessageAcksResponse.data`.
|
|
159
|
+
|
|
160
|
+
`ack` codes: -1=error, 0=pending, 1=server, 2=device, 3=read, 4=played;
|
|
161
|
+
`None` when the engine has not yet returned a status for this key.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(extra="ignore")
|
|
165
|
+
|
|
166
|
+
key: str
|
|
167
|
+
ack: Optional[int] = None # noqa: UP045
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class BatchMessageAcksResponse(BaseModel):
|
|
171
|
+
"""Response from POST /v1/chats/message_acks."""
|
|
172
|
+
|
|
173
|
+
model_config = ConfigDict(extra="ignore")
|
|
174
|
+
|
|
175
|
+
data: list[BatchMessageAckEntry]
|
|
@@ -28,6 +28,7 @@ src/blueticks/resources/scheduled_messages.py
|
|
|
28
28
|
src/blueticks/resources/utils.py
|
|
29
29
|
src/blueticks/resources/webhooks.py
|
|
30
30
|
src/blueticks/types/__init__.py
|
|
31
|
+
src/blueticks/types/_deleted_resource.py
|
|
31
32
|
src/blueticks/types/account.py
|
|
32
33
|
src/blueticks/types/audiences.py
|
|
33
34
|
src/blueticks/types/campaigns.py
|
|
@@ -44,8 +45,10 @@ src/blueticks/types/webhooks.py
|
|
|
44
45
|
tests/test_account.py
|
|
45
46
|
tests/test_audiences.py
|
|
46
47
|
tests/test_campaigns.py
|
|
48
|
+
tests/test_chats.py
|
|
47
49
|
tests/test_client.py
|
|
48
50
|
tests/test_errors.py
|
|
51
|
+
tests/test_groups.py
|
|
49
52
|
tests/test_messages.py
|
|
50
53
|
tests/test_ping.py
|
|
51
54
|
tests/test_scheduled_messages.py
|
|
@@ -99,10 +99,12 @@ def test_delete_audience():
|
|
|
99
99
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
100
100
|
assert req.method == "DELETE"
|
|
101
101
|
assert req.url.path == "/v1/audiences/aud_1"
|
|
102
|
-
return httpx.Response(
|
|
102
|
+
return httpx.Response(200, json={"id": "aud_1", "deleted": True})
|
|
103
103
|
|
|
104
104
|
client = Blueticks(api_key="bt_live_test", _http_transport=httpx.MockTransport(handler))
|
|
105
|
-
|
|
105
|
+
result = client.audiences.delete("aud_1")
|
|
106
|
+
assert result.id == "aud_1"
|
|
107
|
+
assert result.deleted is True
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
def test_append_contacts():
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from blueticks import Blueticks
|
|
10
|
+
from blueticks._errors import AuthenticationError
|
|
11
|
+
from blueticks.types.chats import (
|
|
12
|
+
BatchMessageAcksResponse,
|
|
13
|
+
Chat,
|
|
14
|
+
ChatMedia,
|
|
15
|
+
ChatMessage,
|
|
16
|
+
ChatRef,
|
|
17
|
+
LoadOlderMessagesResponse,
|
|
18
|
+
MediaUrlResponse,
|
|
19
|
+
MessageAck,
|
|
20
|
+
OkResponse,
|
|
21
|
+
Participant,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _client(handler):
|
|
26
|
+
return Blueticks(api_key="bt_live_test", _http_transport=httpx.MockTransport(handler))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _chat(cid: str = "chat_1", **overrides) -> dict:
|
|
30
|
+
data = {
|
|
31
|
+
"id": cid,
|
|
32
|
+
"name": "Acme",
|
|
33
|
+
"is_group": False,
|
|
34
|
+
"last_message_at": "2026-04-23T00:00:00Z",
|
|
35
|
+
"unread_count": 3,
|
|
36
|
+
}
|
|
37
|
+
data.update(overrides)
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _chat_message(**overrides) -> dict:
|
|
42
|
+
data = {
|
|
43
|
+
"key": "msg_key_1",
|
|
44
|
+
"chat_id": "chat_1",
|
|
45
|
+
"from": "+15551234567",
|
|
46
|
+
"timestamp": "2026-04-23T00:00:00Z",
|
|
47
|
+
"text": "hello",
|
|
48
|
+
"type": "chat",
|
|
49
|
+
"from_me": False,
|
|
50
|
+
"ack": 3,
|
|
51
|
+
"media_url": None,
|
|
52
|
+
"caption": None,
|
|
53
|
+
"filename": None,
|
|
54
|
+
}
|
|
55
|
+
data.update(overrides)
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_list_chats_returns_page_of_chats():
|
|
60
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
61
|
+
assert req.method == "GET"
|
|
62
|
+
assert req.url.path == "/v1/chats"
|
|
63
|
+
return httpx.Response(
|
|
64
|
+
200,
|
|
65
|
+
json={
|
|
66
|
+
"data": [_chat("chat_1"), _chat("chat_2")],
|
|
67
|
+
"has_more": False,
|
|
68
|
+
"next_cursor": None,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
page = _client(handler).chats.list(query="acme", limit=10)
|
|
73
|
+
assert len(page.data) == 2
|
|
74
|
+
assert isinstance(page.data[0], Chat)
|
|
75
|
+
assert page.data[0].id == "chat_1"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_get_chat_returns_typed_model():
|
|
79
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
80
|
+
assert req.url.path == "/v1/chats/chat_1"
|
|
81
|
+
return httpx.Response(200, json=_chat("chat_1"))
|
|
82
|
+
|
|
83
|
+
chat = _client(handler).chats.get("chat_1")
|
|
84
|
+
assert isinstance(chat, Chat)
|
|
85
|
+
assert chat.unread_count == 3
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_list_participants_returns_page():
|
|
89
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
90
|
+
assert req.url.path == "/v1/chats/chat_1/participants"
|
|
91
|
+
return httpx.Response(
|
|
92
|
+
200,
|
|
93
|
+
json={
|
|
94
|
+
"data": [{"chat_id": "12345@c.us", "is_admin": True, "is_super_admin": False}],
|
|
95
|
+
"has_more": False,
|
|
96
|
+
"next_cursor": None,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
page = _client(handler).chats.list_participants("chat_1")
|
|
101
|
+
assert isinstance(page.data[0], Participant)
|
|
102
|
+
assert page.data[0].is_admin is True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_mark_read_returns_ok_response():
|
|
106
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
107
|
+
assert req.method == "POST"
|
|
108
|
+
assert req.url.path == "/v1/chats/chat_1/mark_read"
|
|
109
|
+
return httpx.Response(200, json={"ok": True})
|
|
110
|
+
|
|
111
|
+
result = _client(handler).chats.mark_read("chat_1")
|
|
112
|
+
assert isinstance(result, OkResponse)
|
|
113
|
+
assert result.ok is True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_open_returns_chat_ref():
|
|
117
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
118
|
+
assert req.url.path == "/v1/chats/chat_1/open"
|
|
119
|
+
return httpx.Response(200, json={"chat_id": "chat_1"})
|
|
120
|
+
|
|
121
|
+
result = _client(handler).chats.open("chat_1")
|
|
122
|
+
assert isinstance(result, ChatRef)
|
|
123
|
+
assert result.chat_id == "chat_1"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_list_messages_returns_page_of_chat_messages():
|
|
127
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
128
|
+
assert req.url.path == "/v1/chats/chat_1/messages"
|
|
129
|
+
assert req.url.params.get("mode") == "history"
|
|
130
|
+
assert req.url.params.get("message_types") == "image,video"
|
|
131
|
+
return httpx.Response(
|
|
132
|
+
200,
|
|
133
|
+
json={"data": [_chat_message()], "has_more": True, "next_cursor": "cur_2"},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
page = _client(handler).chats.list_messages(
|
|
137
|
+
"chat_1", mode="history", message_types=["image", "video"]
|
|
138
|
+
)
|
|
139
|
+
assert isinstance(page.data[0], ChatMessage)
|
|
140
|
+
assert page.data[0].from_ == "+15551234567"
|
|
141
|
+
assert page.next_cursor == "cur_2"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_get_message_returns_chat_message():
|
|
145
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
146
|
+
assert req.url.path == "/v1/chats/chat_1/messages/msg_key_1"
|
|
147
|
+
return httpx.Response(200, json=_chat_message())
|
|
148
|
+
|
|
149
|
+
msg = _client(handler).chats.get_message("chat_1", "msg_key_1")
|
|
150
|
+
assert isinstance(msg, ChatMessage)
|
|
151
|
+
assert msg.text == "hello"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_get_message_ack_returns_typed_model():
|
|
155
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
156
|
+
assert req.url.path == "/v1/chats/chat_1/messages/msg_key_1/ack"
|
|
157
|
+
return httpx.Response(200, json={"ack": 3})
|
|
158
|
+
|
|
159
|
+
result = _client(handler).chats.get_message_ack("chat_1", "msg_key_1")
|
|
160
|
+
assert isinstance(result, MessageAck)
|
|
161
|
+
assert result.ack == 3
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_react_returns_ok_response():
|
|
165
|
+
body_seen: dict = {}
|
|
166
|
+
|
|
167
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
168
|
+
body_seen.update(json.loads(req.content))
|
|
169
|
+
assert req.url.path == "/v1/chats/chat_1/messages/msg_key_1/reactions"
|
|
170
|
+
return httpx.Response(200, json={"ok": True})
|
|
171
|
+
|
|
172
|
+
result = _client(handler).chats.react("chat_1", "msg_key_1", emoji="🔥")
|
|
173
|
+
assert isinstance(result, OkResponse)
|
|
174
|
+
assert body_seen == {"emoji": "🔥"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_load_older_messages_returns_typed_model():
|
|
178
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
179
|
+
assert req.method == "POST"
|
|
180
|
+
assert req.url.path == "/v1/chats/chat_1/messages/load_older"
|
|
181
|
+
return httpx.Response(200, json={"total_messages": 124, "added": 24, "can_load_more": True})
|
|
182
|
+
|
|
183
|
+
result = _client(handler).chats.load_older_messages("chat_1")
|
|
184
|
+
assert isinstance(result, LoadOlderMessagesResponse)
|
|
185
|
+
assert result.added == 24
|
|
186
|
+
assert result.can_load_more is True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_get_media_returns_chat_media():
|
|
190
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
191
|
+
assert req.url.path == "/v1/chats/chat_1/messages/msg_key_1/media"
|
|
192
|
+
return httpx.Response(
|
|
193
|
+
200,
|
|
194
|
+
json={
|
|
195
|
+
"url": "https://cdn.example/x.jpg",
|
|
196
|
+
"mimetype": "image/jpeg",
|
|
197
|
+
"filename": "x.jpg",
|
|
198
|
+
"data_base64": None,
|
|
199
|
+
"original_quality": True,
|
|
200
|
+
"media_unavailable": None,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
result = _client(handler).chats.get_media("chat_1", "msg_key_1")
|
|
205
|
+
assert isinstance(result, ChatMedia)
|
|
206
|
+
assert result.mimetype == "image/jpeg"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_get_media_url_returns_typed_model():
|
|
210
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
211
|
+
assert req.url.path == "/v1/chats/chat_1/messages/msg_key_1/media_url"
|
|
212
|
+
return httpx.Response(200, json={"url": "https://cdn.example/y.jpg"})
|
|
213
|
+
|
|
214
|
+
result = _client(handler).chats.get_media_url("chat_1", "msg_key_1")
|
|
215
|
+
assert isinstance(result, MediaUrlResponse)
|
|
216
|
+
assert result.url == "https://cdn.example/y.jpg"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_batch_message_acks_returns_typed_model():
|
|
220
|
+
body_seen: dict = {}
|
|
221
|
+
|
|
222
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
223
|
+
body_seen.update(json.loads(req.content))
|
|
224
|
+
assert req.method == "POST"
|
|
225
|
+
assert req.url.path == "/v1/chats/message_acks"
|
|
226
|
+
return httpx.Response(
|
|
227
|
+
200,
|
|
228
|
+
json={
|
|
229
|
+
"data": [
|
|
230
|
+
{"key": "msg_a", "ack": 3},
|
|
231
|
+
{"key": "msg_b", "ack": 1},
|
|
232
|
+
{"key": "msg_c", "ack": None},
|
|
233
|
+
]
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
result = _client(handler).chats.batch_message_acks(message_keys=["msg_a", "msg_b", "msg_c"])
|
|
238
|
+
assert isinstance(result, BatchMessageAcksResponse)
|
|
239
|
+
assert body_seen == {"message_keys": ["msg_a", "msg_b", "msg_c"]}
|
|
240
|
+
assert result.data[0].key == "msg_a"
|
|
241
|
+
assert result.data[0].ack == 3
|
|
242
|
+
assert result.data[1].ack == 1
|
|
243
|
+
assert result.data[2].ack is None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_chats_get_raises_authentication_error_on_401():
|
|
247
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
248
|
+
return httpx.Response(
|
|
249
|
+
401,
|
|
250
|
+
json={
|
|
251
|
+
"error": {
|
|
252
|
+
"code": "authentication_required",
|
|
253
|
+
"message": "bad key",
|
|
254
|
+
"request_id": "req_chats_1",
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
260
|
+
_client(handler).chats.get("chat_1")
|
|
261
|
+
assert exc_info.value.code == "authentication_required"
|
|
262
|
+
assert exc_info.value.request_id == "req_chats_1"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_chat_validation_fails_when_required_field_missing():
|
|
266
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
267
|
+
return httpx.Response(200, json={})
|
|
268
|
+
|
|
269
|
+
with pytest.raises(ValidationError):
|
|
270
|
+
_client(handler).chats.get("chat_1")
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from blueticks import Blueticks
|
|
10
|
+
from blueticks._errors import AuthenticationError
|
|
11
|
+
from blueticks.types.groups import Group
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _client(handler):
|
|
15
|
+
return Blueticks(api_key="bt_live_test", _http_transport=httpx.MockTransport(handler))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _group(gid: str = "grp_1", **overrides) -> dict:
|
|
19
|
+
data = {
|
|
20
|
+
"id": gid,
|
|
21
|
+
"name": "Acme Team",
|
|
22
|
+
"description": "Internal coordination",
|
|
23
|
+
"owner": "12345@c.us",
|
|
24
|
+
"created_at": "2026-04-23T00:00:00Z",
|
|
25
|
+
"last_message_at": "2026-04-24T00:00:00Z",
|
|
26
|
+
"participant_count": 5,
|
|
27
|
+
"announce": False,
|
|
28
|
+
"restrict": False,
|
|
29
|
+
"participants": None,
|
|
30
|
+
}
|
|
31
|
+
data.update(overrides)
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_create_group_returns_typed_group():
|
|
36
|
+
body_seen: dict = {}
|
|
37
|
+
|
|
38
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
39
|
+
body_seen.update(json.loads(req.content))
|
|
40
|
+
assert req.method == "POST"
|
|
41
|
+
assert req.url.path == "/v1/groups"
|
|
42
|
+
return httpx.Response(200, json=_group("grp_new"))
|
|
43
|
+
|
|
44
|
+
g = _client(handler).groups.create(name="Acme Team", participants=["12345@c.us"])
|
|
45
|
+
assert isinstance(g, Group)
|
|
46
|
+
assert body_seen == {"name": "Acme Team", "participants": ["12345@c.us"]}
|
|
47
|
+
assert g.id == "grp_new"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_get_group_returns_typed_group():
|
|
51
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
52
|
+
assert req.url.path == "/v1/groups/grp_1"
|
|
53
|
+
return httpx.Response(200, json=_group("grp_1"))
|
|
54
|
+
|
|
55
|
+
g = _client(handler).groups.get("grp_1")
|
|
56
|
+
assert isinstance(g, Group)
|
|
57
|
+
assert g.participant_count == 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_update_group_returns_typed_group():
|
|
61
|
+
body_seen: dict = {}
|
|
62
|
+
|
|
63
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
64
|
+
body_seen.update(json.loads(req.content))
|
|
65
|
+
assert req.method == "PATCH"
|
|
66
|
+
assert req.url.path == "/v1/groups/grp_1"
|
|
67
|
+
return httpx.Response(200, json=_group("grp_1", name="Renamed"))
|
|
68
|
+
|
|
69
|
+
g = _client(handler).groups.update("grp_1", name="Renamed", settings={"announce": True})
|
|
70
|
+
assert isinstance(g, Group)
|
|
71
|
+
assert g.name == "Renamed"
|
|
72
|
+
assert body_seen == {"name": "Renamed", "settings": {"announce": True}}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_add_member_returns_typed_group():
|
|
76
|
+
body_seen: dict = {}
|
|
77
|
+
|
|
78
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
79
|
+
body_seen.update(json.loads(req.content))
|
|
80
|
+
assert req.method == "POST"
|
|
81
|
+
assert req.url.path == "/v1/groups/grp_1/members"
|
|
82
|
+
return httpx.Response(200, json=_group("grp_1", participant_count=6))
|
|
83
|
+
|
|
84
|
+
g = _client(handler).groups.add_member("grp_1", chat_id="99999@c.us")
|
|
85
|
+
assert isinstance(g, Group)
|
|
86
|
+
assert g.participant_count == 6
|
|
87
|
+
assert body_seen == {"chat_id": "99999@c.us"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_remove_member_returns_typed_group():
|
|
91
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
92
|
+
assert req.method == "DELETE"
|
|
93
|
+
assert req.url.path == "/v1/groups/grp_1/members/99999@c.us"
|
|
94
|
+
return httpx.Response(200, json=_group("grp_1", participant_count=4))
|
|
95
|
+
|
|
96
|
+
g = _client(handler).groups.remove_member("grp_1", "99999@c.us")
|
|
97
|
+
assert isinstance(g, Group)
|
|
98
|
+
assert g.participant_count == 4
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_promote_admin_returns_typed_group():
|
|
102
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
103
|
+
assert req.method == "POST"
|
|
104
|
+
assert req.url.path == "/v1/groups/grp_1/members/99999@c.us/admin"
|
|
105
|
+
return httpx.Response(200, json=_group("grp_1"))
|
|
106
|
+
|
|
107
|
+
g = _client(handler).groups.promote_admin("grp_1", "99999@c.us")
|
|
108
|
+
assert isinstance(g, Group)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_demote_admin_returns_typed_group():
|
|
112
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
113
|
+
assert req.method == "DELETE"
|
|
114
|
+
assert req.url.path == "/v1/groups/grp_1/members/99999@c.us/admin"
|
|
115
|
+
return httpx.Response(200, json=_group("grp_1"))
|
|
116
|
+
|
|
117
|
+
g = _client(handler).groups.demote_admin("grp_1", "99999@c.us")
|
|
118
|
+
assert isinstance(g, Group)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_set_picture_returns_typed_group():
|
|
122
|
+
body_seen: dict = {}
|
|
123
|
+
|
|
124
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
125
|
+
body_seen.update(json.loads(req.content))
|
|
126
|
+
assert req.method == "PUT"
|
|
127
|
+
assert req.url.path == "/v1/groups/grp_1/picture"
|
|
128
|
+
return httpx.Response(200, json=_group("grp_1"))
|
|
129
|
+
|
|
130
|
+
g = _client(handler).groups.set_picture(
|
|
131
|
+
"grp_1",
|
|
132
|
+
file_data_url="data:image/png;base64,iVBORw0KG...",
|
|
133
|
+
file_name="logo.png",
|
|
134
|
+
file_mime_type="image/png",
|
|
135
|
+
)
|
|
136
|
+
assert isinstance(g, Group)
|
|
137
|
+
assert body_seen["file_data_url"].startswith("data:image/png;base64")
|
|
138
|
+
assert body_seen["file_name"] == "logo.png"
|
|
139
|
+
assert body_seen["file_mime_type"] == "image/png"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_leave_group_returns_none_on_204():
|
|
143
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
144
|
+
assert req.method == "DELETE"
|
|
145
|
+
assert req.url.path == "/v1/groups/grp_1/members/me"
|
|
146
|
+
return httpx.Response(204)
|
|
147
|
+
|
|
148
|
+
assert _client(handler).groups.leave("grp_1") is None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_groups_get_raises_authentication_error_on_401():
|
|
152
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
153
|
+
return httpx.Response(
|
|
154
|
+
401,
|
|
155
|
+
json={
|
|
156
|
+
"error": {
|
|
157
|
+
"code": "authentication_required",
|
|
158
|
+
"message": "bad key",
|
|
159
|
+
"request_id": "req_groups_1",
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
165
|
+
_client(handler).groups.get("grp_1")
|
|
166
|
+
assert exc_info.value.code == "authentication_required"
|
|
167
|
+
assert exc_info.value.request_id == "req_groups_1"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_group_validation_fails_when_required_field_missing():
|
|
171
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
172
|
+
return httpx.Response(200, json={})
|
|
173
|
+
|
|
174
|
+
with pytest.raises(ValidationError):
|
|
175
|
+
_client(handler).groups.get("grp_1")
|
|
@@ -113,14 +113,20 @@ def test_update_scheduled_message(mock_client) -> None:
|
|
|
113
113
|
assert body_seen == {"text": "updated", "send_at": "2026-07-01T09:00:00Z"}
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
def
|
|
116
|
+
def test_delete_scheduled_message_returns_typed_ref(mock_client) -> None:
|
|
117
117
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
118
118
|
assert req.method == "DELETE"
|
|
119
119
|
assert req.url.path == "/v1/scheduled-messages/sched_1"
|
|
120
|
-
return httpx.Response(
|
|
120
|
+
return httpx.Response(
|
|
121
|
+
200,
|
|
122
|
+
content=json.dumps({"id": "sched_1", "deleted": True}).encode(),
|
|
123
|
+
headers={"content-type": "application/json"},
|
|
124
|
+
)
|
|
121
125
|
|
|
122
126
|
with mock_client(handler) as client:
|
|
123
|
-
|
|
127
|
+
result = client.scheduled_messages.delete("sched_1")
|
|
128
|
+
assert result.id == "sched_1"
|
|
129
|
+
assert result.deleted is True
|
|
124
130
|
|
|
125
131
|
|
|
126
132
|
def test_scheduled_messages_propagates_authentication_error(mock_client) -> None:
|
|
@@ -90,14 +90,16 @@ def test_update_webhook():
|
|
|
90
90
|
assert body_seen == {"status": "disabled"}
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def
|
|
93
|
+
def test_delete_webhook_returns_typed_ref():
|
|
94
94
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
95
95
|
assert req.method == "DELETE"
|
|
96
96
|
assert req.url.path == "/v1/webhooks/wh_1"
|
|
97
|
-
return httpx.Response(
|
|
97
|
+
return httpx.Response(200, json={"id": "wh_1", "deleted": True})
|
|
98
98
|
|
|
99
99
|
client = Blueticks(api_key="bt_live_test", _http_transport=httpx.MockTransport(handler))
|
|
100
|
-
|
|
100
|
+
result = client.webhooks.delete("wh_1")
|
|
101
|
+
assert result.id == "wh_1"
|
|
102
|
+
assert result.deleted is True
|
|
101
103
|
|
|
102
104
|
|
|
103
105
|
def test_rotate_secret_returns_new_secret():
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "3.3.0"
|
|
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
|
|
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
|
|
File without changes
|