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,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")
|
slack_objects/client.py
ADDED
|
@@ -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)
|
slack_objects/config.py
ADDED
|
@@ -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
|
+
)
|