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.
- nui_lambda_shared_utils/__init__.py +177 -0
- nui_lambda_shared_utils/cloudwatch_metrics.py +367 -0
- nui_lambda_shared_utils/config.py +127 -0
- nui_lambda_shared_utils/db_client.py +521 -0
- nui_lambda_shared_utils/error_handler.py +372 -0
- nui_lambda_shared_utils/es_client.py +153 -0
- nui_lambda_shared_utils/es_query_builder.py +315 -0
- nui_lambda_shared_utils/secrets_helper.py +187 -0
- nui_lambda_shared_utils/slack_client.py +413 -0
- nui_lambda_shared_utils/slack_formatter.py +325 -0
- nui_lambda_shared_utils/slack_setup/__init__.py +14 -0
- nui_lambda_shared_utils/slack_setup/channel_creator.py +295 -0
- nui_lambda_shared_utils/slack_setup/channel_definitions.py +187 -0
- nui_lambda_shared_utils/slack_setup/setup_helpers.py +211 -0
- nui_lambda_shared_utils/slack_setup_cli.py +194 -0
- nui_lambda_shared_utils/timezone.py +117 -0
- nui_lambda_shared_utils-1.0.0.dist-info/METADATA +401 -0
- nui_lambda_shared_utils-1.0.0.dist-info/RECORD +22 -0
- nui_lambda_shared_utils-1.0.0.dist-info/WHEEL +5 -0
- nui_lambda_shared_utils-1.0.0.dist-info/entry_points.txt +2 -0
- nui_lambda_shared_utils-1.0.0.dist-info/licenses/LICENSE +21 -0
- nui_lambda_shared_utils-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|