ml-analytics-tools 0.2.0__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,637 @@
1
+ """
2
+ Slack connector for sending messages, images, and files to Slack channels and users.
3
+
4
+ This module provides a simple interface to interact with Slack's API for common tasks
5
+ such as sending messages, uploading files, and managing channel communications.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from slack_sdk import WebClient
11
+ from slack_sdk.errors import SlackApiError
12
+
13
+ from .utils import get_credential_value, get_logger, log_and_raise_error
14
+
15
+
16
+ class SlackConnector:
17
+ """
18
+ A connector class for interacting with Slack API.
19
+
20
+ This class provides methods to send messages, upload files, and interact
21
+ with Slack channels using a bot token.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ token: str = None,
27
+ token_path: str | Path = None,
28
+ log_level: str = "INFO",
29
+ return_response: bool = False,
30
+ scope: str = "ml",
31
+ ):
32
+ """
33
+ Initialize the Slack connector.
34
+
35
+ Parameters
36
+ ----------
37
+ token : str, optional
38
+ Slack Bot Token (starts with 'xoxb-').
39
+ If not provided, will check SLACK_BOT_TOKEN environment variable or mounted secrets,
40
+ then token_path, then default locations.
41
+ token_path : str | Path, optional
42
+ Path to file containing the Slack token.
43
+ If not provided, will look for 'slack_token.txt' in current directory.
44
+ log_level : str, optional
45
+ Logging level. Default is "INFO".
46
+ return_response : bool, optional
47
+ If True, methods return full API response. If False, returns None for cleaner output.
48
+ Default is False.
49
+ scope : str, optional
50
+ Scope for mounted secrets (e.g., '/mnt/{scope}/SLACK_BOT_TOKEN').
51
+ Default is "ml".
52
+
53
+ Examples
54
+ --------
55
+ >>> # Using token directly
56
+ >>> slack = SlackConnector(token="xoxb-your-token")
57
+ >>>
58
+ >>> # Using token from .env file (recommended)
59
+ >>> # Add to .env: SLACK_BOT_TOKEN=xoxb-your-token
60
+ >>> slack = SlackConnector()
61
+ >>>
62
+ >>> # Using token file
63
+ >>> slack = SlackConnector(token_path="slack_token.txt")
64
+ >>>
65
+ >>> # Using mounted secrets with a custom scope
66
+ >>> slack = SlackConnector(scope="custom-scope")
67
+ """
68
+ self._logger = get_logger("SlackConnector")
69
+ self._logger.setLevel(log_level)
70
+ self.return_response = return_response
71
+
72
+ if token is None and token_path is None:
73
+ # Try to get token from environment variable or mounted secret
74
+ try:
75
+ token = get_credential_value("SLACK_BOT_TOKEN", scope=scope)
76
+ self._logger.debug("Using Slack token from environment or mounted secret")
77
+ except Exception:
78
+ # Fall back to checking default file location
79
+ default_path = Path.cwd() / "slack_token.txt"
80
+ if default_path.exists():
81
+ token_path = default_path
82
+ else:
83
+ log_and_raise_error(
84
+ self._logger,
85
+ "Slack token not found. Please provide one of:\n"
86
+ " 1. Set SLACK_BOT_TOKEN in .env file\n"
87
+ " 2. Pass 'token' parameter\n"
88
+ " 3. Pass 'token_path' parameter\n"
89
+ " 4. Create 'slack_token.txt' in current directory\n"
90
+ " 5. Mount secret at /mnt/<scope>/SLACK_BOT_TOKEN",
91
+ )
92
+
93
+ if token_path is not None:
94
+ token_path = Path(token_path)
95
+ if not token_path.exists():
96
+ log_and_raise_error(
97
+ self._logger,
98
+ f"Token file not found at: {token_path}",
99
+ )
100
+ token = token_path.read_text().strip()
101
+
102
+ try:
103
+ self.client = WebClient(token=token)
104
+ response = self.client.auth_test()
105
+ self.bot_user_id = response["user_id"]
106
+ self.team_name = response.get("team", "Unknown")
107
+ self.bot_name = response.get("user", "Bot")
108
+ self._logger.info(f"Successfully connected to Slack workspace: {self.team_name} (bot: {self.bot_name})")
109
+ except SlackApiError as e:
110
+ log_and_raise_error(
111
+ self._logger,
112
+ f"Failed to connect to Slack: {e.response['error']}",
113
+ )
114
+ except Exception as e:
115
+ log_and_raise_error(
116
+ self._logger,
117
+ f"Error initializing Slack client: {e}",
118
+ )
119
+
120
+ def send_message(
121
+ self,
122
+ channel: str,
123
+ text: str,
124
+ blocks: list[dict] = None,
125
+ thread_ts: str = None,
126
+ unfurl_links: bool = True,
127
+ unfurl_media: bool = True,
128
+ ) -> dict:
129
+ """
130
+ Send a message to a Slack channel or user.
131
+
132
+ Parameters
133
+ ----------
134
+ channel : str
135
+ Channel name (e.g., '#general', 'general') or user ID.
136
+ text : str
137
+ Message text (also used as fallback for notifications).
138
+ blocks : list[dict], optional
139
+ Slack Block Kit blocks for rich formatting.
140
+ thread_ts : str, optional
141
+ Thread timestamp to reply in a thread.
142
+ unfurl_links : bool, optional
143
+ Whether to unfurl links. Default is True.
144
+ unfurl_media : bool, optional
145
+ Whether to unfurl media. Default is True.
146
+
147
+ Returns
148
+ -------
149
+ dict
150
+ API response containing message details.
151
+
152
+ Examples
153
+ --------
154
+ >>> slack = SlackConnector(token="xoxb-your-token")
155
+ >>>
156
+ >>> # Simple message
157
+ >>> slack.send_message("#general", "Hello from Python!")
158
+ >>>
159
+ >>> # Rich formatting with blocks
160
+ >>> blocks = [
161
+ ... {
162
+ ... "type": "section",
163
+ ... "text": {"type": "mrkdwn", "text": "*Important Alert*"}
164
+ ... }
165
+ ... ]
166
+ >>> slack.send_message("#alerts", "Alert", blocks=blocks)
167
+ >>>
168
+ >>> # Reply in a thread
169
+ >>> response = slack.send_message("#general", "Main message")
170
+ >>> slack.send_message("#general", "Reply", thread_ts=response["ts"])
171
+ """
172
+ # Resolve channel (accepts name or ID)
173
+ channel_id = self.get_or_resolve_channel_id(channel)
174
+
175
+ try:
176
+ response = self.client.chat_postMessage(
177
+ channel=channel_id,
178
+ text=text,
179
+ blocks=blocks,
180
+ thread_ts=thread_ts,
181
+ unfurl_links=unfurl_links,
182
+ unfurl_media=unfurl_media,
183
+ )
184
+ self._logger.info("Message sent successfully")
185
+ return response.data if self.return_response else None
186
+
187
+ except SlackApiError as e:
188
+ log_and_raise_error(
189
+ self._logger,
190
+ f"Error sending message to Slack: {e.response['error']}",
191
+ )
192
+ except Exception as e:
193
+ log_and_raise_error(
194
+ self._logger,
195
+ f"Error sending message: {e}",
196
+ )
197
+
198
+ def send_message_with_image(
199
+ self,
200
+ channel: str,
201
+ text: str,
202
+ image_url: str,
203
+ image_alt: str = "image",
204
+ title: str = None,
205
+ thread_ts: str = None,
206
+ ) -> dict:
207
+ """
208
+ Send a message with an inline image using Block Kit.
209
+
210
+ Note: The image URL must be publicly accessible.
211
+
212
+ Parameters
213
+ ----------
214
+ channel : str
215
+ Channel name or ID.
216
+ text : str
217
+ Message text.
218
+ image_url : str
219
+ Publicly accessible URL to the image.
220
+ image_alt : str, optional
221
+ Alt text for the image. Default is "image".
222
+ title : str, optional
223
+ Optional title above the image.
224
+ thread_ts : str, optional
225
+ Thread timestamp to reply in a thread.
226
+
227
+ Returns
228
+ -------
229
+ dict
230
+ API response containing message details.
231
+
232
+ Examples
233
+ --------
234
+ >>> slack = SlackConnector(token="xoxb-your-token")
235
+ >>> slack.send_message_with_image(
236
+ ... "#general",
237
+ ... "Check out our results!",
238
+ ... "https://example.com/chart.png",
239
+ ... image_alt="Performance chart",
240
+ ... title="Model Performance"
241
+ ... )
242
+ """
243
+ blocks = []
244
+
245
+ if title:
246
+ blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}})
247
+
248
+ blocks.extend(
249
+ [
250
+ {"type": "section", "text": {"type": "mrkdwn", "text": text}},
251
+ {"type": "image", "image_url": image_url, "alt_text": image_alt},
252
+ ]
253
+ )
254
+
255
+ result = self.send_message(channel, text, blocks=blocks, thread_ts=thread_ts)
256
+ self._logger.info("Message with image sent successfully")
257
+ return result
258
+
259
+ def send_file(
260
+ self,
261
+ channels: str | list[str],
262
+ file_path: str | Path = None,
263
+ content: str | bytes = None,
264
+ filename: str = None,
265
+ title: str = None,
266
+ initial_comment: str = None,
267
+ thread_ts: str = None,
268
+ ) -> dict:
269
+ """
270
+ Upload a file to Slack channel(s).
271
+
272
+ Parameters
273
+ ----------
274
+ channels : str | list[str]
275
+ Channel name(s) to upload to (e.g., '#general', 'general').
276
+ file_path : str | Path, optional
277
+ Path to file to upload.
278
+ content : str | bytes, optional
279
+ File content (alternative to file_path).
280
+ filename : str, optional
281
+ Filename to display in Slack (required if using content).
282
+ title : str, optional
283
+ Title of the file.
284
+ initial_comment : str, optional
285
+ Comment to add with the file.
286
+ thread_ts : str, optional
287
+ Thread timestamp to upload file to a thread.
288
+
289
+ Returns
290
+ -------
291
+ dict
292
+ API response containing file details.
293
+
294
+ Examples
295
+ --------
296
+ >>> slack = SlackConnector(token="xoxb-your-token")
297
+ >>>
298
+ >>> # Upload a file
299
+ >>> slack.send_file(
300
+ ... channels="#data-team",
301
+ ... file_path="report.csv",
302
+ ... title="Daily Report",
303
+ ... initial_comment="Here's today's report"
304
+ ... )
305
+ >>>
306
+ >>> # Upload to multiple channels
307
+ >>> slack.send_file(
308
+ ... channels=["#team-a", "#team-b"],
309
+ ... file_path="metrics.png",
310
+ ... title="Metrics Dashboard"
311
+ ... )
312
+ >>>
313
+ >>> # Upload from content
314
+ >>> import pandas as pd
315
+ >>> df = pd.DataFrame({'col1': [1, 2, 3]})
316
+ >>> csv_content = df.to_csv(index=False)
317
+ >>> slack.send_file(
318
+ ... channels="#data-team",
319
+ ... content=csv_content,
320
+ ... filename="data.csv",
321
+ ... title="Generated Data"
322
+ ... )
323
+ """
324
+
325
+ if isinstance(channels, str):
326
+ channels = [channels]
327
+
328
+ channel_ids = [self.get_or_resolve_channel_id(ch) for ch in channels]
329
+
330
+ try:
331
+ if file_path is not None:
332
+ response = self.client.files_upload_v2(
333
+ channel=channel_ids[0] if len(channel_ids) == 1 else None,
334
+ channels=channel_ids if len(channel_ids) > 1 else None,
335
+ file=str(file_path),
336
+ title=title,
337
+ initial_comment=initial_comment,
338
+ thread_ts=thread_ts,
339
+ )
340
+ elif content is not None:
341
+ if filename is None:
342
+ log_and_raise_error(
343
+ self._logger,
344
+ "Parameter 'filename' is required when using 'content'",
345
+ )
346
+ response = self.client.files_upload_v2(
347
+ channel=channel_ids[0] if len(channel_ids) == 1 else None,
348
+ channels=channel_ids if len(channel_ids) > 1 else None,
349
+ content=content,
350
+ filename=filename,
351
+ title=title,
352
+ initial_comment=initial_comment,
353
+ thread_ts=thread_ts,
354
+ )
355
+ else:
356
+ log_and_raise_error(
357
+ self._logger,
358
+ "Either 'file_path' or 'content' must be provided",
359
+ )
360
+
361
+ channel_list = ", ".join([ch.lstrip("#") for ch in channels])
362
+ self._logger.info(f"Successfully uploaded file to {channel_list}")
363
+ return response.data if self.return_response else None
364
+
365
+ except SlackApiError as e:
366
+ log_and_raise_error(
367
+ self._logger,
368
+ f"Error uploading file to Slack: {e.response['error']}",
369
+ )
370
+ except Exception as e:
371
+ log_and_raise_error(
372
+ self._logger,
373
+ f"Error uploading file: {e}",
374
+ )
375
+
376
+ def list_channels(
377
+ self,
378
+ types: str = "public_channel",
379
+ limit: int = 1000,
380
+ exclude_archived: bool = True,
381
+ ) -> list[dict]:
382
+ """
383
+ List channels in the workspace.
384
+
385
+ Parameters
386
+ ----------
387
+ types : str, optional
388
+ Comma-separated channel types to list.
389
+ Options: 'public_channel', 'private_channel', 'im', 'mpim'.
390
+ Default is 'public_channel'.
391
+ limit : int, optional
392
+ Maximum number of channels to return. Default is 1000.
393
+ exclude_archived : bool, optional
394
+ Whether to exclude archived channels. Default is True.
395
+
396
+ Returns
397
+ -------
398
+ list[dict]
399
+ List of channel objects with keys: id, name, is_channel, is_private, etc.
400
+
401
+ Examples
402
+ --------
403
+ >>> slack = SlackConnector(token="xoxb-your-token")
404
+ >>>
405
+ >>> # List public channels
406
+ >>> channels = slack.list_channels()
407
+ >>> for ch in channels:
408
+ ... print(f"#{ch['name']}")
409
+ >>>
410
+ >>> # List private channels (requires appropriate scopes)
411
+ >>> private = slack.list_channels(types="private_channel")
412
+ """
413
+ try:
414
+ response = self.client.conversations_list(
415
+ types=types,
416
+ limit=limit,
417
+ exclude_archived=exclude_archived,
418
+ )
419
+
420
+ return response["channels"]
421
+
422
+ except SlackApiError as e:
423
+ log_and_raise_error(
424
+ self._logger,
425
+ f"Error listing channels: {e.response['error']}",
426
+ )
427
+ except Exception as e:
428
+ log_and_raise_error(
429
+ self._logger,
430
+ f"Error listing channels: {e}",
431
+ )
432
+
433
+ def get_channel_id(self, channel_name: str) -> str | None:
434
+ """
435
+ Get channel ID from channel name.
436
+
437
+ Parameters
438
+ ----------
439
+ channel_name : str
440
+ Channel name (with or without #).
441
+
442
+ Returns
443
+ -------
444
+ str | None
445
+ Channel ID if found, None otherwise.
446
+
447
+ Examples
448
+ --------
449
+ >>> slack = SlackConnector(token="xoxb-your-token")
450
+ >>> channel_id = slack.get_channel_id("#general")
451
+ >>> print(channel_id) # C01234567
452
+ """
453
+ # Clean channel name
454
+ if channel_name.startswith("#"):
455
+ channel_name = channel_name[1:]
456
+
457
+ try:
458
+ # Try private channels first
459
+ try:
460
+ private_channels = self.list_channels(types="private_channel")
461
+ for channel in private_channels:
462
+ if channel["name"] == channel_name:
463
+ return channel["id"]
464
+ except Exception:
465
+ pass
466
+
467
+ channels = self.list_channels(types="public_channel")
468
+ for channel in channels:
469
+ if channel["name"] == channel_name:
470
+ return channel["id"]
471
+
472
+ return None
473
+
474
+ except Exception as e:
475
+ self._logger.error(f"Error getting channel ID: {e}")
476
+ return None
477
+
478
+ def get_or_resolve_channel_id(self, channel: str) -> str:
479
+ """
480
+ Get channel ID by name. If not found in conversations.list,
481
+ send a test message to resolve the ID (works for Slack Connect / multi-workspace).
482
+ """
483
+
484
+ channel_clean = channel.lstrip("#")
485
+ channel_id = self.get_channel_id(channel_clean)
486
+ if channel_id:
487
+ return channel_id
488
+
489
+ try:
490
+ resp = self.client.chat_postMessage(channel=channel_clean, text=".")
491
+ resolved_id = resp["channel"]
492
+
493
+ try:
494
+ self.client.chat_delete(channel=resolved_id, ts=resp["ts"])
495
+ except Exception:
496
+ pass
497
+
498
+ return resolved_id
499
+ except SlackApiError as e:
500
+ log_and_raise_error(self._logger, f"Could not resolve channel '{channel_clean}': {e.response['error']}")
501
+
502
+ def delete_message(self, channel: str, ts: str) -> dict:
503
+ """
504
+ Delete a message.
505
+
506
+ Parameters
507
+ ----------
508
+ channel : str
509
+ Channel name or ID where the message was posted.
510
+ ts : str
511
+ Timestamp of the message to delete.
512
+
513
+ Returns
514
+ -------
515
+ dict
516
+ API response.
517
+
518
+ Examples
519
+ --------
520
+ >>> slack = SlackConnector(token="xoxb-your-token")
521
+ >>> response = slack.send_message("#general", "Temporary message")
522
+ >>> slack.delete_message("#general", response["ts"])
523
+ """
524
+
525
+ channel_id = self.get_or_resolve_channel_id(channel)
526
+
527
+ try:
528
+ response = self.client.chat_delete(
529
+ channel=channel_id,
530
+ ts=ts,
531
+ )
532
+
533
+ self._logger.info(f"Message deleted in channel #{channel}, TS {ts}")
534
+ return response.data
535
+
536
+ except SlackApiError as e:
537
+ log_and_raise_error(
538
+ self._logger,
539
+ f"Error deleting message: {e.response['error']}",
540
+ )
541
+ except Exception as e:
542
+ log_and_raise_error(
543
+ self._logger,
544
+ f"Error deleting message: {e}",
545
+ )
546
+
547
+ def add_reaction(self, channel: str, ts: str, emoji: str) -> dict:
548
+ """
549
+ Add an emoji reaction to a message.
550
+
551
+ Parameters
552
+ ----------
553
+ channel : str
554
+ Channel name or ID where the message was posted.
555
+ ts : str
556
+ Timestamp of the message.
557
+ emoji : str
558
+ Emoji name (without colons, e.g., 'thumbsup', 'white_check_mark').
559
+
560
+ Returns
561
+ -------
562
+ dict
563
+ API response.
564
+
565
+ Examples
566
+ --------
567
+ >>> slack = SlackConnector(token="xoxb-your-token")
568
+ >>> response = slack.send_message("#general", "Great work!")
569
+ >>> slack.add_reaction("#general", response["ts"], "thumbsup")
570
+ """
571
+
572
+ channel_id = self.get_or_resolve_channel_id(channel)
573
+
574
+ emoji = emoji.strip(":")
575
+
576
+ try:
577
+ response = self.client.reactions_add(
578
+ channel=channel_id,
579
+ timestamp=ts,
580
+ name=emoji,
581
+ )
582
+
583
+ self._logger.info(f"Added reaction :{emoji}: to message in #{channel}")
584
+ return response.data
585
+
586
+ except SlackApiError as e:
587
+ log_and_raise_error(
588
+ self._logger,
589
+ f"Error adding reaction: {e.response['error']}",
590
+ )
591
+ except Exception as e:
592
+ log_and_raise_error(
593
+ self._logger,
594
+ f"Error adding reaction: {e}",
595
+ )
596
+
597
+ def delete_file(self, file_id: str) -> dict:
598
+ """
599
+ Delete a file from Slack.
600
+
601
+ Parameters
602
+ ----------
603
+ file_id : str
604
+ The ID of the file to delete (e.g., 'F01234567').
605
+
606
+ Returns
607
+ -------
608
+ dict
609
+ API response.
610
+
611
+ Examples
612
+ --------
613
+ >>> slack = SlackConnector(token="xoxb-your-token")
614
+ >>> # Upload a file and get its ID
615
+ >>> response = slack.send_file("#general", file_path="test.txt")
616
+ >>> file_id = response['file']['id']
617
+ >>> # Later, delete the file
618
+ >>> slack.delete_file(file_id)
619
+ """
620
+ try:
621
+ response = self.client.files_delete(
622
+ file=file_id,
623
+ )
624
+
625
+ self._logger.info("File deleted successfully")
626
+ return response.data
627
+
628
+ except SlackApiError as e:
629
+ log_and_raise_error(
630
+ self._logger,
631
+ f"Error deleting file: {e.response['error']}",
632
+ )
633
+ except Exception as e:
634
+ log_and_raise_error(
635
+ self._logger,
636
+ f"Error deleting file: {e}",
637
+ )