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.
Files changed (61) hide show
  1. {blueticks-3.3.0/src/blueticks.egg-info → blueticks-3.3.2}/PKG-INFO +1 -1
  2. blueticks-3.3.2/src/blueticks/_version.py +1 -0
  3. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/audiences.py +9 -3
  4. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/chats.py +39 -16
  5. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/groups.py +22 -15
  6. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/scheduled_messages.py +7 -4
  7. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/webhooks.py +9 -3
  8. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/__init__.py +26 -0
  9. blueticks-3.3.2/src/blueticks/types/_deleted_resource.py +20 -0
  10. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/chats.py +67 -0
  11. {blueticks-3.3.0 → blueticks-3.3.2/src/blueticks.egg-info}/PKG-INFO +1 -1
  12. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/SOURCES.txt +3 -0
  13. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_audiences.py +4 -2
  14. blueticks-3.3.2/tests/test_chats.py +270 -0
  15. blueticks-3.3.2/tests/test_groups.py +175 -0
  16. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_scheduled_messages.py +9 -3
  17. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_webhooks_resource.py +5 -3
  18. blueticks-3.3.0/src/blueticks/_version.py +0 -1
  19. {blueticks-3.3.0 → blueticks-3.3.2}/LICENSE +0 -0
  20. {blueticks-3.3.0 → blueticks-3.3.2}/README.md +0 -0
  21. {blueticks-3.3.0 → blueticks-3.3.2}/pyproject.toml +0 -0
  22. {blueticks-3.3.0 → blueticks-3.3.2}/setup.cfg +0 -0
  23. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/__init__.py +0 -0
  24. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_base_resource.py +0 -0
  25. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_client.py +0 -0
  26. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_errors.py +0 -0
  27. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/_transport.py +0 -0
  28. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/py.typed +0 -0
  29. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/__init__.py +0 -0
  30. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/account.py +0 -0
  31. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/campaigns.py +0 -0
  32. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/contacts.py +0 -0
  33. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/engines.py +0 -0
  34. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/messages.py +0 -0
  35. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/ping.py +0 -0
  36. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/resources/utils.py +0 -0
  37. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/account.py +0 -0
  38. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/audiences.py +0 -0
  39. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/campaigns.py +0 -0
  40. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/contacts.py +0 -0
  41. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/engines.py +0 -0
  42. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/groups.py +0 -0
  43. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/messages.py +0 -0
  44. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/page.py +0 -0
  45. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/ping.py +0 -0
  46. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/scheduled_messages.py +0 -0
  47. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/utils.py +0 -0
  48. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/types/webhooks.py +0 -0
  49. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks/webhooks.py +0 -0
  50. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/dependency_links.txt +0 -0
  51. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/requires.txt +0 -0
  52. {blueticks-3.3.0 → blueticks-3.3.2}/src/blueticks.egg-info/top_level.txt +0 -0
  53. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_account.py +0 -0
  54. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_campaigns.py +0 -0
  55. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_client.py +0 -0
  56. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_errors.py +0 -0
  57. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_messages.py +0 -0
  58. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_ping.py +0 -0
  59. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_transport.py +0 -0
  60. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_types.py +0 -0
  61. {blueticks-3.3.0 → blueticks-3.3.2}/tests/test_webhook_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blueticks
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: Official Python client for the Blueticks API.
5
5
  Author-email: Blueticks <support@blueticks.co>
6
6
  License: MIT License
@@ -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) -> None:
51
- self._client._request("DELETE", f"/v1/audiences/{audience_id}")
52
- return None
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 Chat, ChatMedia, ChatMessage, MessageType, Participant
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) -> dict[str, Any]:
65
+ def mark_read(self, chat_id: str) -> OkResponse:
53
66
  """Mark a chat as read (sends read receipts if enabled)."""
54
- return self._client._request("POST", f"/v1/chats/{chat_id}/mark_read") # type: ignore[no-any-return]
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) -> dict[str, Any]:
70
+ def open(self, chat_id: str) -> ChatRef:
57
71
  """Open a chat on the engine (useful for UI-assisted workflows)."""
58
- return self._client._request("POST", f"/v1/chats/{chat_id}/open") # type: ignore[no-any-return]
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) -> dict[str, Any]:
117
+ def get_message_ack(self, chat_id: str, key: str) -> MessageAck:
103
118
  """Fetch ACK state for a single message."""
104
- return self._client._request("GET", f"/v1/chats/{chat_id}/messages/{key}/ack") # type: ignore[no-any-return]
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) -> dict[str, Any]:
107
- """Add or clear an emoji reaction on a message."""
108
- return self._client._request( # type: ignore[no-any-return]
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) -> dict[str, Any]:
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
- return self._client._request("POST", f"/v1/chats/{chat_id}/messages/load_older") # type: ignore[no-any-return]
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) -> dict[str, Any]:
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
- return self._client._request("GET", f"/v1/chats/{chat_id}/messages/{key}/media_url") # type: ignore[no-any-return]
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: list[str]) -> dict[str, Any]: # type: ignore[valid-type]
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
- return self._client._request( # type: ignore[no-any-return]
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
- ) -> dict[str, Any]:
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
- return self._client._request("PATCH", f"/v1/groups/{group_id}", body=body) # type: ignore[no-any-return]
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) -> dict[str, Any]:
38
+ def add_member(self, group_id: str, *, chat_id: str) -> Group:
38
39
  """Invite a contact to the group."""
39
- return self._client._request( # type: ignore[no-any-return]
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) -> dict[str, Any]:
45
+ def remove_member(self, group_id: str, chat_id: str) -> Group:
44
46
  """Remove a participant from the group."""
45
- return self._client._request("DELETE", f"/v1/groups/{group_id}/members/{chat_id}") # type: ignore[no-any-return]
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) -> dict[str, Any]:
50
+ def promote_admin(self, group_id: str, chat_id: str) -> Group:
48
51
  """Grant admin rights to a participant."""
49
- return self._client._request("POST", f"/v1/groups/{group_id}/members/{chat_id}/admin") # type: ignore[no-any-return]
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) -> dict[str, Any]:
55
+ def demote_admin(self, group_id: str, chat_id: str) -> Group:
52
56
  """Revoke admin rights from a participant."""
53
- return self._client._request("DELETE", f"/v1/groups/{group_id}/members/{chat_id}/admin") # type: ignore[no-any-return]
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
- ) -> dict[str, Any]:
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
- return self._client._request("PUT", f"/v1/groups/{group_id}/picture", body=body) # type: ignore[no-any-return]
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) -> dict[str, Any]:
72
- """Leave the group (as the authenticated user)."""
73
- return self._client._request("DELETE", f"/v1/groups/{group_id}/members/me") # type: ignore[no-any-return]
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) -> None:
65
+ def delete(self, scheduled_message_id: str) -> DeletedResource:
65
66
  """Cancel scheduled message.
66
67
 
67
- Removes the resource with id.
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 None
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) -> None:
70
- self._client._request("DELETE", f"/v1/webhooks/{webhook_id}")
71
- return None
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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blueticks
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: Official Python client for the Blueticks API.
5
5
  Author-email: Blueticks <support@blueticks.co>
6
6
  License: MIT License
@@ -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(204)
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
- assert client.audiences.delete("aud_1") is None
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 test_delete_scheduled_message_returns_none(mock_client) -> None:
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(204)
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
- assert client.scheduled_messages.delete("sched_1") is None
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 test_delete_webhook_returns_none():
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(204)
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
- assert client.webhooks.delete("wh_1") is None
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