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/files.py ADDED
@@ -0,0 +1,331 @@
1
+ # src/slack_objects/files.py
2
+ from __future__ import annotations
3
+
4
+ """
5
+ slack_objects.files
6
+ ==================
7
+
8
+ Files helper for the `slack-objects` package.
9
+
10
+ Design goals (mirrors Users):
11
+ - Factory-friendly:
12
+ files = slack.files()
13
+ f = slack.files("F123")
14
+ - Modular internals:
15
+ public methods call *wrapper methods*; wrapper methods are the only place that call Slack APIs.
16
+ - Testable:
17
+ file downloads use an injectable requests.Session.
18
+
19
+ This module provides file-centric behaviors:
20
+ - Load file metadata (files.info)
21
+ - Download text file content (via url_private)
22
+ - Upload content (files.uploadV2)
23
+ - Delete/list files
24
+ - Identify the message where a file was shared (via a Conversations/Channels-like helper)
25
+ """
26
+
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Dict, Optional, Sequence, Union, List
29
+
30
+ import requests
31
+
32
+ from .base import SlackObjectBase
33
+ from .config import RateTier
34
+
35
+
36
+ @dataclass
37
+ class Files(SlackObjectBase):
38
+ """
39
+ Files domain helper.
40
+
41
+ Factory-style usage:
42
+ slack = SlackObjectsClient(cfg)
43
+ files = slack.files() # unbound
44
+ f = slack.files("F123") # bound to file_id
45
+
46
+ Notes:
47
+ - file_id is optional
48
+ - attributes are loaded lazily via refresh()
49
+ - file_content is only populated if you call get_text_content() or refresh(..., get_content=True)
50
+ """
51
+ file_id: Optional[str] = None
52
+ attributes: Dict[str, Any] = field(default_factory=dict)
53
+
54
+ # File content, only for supported types (currently text/*)
55
+ file_content: Optional[Union[str, bytes]] = None
56
+
57
+ # When locating where a file was shared, store the matching message here
58
+ source_message: Optional[Dict[str, Any]] = None
59
+
60
+ # Optional requests session (handy for unit tests and connection pooling)
61
+ http_session: requests.Session = field(default_factory=requests.Session, repr=False)
62
+
63
+ # ---------- factory helpers ----------
64
+
65
+ def with_file(self, file_id: str) -> "Files":
66
+ """Return a new Files instance bound to file_id, sharing cfg/client/logger/api."""
67
+ return Files(
68
+ cfg=self.cfg,
69
+ client=self.client,
70
+ logger=self.logger,
71
+ api=self.api,
72
+ file_id=file_id,
73
+ http_session=self.http_session,
74
+ )
75
+
76
+ # ---------- attribute lifecycle ----------
77
+
78
+ def refresh(self, file_id: Optional[str] = None, *, get_content: bool = False) -> Dict[str, Any]:
79
+ """
80
+ Refresh attributes for file_id (or self.file_id) using files.info.
81
+ If get_content=True and mimetype is text/*, also fetch the file content.
82
+ """
83
+ if file_id:
84
+ self.file_id = file_id
85
+ if not self.file_id:
86
+ raise ValueError("refresh() requires file_id (passed or already set)")
87
+
88
+ resp = self.get_file_info(self.file_id)
89
+ if not resp.get("ok"):
90
+ raise RuntimeError(f"Files.get_file_info() failed: {resp}")
91
+
92
+ # Slack returns file info under 'file'
93
+ self.attributes = resp.get("file") or {}
94
+
95
+ if get_content and self._is_text_file():
96
+ self.get_text_content()
97
+
98
+ return self.attributes
99
+
100
+ def _require_attributes(self) -> Dict[str, Any]:
101
+ """Ensure file attributes are loaded before accessing fields like url_private/mimetype."""
102
+ if self.attributes:
103
+ return self.attributes
104
+ if self.file_id:
105
+ return self.refresh()
106
+ raise ValueError("File attributes not loaded and no file_id set (call refresh() or bind file_id).")
107
+
108
+ def _is_text_file(self) -> bool:
109
+ attrs = self._require_attributes()
110
+ return "text/" in str(attrs.get("mimetype", ""))
111
+
112
+ # ============================================================
113
+ # Slack Web API wrapper layer
114
+ # ============================================================
115
+ # Only these methods should call `self.api.call(...)` directly.
116
+
117
+ def _files_info(self, file_id: str, cursor: Optional[str] = None) -> Dict[str, Any]:
118
+ """Wrapper for files.info."""
119
+ payload: Dict[str, Any] = {"file": file_id}
120
+ if cursor:
121
+ payload["cursor"] = cursor
122
+ return self.api.call(self.client, "files.info", rate_tier=RateTier.TIER_4, **payload)
123
+
124
+ def _files_delete(self, file_id: str) -> Dict[str, Any]:
125
+ """Wrapper for files.delete."""
126
+ return self.api.call(self.client, "files.delete", rate_tier=RateTier.TIER_3, file=file_id)
127
+
128
+ def _files_list(self, **kwargs) -> Dict[str, Any]:
129
+ """Wrapper for files.list (thin pass-through)."""
130
+ return self.api.call(self.client, "files.list", rate_tier=RateTier.TIER_3, **kwargs)
131
+
132
+ def _files_upload_v2(self, **kwargs) -> Dict[str, Any]:
133
+ """
134
+ Wrapper for files.uploadV2.
135
+ Slack SDK method name is typically `files.uploadV2` as an API method string.
136
+ """
137
+ return self.api.call(self.client, "files.uploadV2", rate_tier=RateTier.TIER_3, **kwargs)
138
+
139
+ # ============================================================
140
+ # HTTP wrapper layer (for url_private download)
141
+ # ============================================================
142
+
143
+ def _http_get_private_url(self, url: str) -> requests.Response:
144
+ """
145
+ Download url_private content.
146
+
147
+ Slack file downloads require Authorization: Bearer <bot_token> (or a token that can read the file).
148
+ We use cfg.bot_token by default.
149
+ """
150
+ token = getattr(self.cfg, "bot_token", None)
151
+ if not token:
152
+ raise ValueError("Downloading url_private requires cfg.bot_token")
153
+
154
+ headers = {"Authorization": f"Bearer {token}"}
155
+ timeout = getattr(self.cfg, "http_timeout_seconds", 30)
156
+ return self.http_session.get(url, headers=headers, timeout=timeout)
157
+
158
+ # ============================================================
159
+ # Public Slack Web API methods (call wrappers above)
160
+ # ============================================================
161
+
162
+ def get_file_info(self, file_id: str) -> Dict[str, Any]:
163
+ """
164
+ Public method for files.info.
165
+ Supports pagination via cursor if Slack includes response_metadata.next_cursor.
166
+ Legacy class looped through pages for comments; we keep that behavior.
167
+ """
168
+ combined_file: Dict[str, Any] = {}
169
+ cursor: Optional[str] = None
170
+
171
+ while True:
172
+ resp = self._files_info(file_id, cursor=cursor)
173
+ if not resp.get("ok"):
174
+ return resp
175
+
176
+ file_obj = resp.get("file") or {}
177
+ combined_file.update(file_obj) # merge paginated data (comments, etc.)
178
+
179
+ meta = resp.get("response_metadata") or {}
180
+ next_cursor = (meta.get("next_cursor") or "").strip()
181
+ if next_cursor:
182
+ cursor = next_cursor
183
+ else:
184
+ break
185
+
186
+ return {"ok": True, "file": combined_file}
187
+
188
+ def delete_file(self, file_id: Optional[str] = None) -> Dict[str, Any]:
189
+ """Delete a file by id (defaults to bound self.file_id)."""
190
+ fid = file_id or self.file_id
191
+ if not fid:
192
+ raise ValueError("delete_file requires file_id (passed or bound)")
193
+ return self._files_delete(fid)
194
+
195
+ def list_files(self, **kwargs) -> Dict[str, Any]:
196
+ """
197
+ List files using files.list.
198
+
199
+ This is intentionally a dict return so callers can inspect paging / metadata.
200
+ """
201
+ return self._files_list(**kwargs)
202
+
203
+ def upload_to_slack(
204
+ self,
205
+ *,
206
+ title: str,
207
+ channel: str = "",
208
+ thread_ts: str = "",
209
+ filename: Optional[str] = None,
210
+ content: Optional[str] = None,
211
+ ) -> Dict[str, Any]:
212
+ """
213
+ Upload file content to Slack via files.uploadV2.
214
+
215
+ Behavior (legacy-inspired):
216
+ - Uses self.file_content if `content` is not passed.
217
+ - Uses attributes['name'] as filename if filename not passed and available.
218
+ - If upload succeeds, updates self.file_id from response.
219
+
220
+ Note: Slack supports also uploading via `file` parameter (multipart). Here we use `content`.
221
+ """
222
+ # Decide content source
223
+ upload_content = content if content is not None else self.file_content
224
+ if upload_content is None:
225
+ raise ValueError("upload_to_slack requires content (pass content=... or set self.file_content).")
226
+
227
+ # Decide filename
228
+ if filename is None:
229
+ filename = str((self.attributes or {}).get("name") or "slack_objects_upload.txt")
230
+
231
+ payload: Dict[str, Any] = {
232
+ "content": upload_content,
233
+ "filename": filename,
234
+ "title": title,
235
+ }
236
+ if channel:
237
+ payload["channel"] = channel
238
+ if thread_ts:
239
+ payload["thread_ts"] = thread_ts
240
+
241
+ resp = self._files_upload_v2(**payload)
242
+
243
+ # Update bound file id if Slack returns it
244
+ if resp.get("ok"):
245
+ # files.uploadV2 can return either file or files list depending on usage
246
+ if "file" in resp and isinstance(resp["file"], dict) and resp["file"].get("id"):
247
+ self.file_id = resp["file"]["id"]
248
+ elif "files" in resp and isinstance(resp["files"], list) and resp["files"]:
249
+ first = resp["files"][0]
250
+ if isinstance(first, dict) and first.get("id"):
251
+ self.file_id = first["id"]
252
+
253
+ return resp
254
+
255
+ def get_text_content(self) -> str:
256
+ """
257
+ Download and store file content for text/* files using url_private.
258
+
259
+ Stores decoded text in self.file_content and returns it.
260
+ """
261
+ attrs = self._require_attributes()
262
+ mimetype = str(attrs.get("mimetype", ""))
263
+
264
+ if "text/" not in mimetype:
265
+ pretty_type = attrs.get("pretty_type", "unknown")
266
+ name = attrs.get("name", self.file_id or "unknown")
267
+ raise ValueError(
268
+ f"get_text_content is only for text/* mimetypes; got '{mimetype}' ({pretty_type}) for file '{name}'."
269
+ )
270
+
271
+ url = attrs.get("url_private")
272
+ if not url:
273
+ raise ValueError("File attributes do not include url_private; cannot download content.")
274
+
275
+ resp = self._http_get_private_url(url)
276
+ if not resp.ok:
277
+ raise RuntimeError(f"Failed to download file content (HTTP {resp.status_code}): {resp.text[:200]}")
278
+
279
+ text = resp.content.decode("utf-8")
280
+ self.file_content = text
281
+ return text
282
+
283
+ def get_file_source_message(
284
+ self,
285
+ *,
286
+ conversation,
287
+ file_id: Optional[str] = None,
288
+ user_id: Optional[str] = None,
289
+ limit: int = 5,
290
+ ) -> Optional[Dict[str, Any]]:
291
+ """
292
+ Find the most recent message in a conversation where a file was shared.
293
+
294
+ Parameters:
295
+ - conversation: an object with `.channel_id` and a `get_messages(channel_id, limit=...)` method.
296
+ - file_id: defaults to bound self.file_id
297
+ - user_id: defaults to file uploader (attributes['user']) if attributes are loaded
298
+ - limit: how many recent messages to scan (legacy default ~5)
299
+
300
+ Side effects:
301
+ - Sets self.source_message if found
302
+ - Returns the message dict if found, else None
303
+
304
+ This method calls:
305
+ conversation.get_messages(channel_id=..., limit=...)
306
+
307
+ Note:
308
+ - `get_messages` uses keyword-only arguments.
309
+ """
310
+ fid = file_id or self.file_id
311
+ if not fid:
312
+ raise ValueError("get_file_source_message requires file_id (passed or bound)")
313
+
314
+ # Best-effort user match: if not provided, try to use file uploader id
315
+ uid = user_id
316
+ if uid is None and self.attributes:
317
+ uid = self.attributes.get("user")
318
+
319
+ messages = conversation.get_messages(channel_id=conversation.channel_id, limit=limit)
320
+
321
+ for msg in messages:
322
+ files = msg.get("files")
323
+ if not files:
324
+ continue
325
+ if uid and msg.get("user") != uid:
326
+ continue
327
+ if any(isinstance(f, dict) and f.get("id") == fid for f in files):
328
+ self.source_message = msg
329
+ return msg
330
+
331
+ return None
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ slack_objects.idp_groups
5
+ ========================
6
+
7
+ IDP_groups helper for the `slack-objects` package.
8
+
9
+ Purpose
10
+ -------
11
+ Manage Identity Provider (IdP) groups synced into Slack via SCIM.
12
+ This module implements the following functionality:
13
+
14
+ - list groups (paginated)
15
+ - get members of a given group
16
+ - check whether a user is a member of a group
17
+
18
+ Design decisions
19
+ ----------------
20
+ - SCIM REST calls are centralized in `_scim_request()`; all public methods call those wrappers (keeps code modular and testable).
21
+ - Uses an injectable `requests.Session` (`scim_session`) so tests can pass a fake session.
22
+ - Keeps legacy output shapes: lists of dicts for groups and members.
23
+ """
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ import json
29
+ import time
30
+ import requests
31
+
32
+ from .base import SlackObjectBase
33
+ from .config import RateTier
34
+
35
+
36
+ @dataclass
37
+ class IDP_groups(SlackObjectBase):
38
+ """
39
+ IdP (SCIM) groups helper.
40
+
41
+ Factory usage:
42
+ slack = SlackObjectsClient(cfg)
43
+ idp = slack.idp_groups() # unbound
44
+ bound = slack.idp_groups("S123") # bound to a group_id
45
+
46
+ The SCIM session can be replaced for unit tests by passing scim_session argument.
47
+ """
48
+ group_id: Optional[str] = None
49
+ scim_session: requests.Session = field(default_factory=requests.Session, repr=False)
50
+
51
+ # ---------- factory ----------
52
+ def with_group(self, group_id: str) -> "IDP_groups":
53
+ """Return a new instance bound to a particular group_id, sharing cfg/client/logger/api."""
54
+ return IDP_groups(
55
+ cfg=self.cfg,
56
+ client=self.client,
57
+ logger=self.logger,
58
+ api=self.api,
59
+ group_id=group_id,
60
+ scim_session=self.scim_session,
61
+ )
62
+
63
+ # ---------- SCIM request wrapper ----------
64
+ def _scim_base_url(self, scim_version: str) -> str:
65
+ """Return configurable SCIM base URL; default to Slack SCIM endpoints when not overridden."""
66
+ if scim_version == "v2" and getattr(self.cfg, "scim_base_url_v2", None):
67
+ return self.cfg.scim_base_url_v2.rstrip("/") + "/"
68
+ if scim_version == "v1" and getattr(self.cfg, "scim_base_url_v1", None):
69
+ return self.cfg.scim_base_url_v1.rstrip("/") + "/"
70
+ return f"https://api.slack.com/scim/{scim_version}/"
71
+
72
+ def _scim_request(
73
+ self,
74
+ *,
75
+ path: str,
76
+ method: str = "GET",
77
+ payload: Optional[Dict[str, Any]] = None,
78
+ scim_version: str = "v1",
79
+ token: Optional[str] = None,
80
+ params: Optional[Dict[str, Any]] = None,
81
+ ) -> Dict[str, Any]:
82
+ """
83
+ Low-level SCIM request. Returns parsed JSON dict.
84
+
85
+ It raises ValueError when token is missing. Network/HTTP errors will raise requests exceptions.
86
+ We add a small sleep based on RateTier to reduce burstiness (keeps legacy cautious behavior).
87
+ """
88
+ tok = token or getattr(self.cfg, "scim_token", None)
89
+ if not tok:
90
+ raise ValueError("SCIM request requires cfg.scim_token (or token override)")
91
+
92
+ # conservative sleep to avoid bursts (can be tuned)
93
+ time.sleep(float(RateTier.TIER_2))
94
+
95
+ url = self._scim_base_url(scim_version) + path.lstrip("/")
96
+ headers = {
97
+ "Authorization": f"Bearer {tok}",
98
+ "Content-Type": "application/json; charset=utf-8",
99
+ }
100
+
101
+ resp = self.scim_session.request(
102
+ method=method.upper(),
103
+ url=url,
104
+ headers=headers,
105
+ params=params,
106
+ json=payload,
107
+ timeout=getattr(self.cfg, "http_timeout_seconds", 30),
108
+ )
109
+ resp.raise_for_status()
110
+ # best-effort JSON parse; return empty dict if no body
111
+ try:
112
+ return resp.json() if resp.text else {}
113
+ except Exception:
114
+ return {"_raw_text": resp.text or ""}
115
+
116
+ # ---------- endpoint wrappers (only these call _scim_request) ----------
117
+
118
+ def _scim_groups_list(self, *, count: int = 1000, start_index: Optional[int] = None, scim_version: str = "v1") -> Dict[str, Any]:
119
+ """
120
+ Wrapper for GET Groups (paginated).
121
+ Accepts pagination params as query parameters according to Slack SCIM docs.
122
+ """
123
+ params = {"count": count}
124
+ if start_index:
125
+ params["startIndex"] = start_index
126
+ return self._scim_request(path="Groups", method="GET", params=params, scim_version=scim_version)
127
+
128
+ def _scim_group_get(self, group_id: str, scim_version: str = "v1") -> Dict[str, Any]:
129
+ """Wrapper for GET Groups/{id}"""
130
+ return self._scim_request(path=f"Groups/{group_id}", method="GET", scim_version=scim_version)
131
+
132
+ # ---------- public helpers ----------
133
+
134
+ def get_groups(self, scim_version: str = "v1", fetch_count: int = 1000) -> List[Dict[str, str]]:
135
+ """
136
+ Return a list of IdP groups visible to the SCIM token.
137
+
138
+ Legacy behavior: returns a list of maps containing only 'group id' and 'group name'.
139
+ Pagination is respected; this method aggregates all pages.
140
+
141
+ Raises:
142
+ requests.HTTPError on non-2xx responses.
143
+ """
144
+ groups_out: List[Dict[str, str]] = []
145
+ start_index = None
146
+ total_results = None
147
+ retrieved = 0
148
+
149
+ while True:
150
+ resp = self._scim_groups_list(count=fetch_count, start_index=start_index, scim_version=scim_version)
151
+
152
+ # Slack SCIM returns 'Resources' (list) and 'totalResults' and 'startIndex' values.
153
+ resources = resp.get("Resources", []) or []
154
+ for grp in resources:
155
+ groups_out.append({"group id": grp.get("id"), "group name": grp.get("displayName")})
156
+ retrieved += 1
157
+
158
+ total_results = resp.get("totalResults", total_results)
159
+ # Calculate next page: SCIM uses startIndex + count
160
+ if total_results is None:
161
+ # If API doesn't give a total, break to avoid infinite loop
162
+ break
163
+
164
+ # Determine if we fetched all
165
+ if retrieved >= int(total_results):
166
+ break
167
+
168
+ # Move cursor forward; SCIM startIndex is 1-based
169
+ if start_index is None:
170
+ start_index = fetch_count + 1
171
+ else:
172
+ start_index = start_index + fetch_count
173
+
174
+ return groups_out
175
+
176
+ def get_members(self, group_id: Optional[str] = None, scim_version: str = "v1") -> List[Dict[str, str]]:
177
+ """
178
+ Return the members of a group as a list of dicts `{'value': <user_id>, 'display': <name>}`.
179
+
180
+ If `group_id` omitted, uses bound `self.group_id`. Raises ValueError if none provided.
181
+ """
182
+ gid = group_id or self.group_id
183
+ if not gid:
184
+ raise ValueError("get_members requires group_id (passed or bound)")
185
+
186
+ resp = self._scim_group_get(gid, scim_version=scim_version)
187
+ # In the legacy scripts, group members are at `members` in the response body
188
+ return resp.get("members", [])
189
+
190
+ def is_member(self, user_id: str, group_id: Optional[str] = None, scim_version: str = "v1") -> bool:
191
+ """
192
+ Return True if `user_id` is a member of `group_id`.
193
+ Preserves legacy semantics (scans the members list).
194
+ """
195
+ members = self.get_members(group_id=group_id, scim_version=scim_version)
196
+ for member in members:
197
+ # member dicts historically had 'value' for id
198
+ if member.get("value") == user_id:
199
+ return True
200
+ return False