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,22 @@
1
+ from .client import SlackObjectsClient
2
+ from .config import SlackObjectsConfig, RateTier #, IdPGroupConfig
3
+
4
+ from .users import Users
5
+ #from .conversations import Conversations
6
+ #from .messages import Messages
7
+ #from .files import Files
8
+ #from .workspaces import Workspaces
9
+ #from .idp_groups import IDP_groups
10
+
11
+ __all__ = [
12
+ "SlackObjectsClient",
13
+ "SlackObjectsConfig",
14
+ "RateTier",
15
+ "IdPGroupConfig",
16
+ "Users",
17
+ "Channels",
18
+ "Messages",
19
+ "Files",
20
+ "Workspaces",
21
+ "IDP_groups",
22
+ ]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.post31'
32
+ __version_tuple__ = version_tuple = (0, 0, 'post31')
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,42 @@
1
+ import time
2
+ from typing import Any, Optional
3
+
4
+ from slack_sdk.errors import SlackApiError
5
+
6
+ from .config import SlackObjectsConfig, RateTier
7
+ from .rate_limits import DEFAULT_RATE_POLICY, RateLimitPolicy
8
+
9
+
10
+ class SlackApiCaller:
11
+ """
12
+ Wrapper around Slack SDK client to handle rate limiting and API calls.
13
+
14
+ Example: self.api.call(self.client, "users.lookupByEmail", email=email)
15
+ """
16
+ def __init__(self, cfg: SlackObjectsConfig, policy: RateLimitPolicy = DEFAULT_RATE_POLICY):
17
+ self.cfg = cfg
18
+ self.policy = policy
19
+
20
+ def call(self, client, method: str, *, rate_tier: Optional[RateTier] = None, use_json: bool = False, **kwargs) -> dict:
21
+ tier = rate_tier or self.policy.tier_for(method) or self.cfg.default_rate_tier
22
+
23
+ try:
24
+ if use_json:
25
+ resp = client.api_call(method, json=kwargs)
26
+ else:
27
+ resp = client.api_call(method, params=kwargs)
28
+
29
+ data = resp.data if hasattr(resp, "data") else resp
30
+
31
+ # Space out subsequent calls
32
+ time.sleep(float(tier))
33
+ return data
34
+
35
+ except SlackApiError as e:
36
+ # Handle rate limiting properly
37
+ if e.response is not None and e.response.status_code == 429:
38
+ retry_after = int(e.response.headers.get("Retry-After", tier))
39
+ time.sleep(retry_after)
40
+ return self.call(client, method, rate_tier=tier, **kwargs)
41
+
42
+ raise
slack_objects/base.py ADDED
@@ -0,0 +1,30 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional, Any
4
+ from slack_sdk import WebClient
5
+
6
+ from .api_caller import SlackApiCaller
7
+ from .config import SlackObjectsConfig
8
+
9
+
10
+ @dataclass
11
+ class SlackObjectBase:
12
+ """
13
+ Base class that all object helpers inherit from.
14
+
15
+ Holds shared context so every object doesn't need to reinvent plumbing (i.e., don't need to pass cfg, client, logger, and the apicaller).
16
+ Logging is optional; a default package logger will be used if none is provided.
17
+ """
18
+ cfg: SlackObjectsConfig
19
+ client: WebClient
20
+ api: SlackApiCaller
21
+ logger: logging.Logger = field(default_factory=lambda: logging.getLogger("slack-objects")) # logger is guaranteed to exist via default_factory
22
+
23
+ def __post_init__(self) -> None:
24
+ # Required dependencies check
25
+ if self.cfg is None:
26
+ raise ValueError("cfg is required")
27
+ if self.client is None:
28
+ raise ValueError("client is required")
29
+ if self.api is None:
30
+ raise ValueError("api is required")
@@ -0,0 +1,50 @@
1
+ from slack_sdk import WebClient
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from .config import SlackObjectsConfig
6
+ from .api_caller import SlackApiCaller
7
+ from .users import Users
8
+ from .messages import Messages
9
+ from .conversations import Conversations
10
+ from .files import Files
11
+ from .workspaces import Workspaces
12
+ from .idp_groups import IDP_groups
13
+
14
+
15
+ class SlackObjectsClient:
16
+ """
17
+ Central factory / context object.
18
+ Owns config, Slack client, and rate-limited API caller.
19
+ """
20
+
21
+ def __init__(self, cfg: SlackObjectsConfig, logger: logging.Logger | None = None):
22
+ self.cfg = cfg
23
+ self.logger = logger or logging.getLogger("slack-objects")
24
+
25
+ # Prefer bot token for general Web API calls; fall back to user token.
26
+ web_token = cfg.bot_token or cfg.user_token
27
+ if not web_token:
28
+ raise ValueError("SlackObjectsClient requires cfg.bot_token or cfg.user_token.")
29
+
30
+ self.web_client = WebClient(token=web_token)
31
+ self.api = SlackApiCaller(cfg)
32
+
33
+ def users(self, user_id: Optional[str] = None) -> Users:
34
+ base = Users(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
35
+ return base if user_id is None else base.with_user(user_id)
36
+
37
+ def conversations(self) -> Conversations:
38
+ return Conversations(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
39
+
40
+ def files(self) -> Files:
41
+ return Files(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
42
+
43
+ def messages(self) -> Messages:
44
+ return Messages(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
45
+
46
+ def workspaces(self) -> Workspaces:
47
+ return Workspaces(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
48
+
49
+ def idp_groups(self) -> IDP_groups:
50
+ return IDP_groups(cfg=self.cfg, client=self.web_client, api=self.api, logger=self.logger)
@@ -0,0 +1,29 @@
1
+ from enum import Enum
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ class RateTier(float, Enum):
6
+ """
7
+ Slack API rate-tier backoff defaults (seconds). These are defined to conform to Slack Web API rate limits.
8
+ https://docs.slack.dev/apis/web-api/rate-limits/
9
+ """
10
+
11
+ TIER_1 = 60.0 # 1+ per minute
12
+ TIER_2 = 3.0 # 20+ per minute
13
+ TIER_3 = 1.2 # 50+ per minute
14
+ TIER_4 = 0.6 # 100+ per minute
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SlackObjectsConfig:
19
+ """
20
+ Configuration settings for slack-objects.
21
+
22
+ Tokens are optional at construction time.
23
+ Individual methods will raise clear errors if a required token is missing.
24
+ """
25
+ bot_token: Optional[str] = None
26
+ user_token: Optional[str] = None
27
+ scim_token: Optional[str] = None
28
+
29
+ default_rate_tier: RateTier = RateTier.TIER_2
@@ -0,0 +1,437 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ slack_objects.conversations
5
+ ==========================
6
+
7
+ Conversations (Channels) helper for the `slack-objects` package.
8
+
9
+ This module provides methods for working with Slack conversations (public channels, private channels,
10
+ and (in Grid) enterprise conversations), including:
11
+ - Fetching conversation attributes (`conversations.info`)
12
+ - Searching by name (`admin.conversations.search`)
13
+ - Archiving (`admin.conversations.archive`)
14
+ - Sharing/moving across workspaces (`admin.conversations.setTeams`)
15
+ - Restricting access via IdP group allowlists (`admin.conversations.restrictAccess.addGroup`)
16
+ - Reading history (`conversations.history`)
17
+ - Listing members via Discovery (`discovery.conversations.members`)
18
+
19
+ Design goals:
20
+ - Factory-friendly:
21
+ slack = SlackObjectsClient(cfg)
22
+ convos = slack.conversations()
23
+ general = slack.conversations("C123")
24
+ - Modular:
25
+ Only *endpoint wrapper* methods call self.api.call(...).
26
+ Public methods call wrappers.
27
+ - Practical:
28
+ conversations.info sometimes needs a user token to see private channels not joined by the bot.
29
+ This class will attempt user token (if provided) and fallback to bot token.
30
+ """
31
+
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Dict, List, Optional, Sequence, Union
34
+
35
+
36
+ from .base import SlackObjectBase
37
+ from .config import RateTier
38
+ from .messages import Messages
39
+
40
+ @dataclass
41
+ class Conversations(SlackObjectBase):
42
+ """
43
+ Conversations domain helper.
44
+
45
+ Factory-style usage:
46
+ slack = SlackObjectsClient(cfg)
47
+ convos = slack.conversations() # unbound
48
+ general = slack.conversations("C123") # bound to channel_id
49
+
50
+ Notes:
51
+ - channel_id is optional. Methods that need it require a passed channel_id or a bound instance.
52
+ - attributes cache is populated via refresh().
53
+ """
54
+ channel_id: Optional[str] = None
55
+ attributes: Dict[str, Any] = field(default_factory=dict)
56
+
57
+ # ---------- factory helpers ----------
58
+
59
+ def with_conversation(self, channel_id: str) -> "Conversations":
60
+ """Return a new Conversations instance bound to channel_id, sharing cfg/client/logger/api."""
61
+ return Conversations(cfg=self.cfg, client=self.client, logger=self.logger, api=self.api, channel_id=channel_id)
62
+
63
+ # ---------- attribute lifecycle ----------
64
+
65
+ def refresh(self, channel_id: Optional[str] = None) -> Dict[str, Any]:
66
+ """
67
+ Refresh cached attributes for the conversation via conversations.info.
68
+
69
+ This is layered/modular: refresh() calls get_conversation_info(), which calls the wrapper.
70
+ """
71
+ if channel_id:
72
+ self.channel_id = channel_id
73
+ if not self.channel_id:
74
+ raise ValueError("refresh() requires channel_id (passed or already set)")
75
+
76
+ resp = self.get_conversation_info(self.channel_id)
77
+ if not resp.get("ok"):
78
+ raise RuntimeError(f"Conversations.get_conversation_info() failed: {resp}")
79
+
80
+ self.attributes = resp.get("channel") or {}
81
+ return self.attributes
82
+
83
+ def _require_attributes(self) -> Dict[str, Any]:
84
+ """Ensure attributes are loaded before helpers rely on them."""
85
+ if self.attributes:
86
+ return self.attributes
87
+ if self.channel_id:
88
+ return self.refresh()
89
+ raise ValueError("Conversation attributes not loaded and no channel_id set (call refresh() or bind channel_id).")
90
+
91
+ # ============================================================
92
+ # Slack Web/Admin/Discovery API wrapper layer
93
+ # ============================================================
94
+ # Only these methods should call `self.api.call(...)` directly.
95
+
96
+ def _conversations_info(self, channel_id: str, *, token: Optional[str] = None) -> Dict[str, Any]:
97
+ """
98
+ Wrapper for conversations.info.
99
+
100
+ If a user token exists in cfg, we try it first because bot tokens often cannot see
101
+ private channels the bot is not a member of. This mirrors your PCbot behavior/fallback. :contentReference[oaicite:3]{index=3}
102
+ """
103
+ kwargs: Dict[str, Any] = {"channel": channel_id}
104
+
105
+ # Token override handling:
106
+ # - If explicit token provided: use it
107
+ # - Else if cfg.user_token exists: try user token first, fallback to default client token
108
+ if token:
109
+ kwargs["token"] = token
110
+ return self.api.call(self.client, "conversations.info", rate_tier=RateTier.TIER_3, **kwargs)
111
+
112
+ if getattr(self.cfg, "user_token", None):
113
+ # First attempt with user_token
114
+ kwargs_user = dict(kwargs)
115
+ kwargs_user["token"] = self.cfg.user_token
116
+ resp = self.api.call(self.client, "conversations.info", rate_tier=RateTier.TIER_3, **kwargs_user)
117
+ if resp.get("ok"):
118
+ return resp
119
+
120
+ # Fallback: bot token / default client token
121
+ return self.api.call(self.client, "conversations.info", rate_tier=RateTier.TIER_3, **kwargs)
122
+
123
+ # Default
124
+ return self.api.call(self.client, "conversations.info", rate_tier=RateTier.TIER_3, **kwargs)
125
+
126
+ def _conversations_history(self, payload: Dict[str, Any]) -> Dict[str, Any]:
127
+ """Wrapper for conversations.history."""
128
+ return self.api.call(self.client, "conversations.history", rate_tier=RateTier.TIER_3, **payload)
129
+
130
+ def _conversations_replies(self, payload: Dict[str, Any]) -> Dict[str, Any]:
131
+ """Wrapper for conversations.replies. Used to fetch thread replies for a parent message."""
132
+ return self.api.call(self.client, "conversations.replies", rate_tier=RateTier.TIER_3, **payload)
133
+
134
+ def _admin_conversations_search(self, payload: Dict[str, Any]) -> Dict[str, Any]:
135
+ """Wrapper for admin.conversations.search (max limit appears to be 20 in legacy). :contentReference[oaicite:4]{index=4}"""
136
+ return self.api.call(self.client, "admin.conversations.search", rate_tier=RateTier.TIER_2, **payload)
137
+
138
+ def _admin_conversations_archive(self, channel_id: str) -> Dict[str, Any]:
139
+ """Wrapper for admin.conversations.archive."""
140
+ return self.api.call(
141
+ self.client, "admin.conversations.archive", rate_tier=RateTier.TIER_2, channel_id=channel_id
142
+ )
143
+
144
+ def _admin_conversations_set_teams(self, payload: Dict[str, Any]) -> Dict[str, Any]:
145
+ """Wrapper for admin.conversations.setTeams (share/move). :contentReference[oaicite:5]{index=5}"""
146
+ return self.api.call(self.client, "admin.conversations.setTeams", rate_tier=RateTier.TIER_2, **payload)
147
+
148
+ def _admin_conversations_restrict_access_add_group(self, payload: Dict[str, Any]) -> Dict[str, Any]:
149
+ """Wrapper for admin.conversations.restrictAccess.addGroup. :contentReference[oaicite:6]{index=6}"""
150
+ return self.api.call(
151
+ self.client,
152
+ "admin.conversations.restrictAccess.addGroup",
153
+ rate_tier=RateTier.TIER_2,
154
+ **payload,
155
+ )
156
+
157
+ def _discovery_conversations_members(self, payload: Dict[str, Any]) -> Dict[str, Any]:
158
+ """Wrapper for discovery.conversations.members. :contentReference[oaicite:7]{index=7}"""
159
+ return self.api.call(self.client, "discovery.conversations.members", rate_tier=RateTier.TIER_3, **payload)
160
+
161
+ # ============================================================
162
+ # Public methods (call wrappers above)
163
+ # ============================================================
164
+
165
+ def messages(self, channel_id: Optional[str] = None) -> Messages:
166
+ """
167
+ Return a Messages helper bound to a channel.
168
+
169
+ Why this exists:
170
+ - Legacy Conversations/Channels relied on Messages for history + threads.
171
+ - Keeps Conversations focused on channel/admin/discovery operations.
172
+ """
173
+ cid = channel_id or self.channel_id
174
+ if not cid:
175
+ raise ValueError("messages() requires channel_id (passed or bound).")
176
+ return Messages(cfg=self.cfg, client=self.client, logger=self.logger, api=self.api, channel_id=cid)
177
+
178
+ def get_conversation_info(self, channel_id: str) -> Dict[str, Any]:
179
+ """Public method for conversations.info (calls wrapper)."""
180
+ return self._conversations_info(channel_id)
181
+
182
+ def is_private(self) -> bool:
183
+ """
184
+ Returns True if the conversation is private.
185
+
186
+ Uses cached attributes if available; otherwise raises unless bound (then refreshes).
187
+ """
188
+ attrs = self._require_attributes()
189
+ return bool(attrs.get("is_private", False))
190
+
191
+ def get_conversation_name(self, channel_id: str = "") -> str:
192
+ """
193
+ Returns conversation name for self (cached) or for the provided channel_id (fresh lookup).
194
+
195
+ Mirrors legacy behavior: when channel_id is supplied, we fetch fresh attributes rather than
196
+ trusting local cache. :contentReference[oaicite:8]{index=8}
197
+ """
198
+ if channel_id:
199
+ info = self.get_conversation_info(channel_id)
200
+ if info.get("ok") and info.get("channel") and "name" in info["channel"]:
201
+ return str(info["channel"]["name"])
202
+ raise RuntimeError(f"Could not get name for channel_id={channel_id}: {info}")
203
+
204
+ attrs = self._require_attributes()
205
+ if "name" in attrs:
206
+ return str(attrs["name"])
207
+ raise RuntimeError(f"No name found in cached attributes: {attrs}")
208
+
209
+ def get_conversation_ids_from_name(
210
+ self,
211
+ channel_name: str,
212
+ *,
213
+ workspace_id: Optional[str] = None,
214
+ workspace_name: Optional[str] = None,
215
+ ) -> List[str]:
216
+ """
217
+ Search for conversations by name and return matching IDs (exact name match).
218
+
219
+ Uses admin.conversations.search (legacy approach). :contentReference[oaicite:9]{index=9}
220
+
221
+ Notes:
222
+ - Slack search is "contains" so we filter down to exact matches.
223
+ - If workspace_id is provided, we scope the search (team_ids).
224
+ - workspace_name resolution is intentionally omitted here to keep this class focused;
225
+ do it via Workspaces helper (slack.workspaces()) and pass workspace_id.
226
+ """
227
+ if workspace_name and not workspace_id:
228
+ raise ValueError("workspace_name resolution should be done via Workspaces; pass workspace_id instead.")
229
+
230
+ limit = 20 # legacy note: admin.conversations.search max appears to be 20 :contentReference[oaicite:10]{index=10}
231
+ payload: Dict[str, Any] = {"limit": limit, "query": channel_name}
232
+ if workspace_id:
233
+ payload["team_ids"] = workspace_id
234
+
235
+ found_tmp: List[Dict[str, Any]] = []
236
+ found: List[str] = []
237
+
238
+ while True:
239
+ resp = self._admin_conversations_search(payload)
240
+ if not resp.get("ok"):
241
+ # keep it explicit; scripts can catch and decide
242
+ raise RuntimeError(f"admin.conversations.search failed: {resp}")
243
+
244
+ found_tmp.extend(resp.get("conversations") or [])
245
+
246
+ next_cursor = resp.get("next_cursor") or ""
247
+ if not next_cursor:
248
+ break
249
+ payload["cursor"] = next_cursor
250
+
251
+ for convo in found_tmp:
252
+ if convo.get("name") == channel_name and convo.get("id"):
253
+ found.append(convo["id"])
254
+
255
+ return found
256
+
257
+ def archive(self, channel_id: Optional[str] = None) -> bool:
258
+ """
259
+ Archive a conversation via admin.conversations.archive.
260
+
261
+ Returns True if archived or already archived.
262
+ """
263
+ cid = channel_id or self.channel_id
264
+ if not cid:
265
+ raise ValueError("archive() requires channel_id (passed or bound)")
266
+
267
+ resp = self._admin_conversations_archive(cid)
268
+ if resp.get("ok"):
269
+ return True
270
+
271
+ # legacy behavior treated already_archived as success :contentReference[oaicite:11]{index=11}
272
+ if resp.get("error") == "already_archived":
273
+ return True
274
+
275
+ return False
276
+
277
+ def share_to_workspaces(
278
+ self,
279
+ target_ws_id: str,
280
+ *,
281
+ channel_id: Optional[str] = None,
282
+ source_ws_id: Optional[str] = None,
283
+ ) -> Dict[str, Any]:
284
+ """
285
+ Share a conversation to additional workspaces via admin.conversations.setTeams.
286
+
287
+ This mirrors your legacy `shareChannel` behavior. :contentReference[oaicite:12]{index=12}
288
+
289
+ - If source_ws_id is provided: setTeams includes both source and target (team_id + target_team_ids)
290
+ - Else: target_team_ids includes only target
291
+ """
292
+ cid = channel_id or self.channel_id
293
+ if not cid:
294
+ raise ValueError("share_to_workspaces() requires channel_id (passed or bound)")
295
+
296
+ payload: Dict[str, Any] = {"channel_id": cid}
297
+
298
+ if source_ws_id:
299
+ payload["team_id"] = source_ws_id
300
+ payload["target_team_ids"] = f"{source_ws_id},{target_ws_id}"
301
+ else:
302
+ payload["target_team_ids"] = target_ws_id
303
+
304
+ return self._admin_conversations_set_teams(payload)
305
+
306
+ def move_to_workspace(
307
+ self,
308
+ channel_id: str,
309
+ source_ws_id: str,
310
+ target_ws_id: str,
311
+ ) -> Dict[str, Any]:
312
+ """
313
+ Move a conversation from one workspace to another via two-step setTeams.
314
+
315
+ Matches your legacy `moveChannel` flow:
316
+ 1) setTeams with source + target
317
+ 2) setTeams with target only (removes from source) :contentReference[oaicite:13]{index=13}
318
+ """
319
+ # Step 1
320
+ payload_1 = {"channel_id": channel_id, "target_team_ids": f"{source_ws_id},{target_ws_id}"}
321
+ resp1 = self._admin_conversations_set_teams(payload_1)
322
+ if not resp1.get("ok"):
323
+ return resp1
324
+
325
+ # Step 2
326
+ payload_2 = {"channel_id": channel_id, "target_team_ids": target_ws_id}
327
+ resp2 = self._admin_conversations_set_teams(payload_2)
328
+ return resp2
329
+
330
+ def restrict_access_add_group(
331
+ self,
332
+ *,
333
+ channel_id: str,
334
+ group_id: str,
335
+ workspace_id: str = "",
336
+ ) -> Dict[str, Any]:
337
+ """
338
+ Add an IdP allowlist group to a (private) conversation.
339
+
340
+ Wrapper around admin.conversations.restrictAccess.addGroup. :contentReference[oaicite:14]{index=14}
341
+
342
+ workspace_id/team_id is required for some single-workspace conversations.
343
+ """
344
+ payload: Dict[str, Any] = {"channel_id": channel_id, "group_id": group_id}
345
+ if workspace_id:
346
+ payload["team_id"] = workspace_id
347
+ return self._admin_conversations_restrict_access_add_group(payload)
348
+
349
+ def get_members(
350
+ self,
351
+ *,
352
+ channel_id: Optional[str] = None,
353
+ workspace_id: str = "",
354
+ include_members_who_left: bool = False,
355
+ ) -> List[str]:
356
+ """
357
+ Return member IDs for a conversation via discovery.conversations.members.
358
+
359
+ Mirrors legacy: supports team context for single-workspace conversations and optional
360
+ include_member_left. :contentReference[oaicite:15]{index=15}
361
+ """
362
+ cid = channel_id or self.channel_id
363
+ if not cid:
364
+ raise ValueError("get_members() requires channel_id (passed or bound)")
365
+
366
+ payload: Dict[str, Any] = {"channel": cid, "limit": 1000}
367
+ if workspace_id:
368
+ payload["team"] = workspace_id
369
+ if include_members_who_left:
370
+ payload["include_member_left"] = True
371
+
372
+ members: List[str] = []
373
+ page = 0
374
+
375
+ while True:
376
+ page += 1
377
+ resp = self._discovery_conversations_members(payload)
378
+ if not resp.get("ok"):
379
+ raise RuntimeError(f"discovery.conversations.members failed on page {page}: {resp}")
380
+
381
+ members.extend(resp.get("members") or [])
382
+
383
+ offset = resp.get("offset")
384
+ if offset:
385
+ payload["offset"] = offset
386
+ else:
387
+ break
388
+
389
+ return members
390
+
391
+ def get_messages(
392
+ self,
393
+ *,
394
+ channel_id: Optional[str] = None,
395
+ include_all_metadata: bool = False,
396
+ limit: Optional[int] = None,
397
+ inclusive: bool = True,
398
+ latest: Optional[str] = None,
399
+ oldest: Optional[str] = None,
400
+ ) -> List[Dict[str, Any]]:
401
+ """
402
+ Fetch conversation history.
403
+
404
+ Delegates to Messages.get_messages() so message logic stays centralized in messages.py.
405
+ """
406
+ return self.messages(channel_id).get_messages(
407
+ channel_id=channel_id, # harmless redundancy; Messages will resolve the same channel_id
408
+ include_all_metadata=include_all_metadata,
409
+ limit=limit,
410
+ inclusive=inclusive,
411
+ latest=latest,
412
+ oldest=oldest,
413
+ )
414
+
415
+ def get_message_threads(
416
+ self,
417
+ *,
418
+ channel_id: Optional[str] = None,
419
+ thread_ts: str,
420
+ limit: Optional[int] = None,
421
+ latest: Optional[str] = None,
422
+ oldest: Optional[str] = None,
423
+ inclusive: bool = True,
424
+ ) -> List[Dict[str, Any]]:
425
+ """
426
+ Fetch thread replies for a parent message.
427
+
428
+ Delegates to Messages.get_message_threads() so thread logic stays centralized in messages.py.
429
+ """
430
+ return self.messages(channel_id).get_message_threads(
431
+ channel_id=channel_id,
432
+ thread_ts=thread_ts,
433
+ limit=limit,
434
+ latest=latest,
435
+ oldest=oldest,
436
+ inclusive=inclusive,
437
+ )