slack-objects 0.0.post31__py3-none-any.whl
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.
- slack_objects/__init__.py +22 -0
- slack_objects/_version.py +34 -0
- slack_objects/api_caller.py +42 -0
- slack_objects/base.py +30 -0
- slack_objects/client.py +50 -0
- slack_objects/config.py +29 -0
- slack_objects/conversations.py +437 -0
- slack_objects/files.py +331 -0
- slack_objects/idp_groups.py +200 -0
- slack_objects/messages.py +322 -0
- slack_objects/rate_limits.py +51 -0
- slack_objects/users.py +554 -0
- slack_objects/workspaces.py +261 -0
- slack_objects-0.0.post31.dist-info/METADATA +201 -0
- slack_objects-0.0.post31.dist-info/RECORD +18 -0
- slack_objects-0.0.post31.dist-info/WHEEL +5 -0
- slack_objects-0.0.post31.dist-info/licenses/LICENSE +21 -0
- slack_objects-0.0.post31.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
slack_objects.messages
|
|
5
|
+
=====================
|
|
6
|
+
|
|
7
|
+
Messages helper for the `slack-objects` package.
|
|
8
|
+
|
|
9
|
+
Merged from two legacy implementations:
|
|
10
|
+
|
|
11
|
+
- PCbot legacy: chat.update + replace_message_block logic :contentReference[oaicite:2]{index=2}
|
|
12
|
+
- SlackAdmin legacy: chat.delete + getMessageThreads via conversations.replies pagination :contentReference[oaicite:3]{index=3}
|
|
13
|
+
|
|
14
|
+
Design goals:
|
|
15
|
+
- Factory-friendly:
|
|
16
|
+
slack = SlackObjectsClient(cfg)
|
|
17
|
+
msgs = slack.messages() # unbound
|
|
18
|
+
msgs_c = slack.messages(channel_id="C123") # bound to channel
|
|
19
|
+
msg = slack.messages("C123", "1700.12") # bound to message
|
|
20
|
+
- Modular:
|
|
21
|
+
Only endpoint wrapper methods call self.api.call(...)
|
|
22
|
+
- Practical:
|
|
23
|
+
Provide common message operations: update/delete, thread replies, and block replacement.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
from .base import SlackObjectBase
|
|
30
|
+
from .config import RateTier
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Messages(SlackObjectBase):
|
|
35
|
+
"""
|
|
36
|
+
Messages domain helper.
|
|
37
|
+
|
|
38
|
+
Optional bindings:
|
|
39
|
+
- channel_id: for channel-scoped message operations
|
|
40
|
+
- ts: message timestamp (for update/delete/thread replies)
|
|
41
|
+
- message: cached message payload (used by replace_message_block if blocks not provided)
|
|
42
|
+
"""
|
|
43
|
+
channel_id: Optional[str] = None
|
|
44
|
+
ts: Optional[str] = None
|
|
45
|
+
message: Optional[Dict[str, Any]] = None
|
|
46
|
+
|
|
47
|
+
# --------------------
|
|
48
|
+
# Factory helpers
|
|
49
|
+
# --------------------
|
|
50
|
+
|
|
51
|
+
def with_channel(self, channel_id: str) -> "Messages":
|
|
52
|
+
"""Return a new Messages instance bound to channel_id, sharing cfg/client/logger/api."""
|
|
53
|
+
return Messages(cfg=self.cfg, client=self.client, logger=self.logger, api=self.api, channel_id=channel_id)
|
|
54
|
+
|
|
55
|
+
def with_message(self, channel_id: str, ts: str, message: Optional[Dict[str, Any]] = None) -> "Messages":
|
|
56
|
+
"""Return a new Messages instance bound to (channel_id, ts), optionally caching message payload."""
|
|
57
|
+
return Messages(
|
|
58
|
+
cfg=self.cfg,
|
|
59
|
+
client=self.client,
|
|
60
|
+
logger=self.logger,
|
|
61
|
+
api=self.api,
|
|
62
|
+
channel_id=channel_id,
|
|
63
|
+
ts=ts,
|
|
64
|
+
message=message,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ============================================================
|
|
68
|
+
# Endpoint wrapper layer (ONLY these call self.api.call directly)
|
|
69
|
+
# ============================================================
|
|
70
|
+
|
|
71
|
+
def _chat_update(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
72
|
+
"""Wrapper for chat.update."""
|
|
73
|
+
return self.api.call(self.client, "chat.update", rate_tier=RateTier.TIER_3, **payload)
|
|
74
|
+
|
|
75
|
+
def _chat_delete(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
76
|
+
"""Wrapper for chat.delete."""
|
|
77
|
+
return self.api.call(self.client, "chat.delete", rate_tier=RateTier.TIER_3, **payload)
|
|
78
|
+
|
|
79
|
+
def _conversations_replies(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
80
|
+
"""Wrapper for conversations.replies (thread replies)."""
|
|
81
|
+
return self.api.call(self.client, "conversations.replies", rate_tier=RateTier.TIER_3, **payload)
|
|
82
|
+
|
|
83
|
+
def _conversations_history(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
84
|
+
"""Wrapper for conversations.history (channel history)."""
|
|
85
|
+
return self.api.call(self.client, "conversations.history", rate_tier=RateTier.TIER_3, **payload)
|
|
86
|
+
|
|
87
|
+
# ============================================================
|
|
88
|
+
# Public operations
|
|
89
|
+
# ============================================================
|
|
90
|
+
|
|
91
|
+
def update_message(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
channel_id: Optional[str] = None,
|
|
95
|
+
ts: Optional[str] = None,
|
|
96
|
+
as_user: bool = True,
|
|
97
|
+
text: str = "",
|
|
98
|
+
blocks: Optional[List[Dict[str, Any]]] = None,
|
|
99
|
+
attachments: Optional[Any] = None,
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""
|
|
102
|
+
Update an existing message (chat.update).
|
|
103
|
+
|
|
104
|
+
Merged from your PCbot legacy update_message method. :contentReference[oaicite:4]{index=4}
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
channel_id: channel containing the message (defaults to bound channel_id)
|
|
108
|
+
ts: timestamp of the message (defaults to bound ts)
|
|
109
|
+
as_user: Slack param; legacy default was True
|
|
110
|
+
text: optional new text
|
|
111
|
+
blocks: optional block kit payload
|
|
112
|
+
attachments: optional attachments payload
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Slack Web API response dict.
|
|
116
|
+
"""
|
|
117
|
+
cid = channel_id or self.channel_id
|
|
118
|
+
mts = ts or self.ts
|
|
119
|
+
if not cid or not mts:
|
|
120
|
+
raise ValueError("update_message requires channel_id and ts (passed or bound).")
|
|
121
|
+
|
|
122
|
+
payload: Dict[str, Any] = {"channel": cid, "ts": mts, "as_user": as_user}
|
|
123
|
+
if text:
|
|
124
|
+
payload["text"] = text
|
|
125
|
+
if blocks is not None:
|
|
126
|
+
payload["blocks"] = blocks
|
|
127
|
+
if attachments is not None:
|
|
128
|
+
payload["attachments"] = attachments
|
|
129
|
+
|
|
130
|
+
resp = self._chat_update(payload)
|
|
131
|
+
if not resp.get("ok"):
|
|
132
|
+
self.logger.error("chat.update failed: %s", resp)
|
|
133
|
+
return resp
|
|
134
|
+
|
|
135
|
+
def delete_message(self, *, channel_id: Optional[str] = None, ts: Optional[str] = None) -> Dict[str, Any]:
|
|
136
|
+
"""
|
|
137
|
+
Delete a message (chat.delete).
|
|
138
|
+
|
|
139
|
+
Merged from your SlackAdmin legacy deleteMessage. :contentReference[oaicite:5]{index=5}
|
|
140
|
+
"""
|
|
141
|
+
cid = channel_id or self.channel_id
|
|
142
|
+
mts = ts or self.ts
|
|
143
|
+
if not cid or not mts:
|
|
144
|
+
raise ValueError("delete_message requires channel_id and ts (passed or bound).")
|
|
145
|
+
|
|
146
|
+
resp = self._chat_delete({"channel": cid, "ts": mts})
|
|
147
|
+
if not resp.get("ok"):
|
|
148
|
+
self.logger.error("chat.delete failed: %s", resp)
|
|
149
|
+
return resp
|
|
150
|
+
|
|
151
|
+
def get_message_threads(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
channel_id: Optional[str] = None,
|
|
155
|
+
thread_ts: Optional[str] = None,
|
|
156
|
+
limit: Optional[int] = None,
|
|
157
|
+
inclusive: bool = True,
|
|
158
|
+
latest: Optional[str] = None,
|
|
159
|
+
oldest: Optional[str] = None,
|
|
160
|
+
) -> List[Dict[str, Any]]:
|
|
161
|
+
"""
|
|
162
|
+
Fetch thread replies for a parent message (conversations.replies), with pagination.
|
|
163
|
+
|
|
164
|
+
This is the refactor of your SlackAdmin legacy getMessageThreads. :contentReference[oaicite:6]{index=6}
|
|
165
|
+
|
|
166
|
+
Note: Slack returns the parent message as the first element in `messages`.
|
|
167
|
+
"""
|
|
168
|
+
cid = channel_id or self.channel_id
|
|
169
|
+
tts = thread_ts or self.ts
|
|
170
|
+
if not cid or not tts:
|
|
171
|
+
raise ValueError("get_message_threads requires channel_id and thread_ts (passed or bound).")
|
|
172
|
+
|
|
173
|
+
payload: Dict[str, Any] = {"channel": cid, "ts": tts, "inclusive": inclusive}
|
|
174
|
+
if limit is not None:
|
|
175
|
+
payload["limit"] = limit
|
|
176
|
+
if latest:
|
|
177
|
+
payload["latest"] = latest
|
|
178
|
+
if oldest:
|
|
179
|
+
payload["oldest"] = oldest
|
|
180
|
+
|
|
181
|
+
replies: List[Dict[str, Any]] = []
|
|
182
|
+
while True:
|
|
183
|
+
resp = self._conversations_replies(payload)
|
|
184
|
+
if not resp.get("ok"):
|
|
185
|
+
raise RuntimeError(f"conversations.replies failed: {resp}")
|
|
186
|
+
|
|
187
|
+
batch = resp.get("messages") or []
|
|
188
|
+
replies.extend(batch)
|
|
189
|
+
|
|
190
|
+
if limit is not None and len(replies) >= limit:
|
|
191
|
+
return replies[:limit]
|
|
192
|
+
|
|
193
|
+
cursor = ((resp.get("response_metadata") or {}).get("next_cursor")) or ""
|
|
194
|
+
if not cursor:
|
|
195
|
+
break
|
|
196
|
+
payload["cursor"] = cursor
|
|
197
|
+
|
|
198
|
+
return replies
|
|
199
|
+
|
|
200
|
+
def get_messages(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
channel_id: Optional[str] = None,
|
|
204
|
+
include_all_metadata: bool = False,
|
|
205
|
+
limit: Optional[int] = None,
|
|
206
|
+
inclusive: bool = True,
|
|
207
|
+
latest: Optional[str] = None,
|
|
208
|
+
oldest: Optional[str] = None,
|
|
209
|
+
) -> List[Dict[str, Any]]:
|
|
210
|
+
"""
|
|
211
|
+
Fetch channel history (conversations.history) with pagination.
|
|
212
|
+
|
|
213
|
+
This mirrors the functionality your legacy *Conversations/Channels* helpers had, but
|
|
214
|
+
putting it here makes Conversations.py cleaner: Conversations delegates to Messages
|
|
215
|
+
for anything message/history/thread related.
|
|
216
|
+
"""
|
|
217
|
+
cid = channel_id or self.channel_id
|
|
218
|
+
if not cid:
|
|
219
|
+
raise ValueError("get_messages requires channel_id (passed or bound).")
|
|
220
|
+
|
|
221
|
+
payload: Dict[str, Any] = {
|
|
222
|
+
"channel": cid,
|
|
223
|
+
"include_all_metadata": include_all_metadata,
|
|
224
|
+
"inclusive": inclusive,
|
|
225
|
+
}
|
|
226
|
+
if limit is not None:
|
|
227
|
+
payload["limit"] = limit
|
|
228
|
+
if latest:
|
|
229
|
+
payload["latest"] = latest
|
|
230
|
+
if oldest:
|
|
231
|
+
payload["oldest"] = oldest
|
|
232
|
+
|
|
233
|
+
out: List[Dict[str, Any]] = []
|
|
234
|
+
while True:
|
|
235
|
+
resp = self._conversations_history(payload)
|
|
236
|
+
if not resp.get("ok"):
|
|
237
|
+
raise RuntimeError(f"conversations.history failed: {resp}")
|
|
238
|
+
|
|
239
|
+
batch = resp.get("messages") or []
|
|
240
|
+
out.extend(batch)
|
|
241
|
+
|
|
242
|
+
if limit is not None and len(out) >= limit:
|
|
243
|
+
return out[:limit]
|
|
244
|
+
|
|
245
|
+
cursor = ((resp.get("response_metadata") or {}).get("next_cursor")) or ""
|
|
246
|
+
if not cursor:
|
|
247
|
+
break
|
|
248
|
+
payload["cursor"] = cursor
|
|
249
|
+
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
def replace_message_block(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
blocks: Optional[List[Dict[str, Any]]] = None,
|
|
256
|
+
block_type: str = "",
|
|
257
|
+
block_id: str = "",
|
|
258
|
+
text: str = "",
|
|
259
|
+
new_block: Optional[Dict[str, Any]] = None,
|
|
260
|
+
new_block_id: str = "",
|
|
261
|
+
channel_id: Optional[str] = None,
|
|
262
|
+
ts: Optional[str] = None,
|
|
263
|
+
) -> Dict[str, Any]:
|
|
264
|
+
"""
|
|
265
|
+
Replace the first matching block (by type or block_id) and update the message.
|
|
266
|
+
|
|
267
|
+
Ported/refactored from your PCbot legacy replace_message_block. :contentReference[oaicite:7]{index=7}
|
|
268
|
+
|
|
269
|
+
Notes:
|
|
270
|
+
- If blocks is omitted, it uses self.message["blocks"] if available.
|
|
271
|
+
- If neither block_type nor block_id is supplied, this raises.
|
|
272
|
+
"""
|
|
273
|
+
cid = channel_id or self.channel_id
|
|
274
|
+
mts = ts or self.ts
|
|
275
|
+
if not cid or not mts:
|
|
276
|
+
raise ValueError("replace_message_block requires channel_id and ts (passed or bound).")
|
|
277
|
+
|
|
278
|
+
if blocks is None:
|
|
279
|
+
if not self.message or "blocks" not in self.message:
|
|
280
|
+
raise ValueError("No blocks provided and no cached message.blocks available.")
|
|
281
|
+
blocks = list(self.message["blocks"])
|
|
282
|
+
|
|
283
|
+
# Default behavior from legacy: blank text means "remove" area by replacing with a space section.
|
|
284
|
+
if not text:
|
|
285
|
+
text = " "
|
|
286
|
+
|
|
287
|
+
if new_block is None:
|
|
288
|
+
new_block = {
|
|
289
|
+
"type": "section",
|
|
290
|
+
"text": {"type": "mrkdwn", "text": text},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if new_block_id:
|
|
294
|
+
new_block["block_id"] = new_block_id
|
|
295
|
+
|
|
296
|
+
# Determine match key
|
|
297
|
+
key = ""
|
|
298
|
+
target = ""
|
|
299
|
+
if block_type:
|
|
300
|
+
key = "type"
|
|
301
|
+
target = block_type
|
|
302
|
+
if block_id:
|
|
303
|
+
key = "block_id"
|
|
304
|
+
target = block_id
|
|
305
|
+
|
|
306
|
+
if not key:
|
|
307
|
+
raise ValueError("replace_message_block requires block_type or block_id.")
|
|
308
|
+
|
|
309
|
+
# Replace first match
|
|
310
|
+
replaced = False
|
|
311
|
+
for i, b in enumerate(blocks):
|
|
312
|
+
if b.get(key) == target:
|
|
313
|
+
blocks[i] = new_block
|
|
314
|
+
replaced = True
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
if not replaced:
|
|
318
|
+
self.logger.info("No block matched %s=%r; no update performed.", key, target)
|
|
319
|
+
return {"ok": False, "error": "block_not_found", "key": key, "target": target}
|
|
320
|
+
|
|
321
|
+
# Update message with modified blocks
|
|
322
|
+
return self.update_message(channel_id=cid, ts=mts, blocks=blocks)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional, Mapping
|
|
3
|
+
|
|
4
|
+
from .config import RateTier
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RateLimitPolicy:
|
|
9
|
+
# exact method overrides (most specific)
|
|
10
|
+
method_overrides: Mapping[str, RateTier]
|
|
11
|
+
|
|
12
|
+
# prefix rules (less specific)
|
|
13
|
+
prefix_rules: Mapping[str, RateTier]
|
|
14
|
+
|
|
15
|
+
# fallback
|
|
16
|
+
default: RateTier = RateTier.TIER_3
|
|
17
|
+
|
|
18
|
+
def tier_for(self, method: str) -> RateTier:
|
|
19
|
+
# 1) exact match wins
|
|
20
|
+
if method in self.method_overrides:
|
|
21
|
+
return self.method_overrides[method]
|
|
22
|
+
|
|
23
|
+
# 2) longest prefix match wins
|
|
24
|
+
best = None
|
|
25
|
+
for prefix, tier in self.prefix_rules.items():
|
|
26
|
+
if method.startswith(prefix) and (best is None or len(prefix) > len(best[0])):
|
|
27
|
+
best = (prefix, tier)
|
|
28
|
+
if best:
|
|
29
|
+
return best[1]
|
|
30
|
+
|
|
31
|
+
# 3) default
|
|
32
|
+
return self.default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_RATE_POLICY = RateLimitPolicy(
|
|
36
|
+
method_overrides={
|
|
37
|
+
# add only the truly special cases
|
|
38
|
+
"conversations.history": RateTier.TIER_3,
|
|
39
|
+
"files.upload": RateTier.TIER_2,
|
|
40
|
+
},
|
|
41
|
+
prefix_rules={
|
|
42
|
+
"admin.": RateTier.TIER_1,
|
|
43
|
+
"scim.": RateTier.TIER_2, # if you represent SCIM calls similarly
|
|
44
|
+
"conversations.": RateTier.TIER_3,
|
|
45
|
+
"chat.": RateTier.TIER_3,
|
|
46
|
+
"files.": RateTier.TIER_2,
|
|
47
|
+
"users.": RateTier.TIER_2,
|
|
48
|
+
"team.": RateTier.TIER_2,
|
|
49
|
+
},
|
|
50
|
+
default=RateTier.TIER_3,
|
|
51
|
+
)
|