nui-python-shared-utils 1.3.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,675 @@
1
+ """
2
+ Refactored Slack client using BaseClient for DRY code patterns.
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ from typing import Any, List, Dict, Optional
8
+ from pathlib import Path
9
+ from slack_sdk import WebClient
10
+ from slack_sdk.errors import SlackApiError
11
+ from datetime import datetime
12
+
13
+ try:
14
+ import yaml
15
+ YAML_AVAILABLE = True
16
+ except ImportError:
17
+ YAML_AVAILABLE = False
18
+
19
+ from .base_client import BaseClient, ServiceHealthMixin
20
+ from .utils import create_aws_client, handle_client_errors
21
+ from .lambda_helpers import get_lambda_environment_info
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ # Default account ID mappings (examples only - override via config in consuming lambdas)
26
+ DEFAULT_ACCOUNT_NAMES = {
27
+ "123456789012": "Production",
28
+ "234567890123": "Development",
29
+ "345678901234": "Staging",
30
+ }
31
+
32
+
33
+ class SlackClient(BaseClient, ServiceHealthMixin):
34
+ """
35
+ Refactored Slack client with standardized patterns and reduced duplication.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ secret_name: Optional[str] = None,
41
+ account_names: Optional[Dict[str, str]] = None,
42
+ account_names_config: Optional[str] = None,
43
+ service_name: Optional[str] = None,
44
+ credentials: Optional[Dict[str, Any]] = None,
45
+ **kwargs
46
+ ):
47
+ """
48
+ Initialize Slack client with base class functionality.
49
+
50
+ Args:
51
+ secret_name: Override default secret name
52
+ account_names: Dict mapping AWS account IDs to display names
53
+ account_names_config: Path to YAML file with account_names mapping
54
+ service_name: Optional display name for service (e.g., "my-service" instead of full Lambda name)
55
+ credentials: Direct credentials dict (keys: bot_token, webhook_url), bypasses Secrets Manager
56
+ **kwargs: Additional configuration options
57
+
58
+ Examples:
59
+ Basic initialization with defaults:
60
+ >>> slack = SlackClient()
61
+
62
+ With custom account names (programmatic):
63
+ >>> account_mappings = {
64
+ ... "123456789012": "my-prod",
65
+ ... "234567890123": "my-dev"
66
+ ... }
67
+ >>> slack = SlackClient(account_names=account_mappings)
68
+
69
+ With YAML config file (recommended for Lambdas):
70
+ >>> slack = SlackClient(account_names_config="slack_config.yaml")
71
+
72
+ With custom service name for cleaner display:
73
+ >>> slack = SlackClient(service_name="my-service")
74
+
75
+ With direct credentials (no Secrets Manager):
76
+ >>> slack = SlackClient(credentials={"bot_token": "xoxb-..."})
77
+
78
+ With environment variables (SLACK_BOT_TOKEN):
79
+ >>> # export SLACK_BOT_TOKEN=xoxb-...
80
+ >>> slack = SlackClient()
81
+ """
82
+ super().__init__(secret_name=secret_name, credentials=credentials, **kwargs)
83
+
84
+ # Load account names from config file or use provided dict
85
+ self._account_names = self._load_account_names(account_names, account_names_config)
86
+
87
+ # Store optional service name for display
88
+ self._service_name = service_name
89
+
90
+ # Collect Lambda context once during initialization
91
+ self._lambda_context = self._collect_lambda_context()
92
+
93
+ def _load_account_names(
94
+ self,
95
+ account_names: Optional[Dict[str, str]],
96
+ config_path: Optional[str]
97
+ ) -> Dict[str, str]:
98
+ """
99
+ Load account name mappings from config file or dict.
100
+
101
+ Priority order (later overrides earlier):
102
+ 1. DEFAULT_ACCOUNT_NAMES (built-in examples)
103
+ 2. YAML config file (if provided)
104
+ 3. Direct dict parameter (if provided)
105
+
106
+ Args:
107
+ account_names: Direct dict of account mappings
108
+ config_path: Path to YAML file with account_names mapping
109
+
110
+ Returns:
111
+ Dict mapping account IDs to display names
112
+
113
+ Raises:
114
+ No exceptions - failures are logged and defaults are used
115
+ """
116
+ # Start with defaults
117
+ mappings = DEFAULT_ACCOUNT_NAMES.copy()
118
+
119
+ # Load from config file if provided
120
+ if config_path:
121
+ if not YAML_AVAILABLE:
122
+ log.warning("PyYAML not installed, cannot load config from YAML file")
123
+ else:
124
+ try:
125
+ config_file = Path(config_path).resolve()
126
+
127
+ if not config_file.exists():
128
+ log.debug(f"Account names config file not found: {config_path} (optional)")
129
+ else:
130
+ with open(config_file, 'r', encoding='utf-8') as f:
131
+ config_data = yaml.safe_load(f)
132
+
133
+ if config_data is None:
134
+ log.warning(f"Account names config file is empty: {config_path}")
135
+ elif not isinstance(config_data, dict):
136
+ log.warning(f"Account names config file is not a valid YAML dict: {config_path}")
137
+ elif 'account_names' not in config_data:
138
+ log.warning(f"Account names config file missing 'account_names' key: {config_path}")
139
+ elif not isinstance(config_data['account_names'], dict):
140
+ log.warning(f"'account_names' must be a dict in config file: {config_path}")
141
+ else:
142
+ # Validate all keys and values are strings
143
+ account_data = config_data['account_names']
144
+ if all(isinstance(k, str) and isinstance(v, str) for k, v in account_data.items()):
145
+ mappings.update(account_data)
146
+ log.debug(f"Loaded {len(account_data)} account name(s) from {config_path}")
147
+ else:
148
+ log.warning(f"Account names config contains non-string keys/values: {config_path}")
149
+
150
+ except yaml.YAMLError as e:
151
+ log.warning(f"Invalid YAML in account names config {config_path}: {e}")
152
+ except (IOError, OSError) as e:
153
+ log.warning(f"Failed to read account names config {config_path}: {e}")
154
+ except Exception as e:
155
+ # Defensive catch-all for config loading - noqa: BLE001
156
+ log.warning(f"Unexpected error loading account names config {config_path}: {e}")
157
+
158
+ # Override with direct dict if provided
159
+ if account_names:
160
+ if isinstance(account_names, dict) and all(
161
+ isinstance(k, str) and isinstance(v, str) for k, v in account_names.items()
162
+ ):
163
+ mappings.update(account_names)
164
+ log.debug(f"Applied {len(account_names)} custom account name mapping(s)")
165
+ else:
166
+ log.warning("account_names parameter must be Dict[str, str], ignoring")
167
+
168
+ return mappings
169
+
170
+ def set_handler_context(self, context: Any) -> None:
171
+ """
172
+ Extract AWS account ID from the Lambda handler context object.
173
+
174
+ Call this from your handler with the Lambda context to populate
175
+ account info without an STS API call. The powertools_handler
176
+ decorator calls this automatically.
177
+
178
+ Args:
179
+ context: Lambda context object (second arg to handler)
180
+ """
181
+ arn = getattr(context, "invoked_function_arn", None)
182
+ if not arn:
183
+ return
184
+
185
+ # ARN format: arn:aws:lambda:REGION:ACCOUNT_ID:function:NAME
186
+ parts = arn.split(":")
187
+ if len(parts) >= 5:
188
+ account_id = parts[4]
189
+ self._lambda_context["aws_account_id"] = account_id
190
+ self._lambda_context["aws_account_arn"] = arn
191
+ self._lambda_context["aws_account_name"] = self._account_names.get(account_id, "Unknown")
192
+ log.debug(f"Account ID from Lambda ARN: {account_id}")
193
+
194
+ def _get_default_config_prefix(self) -> str:
195
+ """Return configuration prefix for Slack."""
196
+ return "slack"
197
+
198
+ def _get_default_secret_name(self) -> str:
199
+ """Return default secret name for Slack credentials."""
200
+ return "slack-credentials"
201
+
202
+ def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
203
+ """Resolve Slack credentials from environment variables.
204
+
205
+ Checks for SLACK_BOT_TOKEN (required to trigger).
206
+ Optionally picks up SLACK_WEBHOOK_URL.
207
+ """
208
+ bot_token = os.environ.get("SLACK_BOT_TOKEN")
209
+ if not bot_token:
210
+ return None
211
+ creds: Dict[str, Any] = {"bot_token": bot_token}
212
+ webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
213
+ if webhook_url:
214
+ creds["webhook_url"] = webhook_url
215
+ return creds
216
+
217
+ def _create_service_client(self) -> WebClient:
218
+ """Create Slack WebClient with credentials."""
219
+ bot_token = self.credentials.get("bot_token") or self.credentials.get("token")
220
+ if not bot_token:
221
+ raise ValueError("Slack credentials must include 'bot_token' or 'token'")
222
+
223
+ return WebClient(token=bot_token)
224
+
225
+ def _collect_lambda_context(self) -> Dict[str, str]:
226
+ """
227
+ Collect Lambda runtime context with AWS client integration.
228
+
229
+ Returns:
230
+ Dictionary containing Lambda and AWS context
231
+ """
232
+ # Get base environment info from shared helper
233
+ env_info = get_lambda_environment_info()
234
+
235
+ # Build context with "Unknown" defaults for display purposes
236
+ # Cast to str() for type safety - these keys are always strings in practice
237
+ context = {
238
+ "function_name": str(env_info["function_name"]) or "Unknown",
239
+ "function_version": str(env_info["function_version"]) or "Unknown",
240
+ "aws_region": str(env_info["aws_region"]) or "Unknown",
241
+ "stage": str(env_info["environment"]) if env_info["environment"] != "unknown" else "Unknown",
242
+ # Extra fields not in shared helper
243
+ "log_group": os.environ.get("AWS_LAMBDA_LOG_GROUP_NAME", "Unknown"),
244
+ "log_stream": os.environ.get("AWS_LAMBDA_LOG_STREAM_NAME", "Unknown"),
245
+ "execution_env": os.environ.get("AWS_EXECUTION_ENV", "Unknown"),
246
+ }
247
+
248
+ # Account info populated later by set_handler_context() from Lambda ARN
249
+ context.update({
250
+ "aws_account_id": "Unknown",
251
+ "aws_account_name": "Unknown",
252
+ "aws_account_arn": "Unknown",
253
+ })
254
+
255
+ # Get deployment info
256
+ context["deploy_time"] = self._get_deployment_age()
257
+ context["deploy_config_type"] = self._detect_config_type()
258
+
259
+ return context
260
+
261
+ def _get_deployment_age(self) -> str:
262
+ """
263
+ Get Lambda function deployment age using AWS client factory.
264
+
265
+ Returns:
266
+ Human-friendly age string
267
+ """
268
+ try:
269
+ function_name = self._lambda_context.get("function_name")
270
+ if function_name == "Unknown":
271
+ return "Unknown"
272
+
273
+ lambda_client = create_aws_client("lambda")
274
+ response = lambda_client.get_function(FunctionName=function_name)
275
+ last_modified = response["Configuration"].get("LastModified")
276
+
277
+ if last_modified:
278
+ dt = datetime.fromisoformat(last_modified.replace("+0000", "+00:00"))
279
+ now = datetime.now(dt.tzinfo)
280
+ age = now - dt
281
+
282
+ if age.total_seconds() < 60:
283
+ return f"{int(age.total_seconds())}s ago"
284
+ elif age.total_seconds() < 3600:
285
+ return f"{int(age.total_seconds() / 60)}m ago"
286
+ elif age.total_seconds() < 86400:
287
+ return f"{int(age.total_seconds() / 3600)}h ago"
288
+ else:
289
+ return f"{int(age.total_seconds() / 86400)}d ago"
290
+
291
+ return "Unknown"
292
+
293
+ except Exception as e:
294
+ log.debug(f"Could not fetch deployment time: {e}")
295
+ return "Unknown"
296
+
297
+ def _detect_config_type(self) -> str:
298
+ """
299
+ Detect deployment configuration type.
300
+
301
+ Returns:
302
+ Configuration type string
303
+ """
304
+ try:
305
+ if os.path.exists("/var/task/.lambda-deploy.yml"):
306
+ return "lambda-deploy v3.0+"
307
+ elif os.path.exists("/var/task/serverless.yml"):
308
+ return "serverless.yml"
309
+ return "Unknown"
310
+ except Exception:
311
+ return "Unknown"
312
+
313
+ def _create_lambda_header_block(self, event_type: Optional[str] = None) -> List[Dict]:
314
+ """
315
+ Create Lambda context header block.
316
+
317
+ Args:
318
+ event_type: Optional event type label (e.g., "Scheduled", "API", "SQS")
319
+
320
+ Returns:
321
+ List of Slack blocks for Lambda context
322
+ """
323
+ # Build account display from pre-resolved name + ID
324
+ account_id = self._lambda_context['aws_account_id']
325
+ account_name = self._lambda_context['aws_account_name']
326
+ account_display = f"{account_name} ({account_id})" if account_id != "Unknown" else "Unknown"
327
+
328
+ # Use custom service name if provided, otherwise use full function name
329
+ display_name = self._service_name or self._lambda_context['function_name']
330
+
331
+ # Build header lines (line1 includes optional event type)
332
+ line1 = f"🤖 {display_name}"
333
+ if event_type:
334
+ line1 += f" • {event_type}"
335
+ line2 = f"{account_display} • {self._lambda_context['aws_region']}"
336
+ line3 = f"📋 Log: `{self._lambda_context['log_group']}`"
337
+
338
+ return [{
339
+ "type": "context",
340
+ "elements": [{
341
+ "type": "mrkdwn",
342
+ "text": f"{line1}\n{line2}\n{line3}"
343
+ }]
344
+ }]
345
+
346
+ def _create_local_header_block(self, event_type: Optional[str] = None) -> List[Dict]:
347
+ """
348
+ Create header block for local/manual execution.
349
+
350
+ Args:
351
+ event_type: Optional event type label (e.g., "Scheduled", "API", "SQS")
352
+
353
+ Returns:
354
+ List of blocks for local context
355
+ """
356
+ import getpass
357
+ from datetime import timezone
358
+
359
+ try:
360
+ username = getpass.getuser()
361
+ except Exception:
362
+ username = "Unknown"
363
+
364
+ timestamp = datetime.now(timezone.utc).strftime("%H:%M UTC")
365
+ account_id = self._lambda_context["aws_account_id"]
366
+ account_name = self._lambda_context["aws_account_name"]
367
+ account_display = f"{account_name} ({account_id})" if account_id != "Unknown" else "Unknown"
368
+
369
+ line1 = f"👤 `Local Testing` • {username}"
370
+ if event_type:
371
+ line1 += f" • {event_type}"
372
+ line2 = f"📍 {account_display} • {self._lambda_context['aws_region']} • {timestamp}"
373
+ line3 = "📋 Context: Manual/Development Testing"
374
+
375
+ return [{
376
+ "type": "context",
377
+ "elements": [{
378
+ "type": "mrkdwn",
379
+ "text": f"{line1}\n{line2}\n{line3}"
380
+ }]
381
+ }]
382
+
383
+ @handle_client_errors(default_return=False)
384
+ def send_message(
385
+ self,
386
+ channel: str,
387
+ text: str,
388
+ blocks: Optional[List[Dict]] = None,
389
+ attachments: Optional[List[Dict]] = None,
390
+ include_lambda_header: bool = True,
391
+ event_type: Optional[str] = None
392
+ ) -> bool:
393
+ """
394
+ Send message to Slack channel with standardized error handling.
395
+
396
+ Args:
397
+ channel: Channel ID
398
+ text: Fallback text
399
+ blocks: Rich formatted blocks
400
+ attachments: Legacy attachment objects (supports color sidebars)
401
+ include_lambda_header: Whether to include context header
402
+ event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
403
+
404
+ Returns:
405
+ True if successful, False otherwise
406
+ """
407
+ def _send_operation():
408
+ # Add context header if requested
409
+ if include_lambda_header:
410
+ if self._lambda_context["function_name"] != "Unknown":
411
+ header_blocks = self._create_lambda_header_block(event_type=event_type)
412
+ else:
413
+ header_blocks = self._create_local_header_block(event_type=event_type)
414
+
415
+ if blocks:
416
+ blocks_with_header = header_blocks + blocks
417
+ else:
418
+ blocks_with_header = header_blocks
419
+ else:
420
+ blocks_with_header = blocks
421
+
422
+ response = self._service_client.chat_postMessage(
423
+ channel=channel,
424
+ text=text,
425
+ blocks=blocks_with_header,
426
+ attachments=attachments
427
+ )
428
+
429
+ if response["ok"]:
430
+ log.info(
431
+ "Slack message sent successfully",
432
+ extra={"channel": channel, "ts": response["ts"]}
433
+ )
434
+ return True
435
+ else:
436
+ log.error(
437
+ "Slack API returned error",
438
+ extra={"error": response.get("error", "Unknown error")}
439
+ )
440
+ return False
441
+
442
+ return self._execute_with_error_handling(
443
+ "send_message",
444
+ _send_operation,
445
+ channel=channel
446
+ )
447
+
448
+ @handle_client_errors(default_return=False)
449
+ def send_file(
450
+ self,
451
+ channel: str,
452
+ content: str,
453
+ filename: str,
454
+ title: Optional[str] = None
455
+ ) -> bool:
456
+ """
457
+ Upload file to Slack channel.
458
+
459
+ Args:
460
+ channel: Channel ID
461
+ content: File content
462
+ filename: File name
463
+ title: Optional title
464
+
465
+ Returns:
466
+ True if successful, False otherwise
467
+ """
468
+ def _upload_operation():
469
+ response = self._service_client.files_upload_v2(
470
+ channel=channel,
471
+ content=content,
472
+ filename=filename,
473
+ title=title or filename
474
+ )
475
+
476
+ if response["ok"]:
477
+ log.info(
478
+ "File uploaded successfully",
479
+ extra={"channel": channel, "file_name": filename}
480
+ )
481
+ return True
482
+ else:
483
+ log.error(
484
+ "Slack file upload failed",
485
+ extra={"error": response.get("error", "Unknown error")}
486
+ )
487
+ return False
488
+
489
+ return self._execute_with_error_handling(
490
+ "send_file",
491
+ _upload_operation,
492
+ channel=channel,
493
+ filename=filename
494
+ )
495
+
496
+ @handle_client_errors(default_return=False)
497
+ def send_thread_reply(
498
+ self,
499
+ channel: str,
500
+ thread_ts: str,
501
+ text: str,
502
+ blocks: Optional[List[Dict]] = None,
503
+ include_lambda_header: bool = False,
504
+ event_type: Optional[str] = None
505
+ ) -> bool:
506
+ """
507
+ Send thread reply with standardized error handling.
508
+
509
+ Args:
510
+ channel: Channel ID
511
+ thread_ts: Parent message timestamp
512
+ text: Reply text
513
+ blocks: Optional blocks
514
+ include_lambda_header: Whether to include header
515
+ event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
516
+
517
+ Returns:
518
+ True if successful, False otherwise
519
+ """
520
+ def _reply_operation():
521
+ # Add header if requested (uncommon for thread replies)
522
+ blocks_with_header = blocks
523
+ if include_lambda_header and self._lambda_context["function_name"] != "Unknown":
524
+ header_blocks = self._create_lambda_header_block(event_type=event_type)
525
+ if blocks:
526
+ blocks_with_header = header_blocks + blocks
527
+ else:
528
+ blocks_with_header = header_blocks
529
+
530
+ response = self._service_client.chat_postMessage(
531
+ channel=channel,
532
+ thread_ts=thread_ts,
533
+ text=text,
534
+ blocks=blocks_with_header
535
+ )
536
+
537
+ if response["ok"]:
538
+ log.info(
539
+ "Thread reply sent successfully",
540
+ extra={
541
+ "channel": channel,
542
+ "thread_ts": thread_ts,
543
+ "reply_ts": response["ts"]
544
+ }
545
+ )
546
+ return True
547
+ else:
548
+ log.error(
549
+ "Failed to send thread reply",
550
+ extra={"error": response.get("error", "Unknown error")}
551
+ )
552
+ return False
553
+
554
+ return self._execute_with_error_handling(
555
+ "send_thread_reply",
556
+ _reply_operation,
557
+ channel=channel,
558
+ thread_ts=thread_ts
559
+ )
560
+
561
+ @handle_client_errors(default_return=False)
562
+ def update_message(
563
+ self,
564
+ channel: str,
565
+ ts: str,
566
+ text: str,
567
+ blocks: Optional[List[Dict]] = None
568
+ ) -> bool:
569
+ """
570
+ Update existing message.
571
+
572
+ Args:
573
+ channel: Channel ID
574
+ ts: Message timestamp
575
+ text: New text
576
+ blocks: New blocks
577
+
578
+ Returns:
579
+ True if successful, False otherwise
580
+ """
581
+ def _update_operation():
582
+ response = self._service_client.chat_update(
583
+ channel=channel,
584
+ ts=ts,
585
+ text=text,
586
+ blocks=blocks
587
+ )
588
+
589
+ if response["ok"]:
590
+ log.info("Message updated successfully", extra={"channel": channel, "ts": ts})
591
+ return True
592
+ else:
593
+ log.error("Failed to update message", extra={"error": response.get("error", "Unknown error")})
594
+ return False
595
+
596
+ return self._execute_with_error_handling(
597
+ "update_message",
598
+ _update_operation,
599
+ channel=channel,
600
+ ts=ts
601
+ )
602
+
603
+ @handle_client_errors(default_return=False)
604
+ def add_reaction(self, channel: str, ts: str, emoji: str) -> bool:
605
+ """
606
+ Add reaction emoji to message.
607
+
608
+ Args:
609
+ channel: Channel ID
610
+ ts: Message timestamp
611
+ emoji: Emoji name (without colons)
612
+
613
+ Returns:
614
+ True if successful, False otherwise
615
+ """
616
+ def _reaction_operation():
617
+ response = self._service_client.reactions_add(
618
+ channel=channel,
619
+ timestamp=ts,
620
+ name=emoji
621
+ )
622
+
623
+ if response["ok"]:
624
+ log.info("Reaction added successfully", extra={"channel": channel, "ts": ts, "emoji": emoji})
625
+ return True
626
+ else:
627
+ log.error("Failed to add reaction", extra={"error": response.get("error", "Unknown error")})
628
+ return False
629
+
630
+ try:
631
+ return self._execute_with_error_handling(
632
+ "add_reaction",
633
+ _reaction_operation,
634
+ channel=channel,
635
+ ts=ts,
636
+ emoji=emoji
637
+ )
638
+ except SlackApiError as e:
639
+ # Special case: already_reacted is not an error
640
+ if e.response["error"] == "already_reacted":
641
+ log.debug("Reaction already exists", extra={"channel": channel, "ts": ts, "emoji": emoji})
642
+ return True
643
+ raise
644
+
645
+ def _perform_health_check(self):
646
+ """Perform Slack API health check."""
647
+ try:
648
+ response = self._service_client.auth_test()
649
+ if not response["ok"]:
650
+ raise Exception(f"Slack auth test failed: {response.get('error', 'Unknown error')}")
651
+ except Exception as e:
652
+ raise Exception(f"Slack health check failed: {e}")
653
+
654
+ def get_bot_info(self) -> Dict:
655
+ """
656
+ Get information about the Slack bot.
657
+
658
+ Returns:
659
+ Dictionary with bot information
660
+ """
661
+ try:
662
+ response = self._service_client.auth_test()
663
+ if response["ok"]:
664
+ return {
665
+ "bot_id": response.get("bot_id"),
666
+ "user_id": response.get("user_id"),
667
+ "team": response.get("team"),
668
+ "team_id": response.get("team_id"),
669
+ "url": response.get("url")
670
+ }
671
+ else:
672
+ raise Exception(f"Auth test failed: {response.get('error')}")
673
+ except Exception as e:
674
+ log.error(f"Failed to get bot info: {e}")
675
+ return {"error": str(e)}