nui-lambda-shared-utils 1.0.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,413 @@
1
+ """
2
+ Shared Slack client for AWS Lambda functions.
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ from typing import List, Dict, Optional
8
+ from slack_sdk import WebClient
9
+ from slack_sdk.errors import SlackApiError
10
+ import boto3
11
+ from datetime import datetime
12
+ import yaml
13
+ import json
14
+
15
+ from .secrets_helper import get_secret
16
+ from .slack_formatter import SlackBlockBuilder, format_nz_time
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ # AWS account ID to friendly name mapping
21
+ # Configure this mapping for your environment
22
+ ACCOUNT_NAMES = {
23
+ # Example entries - replace with your account IDs
24
+ "123456789012": "Production",
25
+ "234567890123": "Development",
26
+ "345678901234": "Staging",
27
+ }
28
+
29
+
30
+ class SlackClient:
31
+ def __init__(self, secret_name: str):
32
+ """
33
+ Initialize Slack client with credentials from Secrets Manager.
34
+
35
+ Args:
36
+ secret_name: Secret name in AWS Secrets Manager (REQUIRED)
37
+ """
38
+ secret = secret_name
39
+ slack_credentials = get_secret(secret)
40
+ self.token = slack_credentials["bot_token"]
41
+ self.client = WebClient(token=self.token)
42
+
43
+ # Collect Lambda context for headers
44
+ self._lambda_context = self._get_lambda_context()
45
+
46
+ def _get_lambda_context(self) -> Dict[str, str]:
47
+ """
48
+ Collect Lambda runtime context from environment variables.
49
+
50
+ Returns:
51
+ Dict containing Lambda metadata
52
+ """
53
+ context = {
54
+ "function_name": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "Unknown"),
55
+ "function_version": os.environ.get("AWS_LAMBDA_FUNCTION_VERSION", "Unknown"),
56
+ "log_group": os.environ.get("AWS_LAMBDA_LOG_GROUP_NAME", "Unknown"),
57
+ "log_stream": os.environ.get("AWS_LAMBDA_LOG_STREAM_NAME", "Unknown"),
58
+ "aws_region": os.environ.get("AWS_REGION", "Unknown"),
59
+ "stage": os.environ.get("STAGE", os.environ.get("ENV", "Unknown")),
60
+ "execution_env": os.environ.get("AWS_EXECUTION_ENV", "Unknown"),
61
+ }
62
+
63
+ # Try to get AWS account info
64
+ try:
65
+ sts = boto3.client("sts")
66
+ account_info = sts.get_caller_identity()
67
+ context["aws_account_id"] = account_info.get("Account", "Unknown")
68
+ context["aws_account_arn"] = account_info.get("Arn", "Unknown")
69
+
70
+ # Map account ID to friendly name using centralized mapping
71
+ context["aws_account_name"] = ACCOUNT_NAMES.get(
72
+ context["aws_account_id"], f"Unknown Account ({context['aws_account_id']})"
73
+ )
74
+ except Exception as e:
75
+ log.debug(f"Could not fetch AWS account info: {e}")
76
+ context["aws_account_id"] = "Unknown"
77
+ context["aws_account_name"] = "Unknown"
78
+ context["aws_account_arn"] = "Unknown"
79
+
80
+ # Get deployment info from .lambda-deploy.yml or serverless.yml
81
+ context["deploy_time"] = self._get_deployment_time()
82
+ context["deploy_config_type"] = self._detect_config_type()
83
+
84
+ return context
85
+
86
+ def _detect_config_type(self) -> str:
87
+ """
88
+ Detect whether the Lambda uses .lambda-deploy.yml or serverless.yml.
89
+
90
+ Returns:
91
+ Config type string
92
+ """
93
+ # Check for config files in the Lambda's directory
94
+ # In Lambda runtime, we're in /var/task/
95
+ try:
96
+ if os.path.exists("/var/task/.lambda-deploy.yml"):
97
+ return "lambda-deploy v3.0+"
98
+ elif os.path.exists("/var/task/serverless.yml"):
99
+ return "serverless.yml"
100
+ else:
101
+ return "Unknown"
102
+ except Exception:
103
+ return "Unknown"
104
+
105
+ def _get_deployment_time(self) -> str:
106
+ """
107
+ Get the last deployment time of the Lambda function as a human-friendly age.
108
+
109
+ Returns:
110
+ Human-friendly age string (e.g., "5m ago", "2h ago", "3d ago")
111
+ """
112
+ try:
113
+ # Get function name from environment
114
+ function_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME")
115
+ if not function_name:
116
+ return "Unknown"
117
+
118
+ # Get the function's last modified time
119
+ lambda_client = boto3.client("lambda")
120
+ response = lambda_client.get_function(FunctionName=function_name)
121
+
122
+ # LastModified is in the Configuration
123
+ last_modified = response["Configuration"].get("LastModified", "Unknown")
124
+
125
+ if last_modified != "Unknown":
126
+ # Parse the timestamp
127
+ # Lambda returns format: '2023-11-15T10:30:45.123+0000'
128
+ dt = datetime.fromisoformat(last_modified.replace("+0000", "+00:00"))
129
+
130
+ # Calculate age
131
+ now = datetime.now(dt.tzinfo)
132
+ age = now - dt
133
+
134
+ # Format as human-friendly age
135
+ if age.total_seconds() < 60:
136
+ return f"{int(age.total_seconds())}s ago"
137
+ elif age.total_seconds() < 3600:
138
+ return f"{int(age.total_seconds() / 60)}m ago"
139
+ elif age.total_seconds() < 86400:
140
+ return f"{int(age.total_seconds() / 3600)}h ago"
141
+ else:
142
+ return f"{int(age.total_seconds() / 86400)}d ago"
143
+ else:
144
+ return "Unknown"
145
+
146
+ except Exception as e:
147
+ log.debug(f"Could not fetch deployment time: {e}")
148
+ return "Unknown"
149
+
150
+ def _create_lambda_header_block(self) -> List[Dict]:
151
+ """
152
+ Create a concise header block with Lambda metadata.
153
+
154
+ Returns:
155
+ List of Slack blocks for the header
156
+ """
157
+ # Simplify account name to "Production", "Development", etc.
158
+ account_name = self._lambda_context["aws_account_name"]
159
+ if "Production" in account_name:
160
+ simple_account = "Production"
161
+ expected_stage = "prod"
162
+ elif "Development" in account_name:
163
+ simple_account = "Development"
164
+ expected_stage = "dev"
165
+ elif "Production" in account_name:
166
+ simple_account = "Production"
167
+ expected_stage = "prod"
168
+ elif "Development" in account_name:
169
+ simple_account = "Development"
170
+ expected_stage = "dev"
171
+ else:
172
+ simple_account = f"Unknown ({self._lambda_context['aws_account_id']})"
173
+ expected_stage = None
174
+
175
+ # Only show stage if it doesn't match the expected environment
176
+ stage = self._lambda_context["stage"]
177
+ stage_suffix = ""
178
+ if expected_stage and stage != expected_stage:
179
+ # Stage doesn't match environment (e.g., dev Lambda in prod account)
180
+ stage_suffix = f" ({stage})"
181
+
182
+ # Create concise context lines with AI robot emoji
183
+ line1 = f"🤖 `{self._lambda_context['function_name']}`{stage_suffix}"
184
+ line2 = f"📍 {simple_account} • {self._lambda_context['aws_region']} • Deployed: {self._lambda_context['deploy_time']}"
185
+ line3 = f"📋 Log: `{self._lambda_context['log_group']}`"
186
+
187
+ # Single context block with all lines
188
+ return [{"type": "context", "elements": [{"type": "mrkdwn", "text": f"{line1}\n{line2}\n{line3}"}]}]
189
+
190
+ def _create_local_header_block(self) -> List[Dict]:
191
+ """
192
+ Create a context header block for local/manual testing.
193
+
194
+ Returns:
195
+ List of blocks for local testing context
196
+ """
197
+ import getpass
198
+ from datetime import datetime, timezone
199
+
200
+ # Get current user and timestamp
201
+ try:
202
+ username = getpass.getuser()
203
+ except Exception:
204
+ username = "Unknown"
205
+
206
+ timestamp = datetime.now(timezone.utc).strftime("%H:%M UTC")
207
+
208
+ # Map account ID to friendly name using centralized mapping
209
+ account_name = ACCOUNT_NAMES.get(
210
+ self._lambda_context["aws_account_id"], f"Unknown ({self._lambda_context['aws_account_id']})"
211
+ )
212
+
213
+ # Create local testing context lines with human emoji
214
+ line1 = f"👤 `Local Testing` • {username}"
215
+ line2 = f"📍 {account_name} • {self._lambda_context['aws_region']} • {timestamp}"
216
+ line3 = "📋 Context: Manual/Development Testing"
217
+
218
+ return [{"type": "context", "elements": [{"type": "mrkdwn", "text": f"{line1}\n{line2}\n{line3}"}]}]
219
+
220
+ def send_message(
221
+ self, channel: str, text: str, blocks: Optional[List[Dict]] = None, include_lambda_header: bool = True
222
+ ) -> bool:
223
+ """
224
+ Send a message to a Slack channel.
225
+
226
+ Args:
227
+ channel: Channel ID (not name)
228
+ text: Fallback text for notifications
229
+ blocks: Rich formatted blocks
230
+ include_lambda_header: Whether to include Lambda context header (default: True)
231
+
232
+ Returns:
233
+ bool: True if successful
234
+ """
235
+ try:
236
+ # Add appropriate header based on environment
237
+ if include_lambda_header:
238
+ if self._lambda_context["function_name"] != "Unknown":
239
+ # Running in Lambda environment - use Lambda header
240
+ header_blocks = self._create_lambda_header_block()
241
+ else:
242
+ # Running locally/manually - use local header
243
+ header_blocks = self._create_local_header_block()
244
+
245
+ if blocks:
246
+ blocks = header_blocks + blocks
247
+ else:
248
+ blocks = header_blocks
249
+
250
+ response = self.client.chat_postMessage(channel=channel, text=text, blocks=blocks)
251
+
252
+ if response["ok"]:
253
+ log.info("Slack message sent successfully", extra={"channel": channel, "ts": response["ts"]})
254
+ return True
255
+ else:
256
+ log.error("Slack API returned error", extra={"error": response.get("error", "Unknown error")})
257
+ return False
258
+
259
+ except SlackApiError as e:
260
+ log.error("Slack API error", exc_info=True, extra={"error": str(e), "channel": channel})
261
+ return False
262
+ except Exception as e:
263
+ log.error(
264
+ "Unexpected error sending Slack message", exc_info=True, extra={"error": str(e), "channel": channel}
265
+ )
266
+ return False
267
+
268
+ def send_file(self, channel: str, content: str, filename: str, title: Optional[str] = None) -> bool:
269
+ """
270
+ Upload a file to Slack.
271
+
272
+ Args:
273
+ channel: Channel ID
274
+ content: File content as string
275
+ filename: Name for the file
276
+ title: Optional title for the upload
277
+
278
+ Returns:
279
+ bool: True if successful
280
+ """
281
+ try:
282
+ response = self.client.files_upload_v2(
283
+ channel=channel, content=content, filename=filename, title=title or filename
284
+ )
285
+
286
+ if response["ok"]:
287
+ log.info("File uploaded successfully", extra={"channel": channel, "file_name": filename})
288
+ return True
289
+ else:
290
+ log.error("Slack file upload failed", extra={"error": response.get("error", "Unknown error")})
291
+ return False
292
+
293
+ except SlackApiError as e:
294
+ log.error(
295
+ "Slack API error uploading file",
296
+ exc_info=True,
297
+ extra={"error": str(e), "channel": channel, "file_name": filename},
298
+ )
299
+ return False
300
+
301
+ def send_thread_reply(
302
+ self,
303
+ channel: str,
304
+ thread_ts: str,
305
+ text: str,
306
+ blocks: Optional[List[Dict]] = None,
307
+ include_lambda_header: bool = False,
308
+ ) -> bool:
309
+ """
310
+ Send a reply in a thread.
311
+
312
+ Args:
313
+ channel: Channel ID
314
+ thread_ts: Timestamp of the parent message
315
+ text: Reply text
316
+ blocks: Optional rich formatted blocks
317
+ include_lambda_header: Whether to include Lambda context header (default: False for thread replies)
318
+
319
+ Returns:
320
+ bool: True if successful
321
+ """
322
+ try:
323
+ # Optionally add Lambda header to thread replies
324
+ if include_lambda_header and self._lambda_context["function_name"] != "Unknown":
325
+ header_blocks = self._create_lambda_header_block()
326
+ if blocks:
327
+ blocks = header_blocks + blocks
328
+ else:
329
+ blocks = header_blocks
330
+
331
+ response = self.client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=text, blocks=blocks)
332
+
333
+ if response["ok"]:
334
+ log.info(
335
+ "Thread reply sent successfully",
336
+ extra={"channel": channel, "thread_ts": thread_ts, "reply_ts": response["ts"]},
337
+ )
338
+ return True
339
+ else:
340
+ log.error("Failed to send thread reply", extra={"error": response.get("error", "Unknown error")})
341
+ return False
342
+
343
+ except SlackApiError as e:
344
+ log.error(
345
+ "Slack API error sending thread reply",
346
+ exc_info=True,
347
+ extra={"error": str(e), "channel": channel, "thread_ts": thread_ts},
348
+ )
349
+ return False
350
+
351
+ def update_message(self, channel: str, ts: str, text: str, blocks: Optional[List[Dict]] = None) -> bool:
352
+ """
353
+ Update an existing message.
354
+
355
+ Args:
356
+ channel: Channel ID
357
+ ts: Timestamp of the message to update
358
+ text: New text
359
+ blocks: New blocks
360
+
361
+ Returns:
362
+ bool: True if successful
363
+ """
364
+ try:
365
+ response = self.client.chat_update(channel=channel, ts=ts, text=text, blocks=blocks)
366
+
367
+ if response["ok"]:
368
+ log.info("Message updated successfully", extra={"channel": channel, "ts": ts})
369
+ return True
370
+ else:
371
+ log.error("Failed to update message", extra={"error": response.get("error", "Unknown error")})
372
+ return False
373
+
374
+ except SlackApiError as e:
375
+ log.error(
376
+ "Slack API error updating message", exc_info=True, extra={"error": str(e), "channel": channel, "ts": ts}
377
+ )
378
+ return False
379
+
380
+ def add_reaction(self, channel: str, ts: str, emoji: str) -> bool:
381
+ """
382
+ Add a reaction emoji to a message.
383
+
384
+ Args:
385
+ channel: Channel ID
386
+ ts: Timestamp of the message
387
+ emoji: Emoji name (without colons)
388
+
389
+ Returns:
390
+ bool: True if successful
391
+ """
392
+ try:
393
+ response = self.client.reactions_add(channel=channel, timestamp=ts, name=emoji)
394
+
395
+ if response["ok"]:
396
+ log.info("Reaction added successfully", extra={"channel": channel, "ts": ts, "emoji": emoji})
397
+ return True
398
+ else:
399
+ log.error("Failed to add reaction", extra={"error": response.get("error", "Unknown error")})
400
+ return False
401
+
402
+ except SlackApiError as e:
403
+ # already_reacted is not really an error
404
+ if e.response["error"] == "already_reacted":
405
+ log.debug("Reaction already exists", extra={"channel": channel, "ts": ts, "emoji": emoji})
406
+ return True
407
+
408
+ log.error(
409
+ "Slack API error adding reaction",
410
+ exc_info=True,
411
+ extra={"error": str(e), "channel": channel, "ts": ts, "emoji": emoji},
412
+ )
413
+ return False