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/users.py ADDED
@@ -0,0 +1,554 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ slack_objects.users
5
+ ==================
6
+
7
+ Users helper for the `slack-objects` package.
8
+
9
+ Goals:
10
+ - Factory-friendly: `users = slack.users()` or `users = slack.users("U123")`
11
+ - Modular internals: public methods call *wrapper methods*; wrapper methods are the only place
12
+ that directly call Slack Web/Admin APIs (via SlackApiCaller) or SCIM (via requests).
13
+ - Testable: wrapper methods are easy to fake/mocking; SCIM uses an injectable requests.Session.
14
+
15
+ This module intentionally covers only user-related operations and helpers.
16
+ """
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Dict, Optional, Sequence, Union, List
20
+
21
+ import json
22
+ import time
23
+ import requests
24
+
25
+ from .base import SlackObjectBase
26
+ from .config import RateTier
27
+
28
+
29
+ @dataclass
30
+ class ScimResponse:
31
+ """
32
+ Simple structured response for SCIM requests.
33
+
34
+ SCIM responses are not Slack Web API responses (no `ok` boolean), so returning a consistent
35
+ wrapper makes scripts/tests easier to write.
36
+ """
37
+ ok: bool
38
+ status_code: int
39
+ data: Dict[str, Any]
40
+ text: str
41
+
42
+
43
+ @dataclass
44
+ class Users(SlackObjectBase):
45
+ """
46
+ Users domain helper.
47
+
48
+ Factory-style usage:
49
+ slack = SlackObjectsClient(cfg)
50
+ users = slack.users() # unbound
51
+ alice = slack.users("U123") # bound to user_id
52
+
53
+ Notes:
54
+ - `user_id` is optional. If you call methods that need a user, they will require a bound
55
+ user_id or a passed user_id.
56
+ - `attributes` are cached after `refresh()`; many helpers read from this cache.
57
+ """
58
+ user_id: Optional[str] = None
59
+ attributes: Dict[str, Any] = field(default_factory=dict)
60
+
61
+ # Heuristic label used historically to identify contingent workers
62
+ cw_label: str = "[External]"
63
+
64
+ # Optional requests session (handy for unit tests and connection pooling)
65
+ scim_session: requests.Session = field(default_factory=requests.Session, repr=False)
66
+
67
+ def __post_init__(self) -> None:
68
+ super().__post_init__()
69
+
70
+ # Eagerly load attributes if we have a user_id
71
+ if self.user_id:
72
+ # will raise RuntimeError if users.info fails
73
+ self.refresh()
74
+
75
+
76
+ # ---------- factory helpers ----------
77
+
78
+ def with_user(self, user_id: str) -> "Users":
79
+ """
80
+ Return a new Users instance bound to user_id, sharing cfg/client/logger/api.
81
+ """
82
+ return Users(
83
+ cfg=self.cfg,
84
+ client=self.client,
85
+ logger=self.logger,
86
+ api=self.api,
87
+ user_id=user_id,
88
+ scim_session=self.scim_session,
89
+ )
90
+
91
+ # ---------- attribute lifecycle ----------
92
+
93
+ def refresh(self, user_id: Optional[str] = None) -> Dict[str, Any]:
94
+ """
95
+ Refresh attributes for user_id (or self.user_id) using users.info.
96
+
97
+ This method is intentionally layered: it calls `get_user_info()`, which calls the
98
+ underlying endpoint wrapper.
99
+ """
100
+ if user_id:
101
+ self.user_id = user_id
102
+ if not self.user_id:
103
+ raise ValueError("refresh() requires user_id (passed or already set)")
104
+
105
+ resp = self.get_user_info(self.user_id)
106
+ if not resp.get("ok"):
107
+ raise RuntimeError(f"Users.get_user_info() failed: {resp}")
108
+
109
+ self.attributes = resp.get("user") or {}
110
+ return self.attributes
111
+
112
+ def _require_attributes(self) -> Dict[str, Any]:
113
+ """
114
+ Ensure attributes are loaded (via refresh) before using helpers that rely on profile fields.
115
+ """
116
+ if self.attributes:
117
+ return self.attributes
118
+ if self.user_id:
119
+ return self.refresh()
120
+ raise ValueError("User attributes not loaded and no user_id set (call refresh() or bind a user_id).")
121
+
122
+ # ============================================================
123
+ # Slack Web/Admin API wrapper layer
124
+ # ============================================================
125
+ # Only these methods should call `self.api.call(...)` directly.
126
+ # Everything else should call these wrappers.
127
+
128
+ def _users_info(self, user_id: str) -> Dict[str, Any]:
129
+ """Wrapper for users.info."""
130
+ return self.api.call(self.client, "users.info", rate_tier=RateTier.TIER_4, user=user_id)
131
+
132
+ def _users_lookup_by_email(self, email: str) -> Dict[str, Any]:
133
+ """Wrapper for users.lookupByEmail."""
134
+ return self.api.call(self.client, "users.lookupByEmail", rate_tier=RateTier.TIER_3, email=email)
135
+
136
+ def _users_profile_get(self, user_id: str) -> Dict[str, Any]:
137
+ """Wrapper for users.profile.get."""
138
+ return self.api.call(self.client, "users.profile.get", rate_tier=RateTier.TIER_4, user=user_id)
139
+
140
+ def _users_profile_set_name_value(self, user_id: str, field_id: str, new_value: str) -> Dict[str, Any]:
141
+ """
142
+ Wrapper for users.profile.set using legacy name/value style.
143
+
144
+ Note: Slack's recommended pattern can also be "profile": {...}. If you switch later,
145
+ you only need to update this wrapper.
146
+ """
147
+ return self.api.call(
148
+ self.client,
149
+ "users.profile.set",
150
+ rate_tier=RateTier.TIER_3,
151
+ user=user_id,
152
+ name=field_id,
153
+ value=new_value,
154
+ )
155
+
156
+ def _admin_users_invite(self, payload: Dict[str, Any]) -> Dict[str, Any]:
157
+ """Wrapper for admin.users.invite."""
158
+ return self.api.call(self.client, "admin.users.invite", rate_tier=RateTier.TIER_2, **payload)
159
+
160
+ def _admin_users_session_reset(self, user_id: str) -> Dict[str, Any]:
161
+ """Wrapper for admin.users.session.reset."""
162
+ return self.api.call(self.client, "admin.users.session.reset", rate_tier=RateTier.TIER_2, user_id=user_id)
163
+
164
+ def _admin_users_assign(self, user_id: str, team_id: str) -> Dict[str, Any]:
165
+ """Wrapper for admin.users.assign."""
166
+ return self.api.call(self.client, "admin.users.assign", rate_tier=RateTier.TIER_2, user_id=user_id, team_id=team_id)
167
+
168
+ def _admin_users_remove(self, user_id: str, team_id: str) -> Dict[str, Any]:
169
+ """Wrapper for admin.users.remove."""
170
+ return self.api.call(self.client, "admin.users.remove", rate_tier=RateTier.TIER_2, user_id=user_id, team_id=team_id)
171
+
172
+ def _admin_conversations_invite(self, user_ids: Sequence[str], channel_id: str) -> Dict[str, Any]:
173
+ """Wrapper for admin.conversations.invite."""
174
+ return self.api.call(
175
+ self.client,
176
+ "admin.conversations.invite",
177
+ rate_tier=RateTier.TIER_2,
178
+ user_ids=list(user_ids),
179
+ channel_id=channel_id,
180
+ )
181
+
182
+ def _conversations_kick(self, user_id: str, channel_id: str) -> Dict[str, Any]:
183
+ """Wrapper for conversations.kick."""
184
+ return self.api.call(self.client, "conversations.kick", rate_tier=RateTier.TIER_3, user=user_id, channel=channel_id)
185
+
186
+ def _admin_users_set_expiration(self, *, user_id: str, expiration_ts: int, workspace_id: str = "") -> Dict[str, Any]:
187
+ """Wrapper for admin.users.setExpiration."""
188
+ payload: Dict[str, Any] = {
189
+ "expiration_ts": expiration_ts,
190
+ "user_id": user_id,
191
+ }
192
+ if workspace_id:
193
+ payload["team_id"] = workspace_id
194
+
195
+ return self.api.call(
196
+ self.client,
197
+ "admin.users.setExpiration",
198
+ rate_tier=RateTier.TIER_2,
199
+ **payload,
200
+ )
201
+
202
+ def _discovery_user_conversations(self, payload: Dict[str, Any]) -> Dict[str, Any]:
203
+ """Wrapper for discovery.user.conversations."""
204
+ return self.api.call(self.client, "discovery.user.conversations", rate_tier=RateTier.TIER_3, **payload)
205
+
206
+ # ============================================================
207
+ # Public Slack Web/Admin methods (call wrappers above)
208
+ # ============================================================
209
+
210
+ def get_user_info(self, user_id: str) -> Dict[str, Any]:
211
+ """Public method for users.info (calls wrapper)."""
212
+ return self._users_info(user_id)
213
+
214
+ def lookup_by_email(self, email: str) -> Dict[str, Any]:
215
+ """Public method for users.lookupByEmail (calls wrapper)."""
216
+ return self._users_lookup_by_email(email)
217
+
218
+ def get_user_id_from_email(self, email: str) -> str:
219
+ """
220
+ Convenience wrapper that returns only the Slack user ID for an email.
221
+
222
+ Legacy behavior returned '' on miss; keep that for compatibility.
223
+ """
224
+ resp = self.lookup_by_email(email)
225
+ if resp.get("ok"):
226
+ return (resp.get("user") or {}).get("id", "") or ""
227
+ return ""
228
+
229
+ def get_user_profile(self, user_id: Optional[str] = None) -> Dict[str, Any]:
230
+ """Fetch user profile (users.profile.get)."""
231
+ uid = user_id or self.user_id
232
+ if not uid:
233
+ raise ValueError("get_user_profile requires user_id (passed or bound)")
234
+ return self._users_profile_get(uid)
235
+
236
+ def set_user_profile_field(self, field_id: str, new_value: str, user_id: Optional[str] = None) -> Dict[str, Any]:
237
+ """Update a single profile field using the legacy name/value style (users.profile.set)."""
238
+ uid = user_id or self.user_id
239
+ if not uid:
240
+ raise ValueError("set_user_profile_field requires user_id (passed or bound)")
241
+ return self._users_profile_set_name_value(uid, field_id, new_value)
242
+
243
+ # ---------- classification helpers ----------
244
+
245
+ def is_contingent_worker(self) -> bool:
246
+ """Return True if the user's name/display_name contains the CW label."""
247
+ attrs = self._require_attributes()
248
+ real_name = str(attrs.get("real_name", ""))
249
+ display_name = str((attrs.get("profile") or {}).get("display_name", ""))
250
+ return (self.cw_label in real_name) or (self.cw_label in display_name)
251
+
252
+ def is_guest(self) -> bool:
253
+ """Return True for restricted or ultra-restricted guest accounts."""
254
+ attrs = self._require_attributes()
255
+ return bool(attrs.get("is_restricted") or attrs.get("is_ultra_restricted"))
256
+
257
+ # ---------- auth helpers ----------
258
+
259
+ def is_user_authorized(self, service_name: str, auth_level: str = "read") -> bool:
260
+ """
261
+ Determine whether the bound user is authorized for a service.
262
+
263
+ Authorization is based on IdP group membership, using policy defined in cfg.
264
+
265
+ Expected config shape:
266
+ cfg.auth_idp_groups_read_access: dict[str, list[str]]
267
+ cfg.auth_idp_groups_write_access: dict[str, list[str]]
268
+
269
+ This method intentionally delegates all membership checks to IDP_groups.
270
+ """
271
+ if not self.user_id:
272
+ raise ValueError("is_user_authorized requires a bound user_id")
273
+
274
+ # Resolve policy
275
+ if auth_level == "write":
276
+ group_ids = getattr(self.cfg, "auth_idp_groups_write_access", {}).get(service_name, [])
277
+ else:
278
+ group_ids = getattr(self.cfg, "auth_idp_groups_read_access", {}).get(service_name, [])
279
+
280
+ if not group_ids:
281
+ return False
282
+
283
+ # Lazy import to avoid circular dependencies
284
+ from .idp_groups import IDP_groups
285
+
286
+ idp = IDP_groups(
287
+ cfg=self.cfg,
288
+ client=self.client,
289
+ logger=self.logger,
290
+ api=self.api,
291
+ )
292
+
293
+ for group_id in group_ids:
294
+ if idp.is_member(user_id=self.user_id, group_id=group_id):
295
+ return True
296
+
297
+ return False
298
+
299
+
300
+ # ---------- admin api helpers ----------
301
+
302
+ def invite_user(
303
+ self,
304
+ *,
305
+ channel_ids: Union[str, Sequence[str]],
306
+ email: str,
307
+ team_id: str,
308
+ email_password_policy_enabled: bool = False,
309
+ ) -> Dict[str, Any]:
310
+ """
311
+ admin.users.invite
312
+
313
+ Accepts either "C1,C2" or ["C1", "C2"].
314
+
315
+ If cfg.user_token exists, we pass it explicitly in the payload (matches your legacy intent).
316
+ """
317
+ channel_ids_str = ",".join(channel_ids) if isinstance(channel_ids, (list, tuple, set)) else channel_ids
318
+
319
+ payload: Dict[str, Any] = {
320
+ "channel_ids": channel_ids_str,
321
+ "email": email,
322
+ "team_id": team_id,
323
+ "email_password_policy_enabled": email_password_policy_enabled,
324
+ }
325
+
326
+ if getattr(self.cfg, "user_token", None):
327
+ payload["token"] = self.cfg.user_token
328
+
329
+ return self._admin_users_invite(payload)
330
+
331
+ def wipe_all_sessions(self, user_id: Optional[str] = None) -> Dict[str, Any]:
332
+ """admin.users.session.reset"""
333
+ uid = user_id or self.user_id
334
+ if not uid:
335
+ raise ValueError("wipe_all_sessions requires user_id (passed or bound)")
336
+ return self._admin_users_session_reset(uid)
337
+
338
+ def add_to_workspace(self, user_id: str, workspace_id: str) -> Dict[str, Any]:
339
+ """admin.users.assign"""
340
+ return self._admin_users_assign(user_id=user_id, team_id=workspace_id)
341
+
342
+ def remove_from_workspace(self, user_id: str, workspace_id: str) -> Dict[str, Any]:
343
+ """admin.users.remove"""
344
+ return self._admin_users_remove(user_id=user_id, team_id=workspace_id)
345
+
346
+ def add_to_conversation(self, user_ids: Sequence[str], channel_id: str) -> Dict[str, Any]:
347
+ """admin.conversations.invite"""
348
+ return self._admin_conversations_invite(user_ids=user_ids, channel_id=channel_id)
349
+
350
+ def remove_from_conversation(self, user_id: str, channel_id: str) -> Dict[str, Any]:
351
+ """conversations.kick"""
352
+ return self._conversations_kick(user_id=user_id, channel_id=channel_id)
353
+
354
+ def set_guest_expiration_date(self, expiration_date: str, user_id: Optional[str] = None, workspace_id: str = "") -> Dict[str, Any]:
355
+ """
356
+ Set the expiration date for a guest user (admin.users.setExpiration).
357
+
358
+ Args:
359
+ expiration_date: A date string accepted by PC_Utils.Datetime.Datetime.date_to_epoch(). Default is %Y-%m-%d
360
+ user_id: Optional override; if omitted, uses the bound self.user_id
361
+ workspace_id: Optional team/workspace ID for multi-workspace orgs
362
+ """
363
+ uid = user_id or self.user_id
364
+ if not uid:
365
+ raise ValueError("set_guest_expiration_date requires user_id (passed or bound)")
366
+
367
+ # Lazy import: keeps slack-objects importable even if PC_Utils isn't installed,
368
+ # as long as this method isn't called.
369
+ from PC_Utils.Datetime import Datetime
370
+
371
+ expiration_ts = Datetime.date_to_epoch(expiration_date)
372
+ return self._admin_users_set_expiration(user_id=uid, expiration_ts=expiration_ts, workspace_id=workspace_id)
373
+
374
+
375
+ # ---------- discovery helper ----------
376
+
377
+ def get_channels(self, user_id: str, active_only: bool = True) -> List[Dict[str, Any]]:
378
+ """
379
+ discovery.user.conversations, paginated by offset.
380
+
381
+ Returns:
382
+ - If errors occur: a list of error dicts (legacy behavior preserved)
383
+ - Else: list of channels (active_only controls whether it filters to channels with date_left == 0)
384
+ """
385
+ current_channels: List[Dict[str, Any]] = []
386
+ all_channels: List[Dict[str, Any]] = []
387
+ errors: List[Dict[str, Any]] = []
388
+
389
+ payload: Dict[str, Any] = {"user": user_id, "limit": 1000}
390
+
391
+ while True:
392
+ resp = self._discovery_user_conversations(payload)
393
+
394
+ if not resp.get("ok"):
395
+ errors.append({"message": resp.get("error", "unknown_error"), "payload": dict(payload)})
396
+ break
397
+
398
+ channels = resp.get("channels") or []
399
+ for ch in channels:
400
+ all_channels.append(ch)
401
+ if ch.get("date_left", 0) == 0:
402
+ current_channels.append(ch)
403
+
404
+ offset = resp.get("offset")
405
+ if offset:
406
+ payload["offset"] = offset
407
+ else:
408
+ break
409
+
410
+ if errors:
411
+ return errors # preserve legacy behavior
412
+
413
+ return current_channels if active_only else all_channels
414
+
415
+ # ============================================================
416
+ # SCIM (requests) - already modular via _scim_request
417
+ # ============================================================
418
+
419
+ def _scim_base_url(self, scim_version: str) -> str:
420
+ """Return SCIM base URL, allowing config override."""
421
+ if scim_version == "v2" and getattr(self.cfg, "scim_base_url_v2", None):
422
+ return self.cfg.scim_base_url_v2.rstrip("/") + "/"
423
+ if scim_version == "v1" and getattr(self.cfg, "scim_base_url_v1", None):
424
+ return self.cfg.scim_base_url_v1.rstrip("/") + "/"
425
+ return f"https://api.slack.com/scim/{scim_version}/"
426
+
427
+ def _scim_request(
428
+ self,
429
+ *,
430
+ path: str,
431
+ method: str,
432
+ payload: Optional[Dict[str, Any]] = None,
433
+ scim_version: str = "v1",
434
+ token: Optional[str] = None,
435
+ ) -> ScimResponse:
436
+ """
437
+ Perform a SCIM REST request and return a structured response.
438
+
439
+ Note: SCIM rate limiting is separate from Slack Web API rate limiting; we keep a small,
440
+ conservative sleep here. If you later unify SCIM throttling with SlackApiCaller, you can
441
+ remove this sleep.
442
+ """
443
+ tok = token or getattr(self.cfg, "scim_token", None)
444
+ if not tok:
445
+ raise ValueError("SCIM request requires cfg.scim_token (or token override)")
446
+
447
+ # Simple pacing to avoid bursts (tweak as needed)
448
+ time.sleep(float(RateTier.TIER_2))
449
+
450
+ url = self._scim_base_url(scim_version) + path.lstrip("/")
451
+ headers = {
452
+ "Authorization": f"Bearer {tok}",
453
+ "Content-Type": "application/json; charset=utf-8",
454
+ }
455
+
456
+ resp = self.scim_session.request(
457
+ method=method.upper(),
458
+ url=url,
459
+ headers=headers,
460
+ data=json.dumps(payload) if payload is not None else None,
461
+ timeout=getattr(self.cfg, "http_timeout_seconds", 30),
462
+ )
463
+
464
+ text = resp.text or ""
465
+ try:
466
+ data = resp.json() if text else {}
467
+ except Exception:
468
+ data = {}
469
+
470
+ ok = resp.ok and (data.get("Errors") is None)
471
+ return ScimResponse(ok=ok, status_code=resp.status_code, data=data, text=text)
472
+
473
+ def scim_create_user(self, username: str, email: str, scim_version: str = "v1") -> ScimResponse:
474
+ """SCIM POST Users"""
475
+ if scim_version == "v2":
476
+ payload: Dict[str, Any] = {
477
+ "schemas": [
478
+ "urn:ietf:params:scim:schemas:core:2.0:User",
479
+ "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
480
+ "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
481
+ ],
482
+ "userName": username,
483
+ "emails": [{"value": email}],
484
+ }
485
+ elif scim_version == "v1":
486
+ payload = {
487
+ "schemas": [
488
+ "urn:scim:schemas:core:1.0",
489
+ "urn:scim:schemas:extension:enterprise:1.0",
490
+ "urn:scim:schemas:extension:slack:profile:1.0",
491
+ ],
492
+ "userName": username,
493
+ "emails": [{"value": email}],
494
+ }
495
+ else:
496
+ raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
497
+
498
+ return self._scim_request(path="Users", method="POST", payload=payload, scim_version=scim_version)
499
+
500
+ def scim_deactivate_user(self, user_id: str, scim_version: str = "v1") -> ScimResponse:
501
+ """SCIM DELETE Users/<id>"""
502
+ return self._scim_request(path=f"Users/{user_id}", method="DELETE", scim_version=scim_version)
503
+
504
+ def scim_update_user_attribute(
505
+ self,
506
+ *,
507
+ user_id: str,
508
+ attribute: str,
509
+ new_value: Any,
510
+ scim_version: str = "v2",
511
+ ) -> ScimResponse:
512
+ """SCIM PATCH Users/<id>"""
513
+ if scim_version == "v2":
514
+ payload = {
515
+ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
516
+ "Operations": [{"op": "replace", "path": attribute, "value": new_value}],
517
+ }
518
+ elif scim_version == "v1":
519
+ payload = {"schemas": ["urn:scim:schemas:core:1.0"], attribute: new_value}
520
+ else:
521
+ raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
522
+
523
+ return self._scim_request(path=f"Users/{user_id}", method="PATCH", payload=payload, scim_version=scim_version)
524
+
525
+ def make_multi_channel_guest(self, user_id: Optional[str] = None, scim_version: str = "v1") -> ScimResponse:
526
+ """Convert a user to a multi-channel guest via SCIM PATCH."""
527
+ uid = user_id or self.user_id
528
+ if not uid:
529
+ raise ValueError("make_multi_channel_guest requires user_id (passed or bound)")
530
+
531
+ if scim_version == "v2":
532
+ payload = {
533
+ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
534
+ "Operations": [
535
+ {
536
+ "path": "urn:ietf:params:scim:schemas:extension:slack:guest:2.0:User",
537
+ "op": "add",
538
+ "value": {"type": "multi"},
539
+ }
540
+ ],
541
+ }
542
+ elif scim_version == "v1":
543
+ payload = {
544
+ "schemas": [
545
+ "urn:scim:schemas:core:1.0",
546
+ "urn:scim:schemas:extension:enterprise:1.0",
547
+ "urn:scim:schemas:extension:slack:guest:1.0",
548
+ ],
549
+ "urn:scim:schemas:extension:slack:guest:1.0": {"type": "multi"},
550
+ }
551
+ else:
552
+ raise NotImplementedError(f"Invalid SCIM version: {scim_version}")
553
+
554
+ return self._scim_request(path=f"Users/{uid}", method="PATCH", payload=payload, scim_version=scim_version)