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.
- nui_lambda_shared_utils/__init__.py +252 -0
- nui_lambda_shared_utils/base_client.py +323 -0
- nui_lambda_shared_utils/cli.py +225 -0
- nui_lambda_shared_utils/cloudwatch_metrics.py +367 -0
- nui_lambda_shared_utils/config.py +136 -0
- nui_lambda_shared_utils/db_client.py +623 -0
- nui_lambda_shared_utils/error_handler.py +372 -0
- nui_lambda_shared_utils/es_client.py +460 -0
- nui_lambda_shared_utils/es_query_builder.py +315 -0
- nui_lambda_shared_utils/jwt_auth.py +277 -0
- nui_lambda_shared_utils/lambda_helpers.py +84 -0
- nui_lambda_shared_utils/log_processors.py +172 -0
- nui_lambda_shared_utils/powertools_helpers.py +263 -0
- nui_lambda_shared_utils/secrets_helper.py +187 -0
- nui_lambda_shared_utils/slack_client.py +675 -0
- nui_lambda_shared_utils/slack_formatter.py +307 -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/timezone.py +117 -0
- nui_lambda_shared_utils/utils.py +291 -0
- nui_python_shared_utils-1.3.0.dist-info/METADATA +470 -0
- nui_python_shared_utils-1.3.0.dist-info/RECORD +28 -0
- nui_python_shared_utils-1.3.0.dist-info/WHEEL +5 -0
- nui_python_shared_utils-1.3.0.dist-info/entry_points.txt +2 -0
- nui_python_shared_utils-1.3.0.dist-info/licenses/LICENSE +21 -0
- nui_python_shared_utils-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -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)}
|