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.
@@ -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
+ )