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,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slack message formatting utilities for consistent block formatting across Lambda services.
|
|
3
|
+
Provides a builder pattern for creating Slack Block Kit messages.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, List, Any, Optional, Union
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
import pytz
|
|
9
|
+
|
|
10
|
+
# Severity emoji mapping
|
|
11
|
+
SEVERITY_EMOJI = {"critical": "đ¨", "warning": "â ī¸", "info": "âšī¸", "success": "â
", "error": "â"}
|
|
12
|
+
|
|
13
|
+
# Status emoji mapping
|
|
14
|
+
STATUS_EMOJI = {
|
|
15
|
+
"active": "đĸ",
|
|
16
|
+
"pending": "đĄ",
|
|
17
|
+
"completed": "â
",
|
|
18
|
+
"failed": "â",
|
|
19
|
+
"cancelled": "đĢ",
|
|
20
|
+
"ending_soon": "â°",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Number formatting helpers
|
|
25
|
+
def format_currency(value: float, currency: str) -> str:
|
|
26
|
+
"""Format a number as currency."""
|
|
27
|
+
return f"${value:,.2f} {currency}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_percentage(value: float, decimals: int = 1) -> str:
|
|
31
|
+
"""Format a number as percentage."""
|
|
32
|
+
return f"{value:.{decimals}f}%"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_number(value: Union[int, float], decimals: int = 0) -> str:
|
|
36
|
+
"""Format a number with thousands separators."""
|
|
37
|
+
if isinstance(value, int) or decimals == 0:
|
|
38
|
+
return f"{int(value):,}"
|
|
39
|
+
return f"{value:,.{decimals}f}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_nz_time(dt: Optional[datetime] = None) -> str:
|
|
43
|
+
"""Format datetime in NZ timezone."""
|
|
44
|
+
if dt is None:
|
|
45
|
+
dt = datetime.utcnow()
|
|
46
|
+
elif not dt.tzinfo:
|
|
47
|
+
dt = dt.replace(tzinfo=pytz.UTC)
|
|
48
|
+
|
|
49
|
+
nz_tz = pytz.timezone("Pacific/Auckland")
|
|
50
|
+
nz_time = dt.astimezone(nz_tz)
|
|
51
|
+
return nz_time.strftime("%I:%M %p %Z")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_date_range(start: datetime, end: datetime) -> str:
|
|
55
|
+
"""Format a date range in NZ timezone."""
|
|
56
|
+
nz_tz = pytz.timezone("Pacific/Auckland")
|
|
57
|
+
if not start.tzinfo:
|
|
58
|
+
start = start.replace(tzinfo=pytz.UTC)
|
|
59
|
+
if not end.tzinfo:
|
|
60
|
+
end = end.replace(tzinfo=pytz.UTC)
|
|
61
|
+
|
|
62
|
+
start_nz = start.astimezone(nz_tz)
|
|
63
|
+
end_nz = end.astimezone(nz_tz)
|
|
64
|
+
|
|
65
|
+
if start_nz.date() == end_nz.date():
|
|
66
|
+
return f"{start_nz.strftime('%b %d')} {start_nz.strftime('%I:%M %p')} - {end_nz.strftime('%I:%M %p %Z')}"
|
|
67
|
+
else:
|
|
68
|
+
return f"{start_nz.strftime('%b %d %I:%M %p')} - {end_nz.strftime('%b %d %I:%M %p %Z')}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SlackBlockBuilder:
|
|
72
|
+
"""
|
|
73
|
+
Builder for creating Slack Block Kit messages with consistent formatting.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
builder = SlackBlockBuilder()
|
|
77
|
+
blocks = (builder
|
|
78
|
+
.add_header("Daily Report", emoji="đ")
|
|
79
|
+
.add_context(f"Generated at {format_nz_time()}")
|
|
80
|
+
.add_divider()
|
|
81
|
+
.add_section("Total Records", format_number(150))
|
|
82
|
+
.add_fields([
|
|
83
|
+
("Revenue", format_currency(25000, "USD")),
|
|
84
|
+
("Growth", format_percentage(15.5))
|
|
85
|
+
])
|
|
86
|
+
.build()
|
|
87
|
+
)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.blocks = []
|
|
92
|
+
|
|
93
|
+
def add_header(self, text: str, emoji: Optional[str] = None) -> "SlackBlockBuilder":
|
|
94
|
+
"""Add a header block."""
|
|
95
|
+
header_text = f"{emoji} {text}" if emoji else text
|
|
96
|
+
self.blocks.append({"type": "header", "text": {"type": "plain_text", "text": header_text}})
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def add_section(self, text: str, value: Optional[str] = None, emoji: Optional[str] = None) -> "SlackBlockBuilder":
|
|
100
|
+
"""Add a section block with optional value."""
|
|
101
|
+
if value:
|
|
102
|
+
section_text = f"*{text}:* {value}"
|
|
103
|
+
else:
|
|
104
|
+
section_text = text
|
|
105
|
+
|
|
106
|
+
if emoji:
|
|
107
|
+
section_text = f"{emoji} {section_text}"
|
|
108
|
+
|
|
109
|
+
self.blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": section_text}})
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def add_fields(self, fields: List[tuple], columns: int = 2) -> "SlackBlockBuilder":
|
|
113
|
+
"""Add a section with multiple fields."""
|
|
114
|
+
field_blocks = []
|
|
115
|
+
for label, value in fields:
|
|
116
|
+
field_blocks.append({"type": "mrkdwn", "text": f"*{label}:*\n{value}"})
|
|
117
|
+
|
|
118
|
+
# Ensure even number of fields for proper layout
|
|
119
|
+
while len(field_blocks) % columns != 0:
|
|
120
|
+
field_blocks.append({"type": "mrkdwn", "text": " "})
|
|
121
|
+
|
|
122
|
+
self.blocks.append({"type": "section", "fields": field_blocks[:10]}) # Slack limit
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def add_context(self, text: str) -> "SlackBlockBuilder":
|
|
126
|
+
"""Add a context block."""
|
|
127
|
+
self.blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": text}]})
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def add_divider(self) -> "SlackBlockBuilder":
|
|
131
|
+
"""Add a divider block."""
|
|
132
|
+
self.blocks.append({"type": "divider"})
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def add_metrics_list(self, metrics: List[Dict[str, Any]]) -> "SlackBlockBuilder":
|
|
136
|
+
"""Add a formatted list of metrics."""
|
|
137
|
+
for metric in metrics:
|
|
138
|
+
text = f"âĸ *{metric['label']}:* {metric['value']}"
|
|
139
|
+
if "change" in metric:
|
|
140
|
+
change_emoji = "đ" if metric["change"] > 0 else "đ"
|
|
141
|
+
text += f" {change_emoji} {format_percentage(metric['change'])}"
|
|
142
|
+
|
|
143
|
+
self.blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def add_activity_meter(self, value: int, max_value: int = 100, width: int = 10) -> "SlackBlockBuilder":
|
|
147
|
+
"""Add a visual activity meter."""
|
|
148
|
+
filled = int((value / max_value) * width)
|
|
149
|
+
meter = "â" * filled + "â" * (width - filled)
|
|
150
|
+
percentage = (value / max_value) * 100
|
|
151
|
+
|
|
152
|
+
self.blocks.append(
|
|
153
|
+
{
|
|
154
|
+
"type": "section",
|
|
155
|
+
"text": {"type": "mrkdwn", "text": f"Activity Level: {meter} {format_percentage(percentage)}"},
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def add_error_summary(self, errors: List[Dict[str, Any]], limit: int = 3) -> "SlackBlockBuilder":
|
|
161
|
+
"""Add a formatted error summary."""
|
|
162
|
+
if not errors:
|
|
163
|
+
self.add_section("No errors detected", emoji="â
")
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
error_text = f"*Top {min(len(errors), limit)} Errors:*\n"
|
|
167
|
+
for error in errors[:limit]:
|
|
168
|
+
error_text += f"âĸ `{error.get('key', 'Unknown')}` - {error.get('doc_count', 0)} occurrences\n"
|
|
169
|
+
|
|
170
|
+
self.blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": error_text}})
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def add_chart(
|
|
174
|
+
self, title: str, data_points: List[float], labels: Optional[List[str]] = None
|
|
175
|
+
) -> "SlackBlockBuilder":
|
|
176
|
+
"""Add a simple ASCII chart."""
|
|
177
|
+
max_val = max(data_points) if data_points else 1
|
|
178
|
+
chart_height = 5
|
|
179
|
+
|
|
180
|
+
chart_text = f"*{title}*\n```\n"
|
|
181
|
+
|
|
182
|
+
# Create the chart
|
|
183
|
+
for row in range(chart_height, 0, -1):
|
|
184
|
+
threshold = (row / chart_height) * max_val
|
|
185
|
+
line = ""
|
|
186
|
+
for val in data_points:
|
|
187
|
+
if val >= threshold:
|
|
188
|
+
line += "â "
|
|
189
|
+
else:
|
|
190
|
+
line += " "
|
|
191
|
+
chart_text += line + "\n"
|
|
192
|
+
|
|
193
|
+
# Add labels if provided
|
|
194
|
+
if labels:
|
|
195
|
+
chart_text += "-" * (len(data_points) * 2) + "\n"
|
|
196
|
+
label_line = ""
|
|
197
|
+
for i, label in enumerate(labels[: len(data_points)]):
|
|
198
|
+
if i < len(data_points):
|
|
199
|
+
label_line += label[:1] + " "
|
|
200
|
+
chart_text += label_line + "\n"
|
|
201
|
+
|
|
202
|
+
chart_text += "```"
|
|
203
|
+
|
|
204
|
+
self.blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chart_text}})
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def add_alert(
|
|
208
|
+
self, severity: str, service: str, message: str, details: Optional[Dict] = None
|
|
209
|
+
) -> "SlackBlockBuilder":
|
|
210
|
+
"""Add a formatted alert block."""
|
|
211
|
+
severity_emoji = SEVERITY_EMOJI.get(severity.lower(), "â")
|
|
212
|
+
|
|
213
|
+
self.add_header(f"Alert - {severity.upper()}", emoji=severity_emoji)
|
|
214
|
+
self.add_fields(
|
|
215
|
+
[
|
|
216
|
+
("Service", service.upper()),
|
|
217
|
+
("Time", format_nz_time()),
|
|
218
|
+
("Environment", "Production"),
|
|
219
|
+
("Severity", severity.title()),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
self.add_section(message)
|
|
223
|
+
|
|
224
|
+
if details:
|
|
225
|
+
detail_text = "*Details:*\n"
|
|
226
|
+
for key, value in details.items():
|
|
227
|
+
detail_text += f"âĸ {key}: {value}\n"
|
|
228
|
+
self.add_section(detail_text)
|
|
229
|
+
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
def add_summary_stats(self, stats: Dict[str, Any]) -> "SlackBlockBuilder":
|
|
233
|
+
"""Add a formatted summary statistics section."""
|
|
234
|
+
self.add_section("đ *Summary Statistics*")
|
|
235
|
+
|
|
236
|
+
fields = []
|
|
237
|
+
for key, value in stats.items():
|
|
238
|
+
label = key.replace("_", " ").title()
|
|
239
|
+
if isinstance(value, (int, float)):
|
|
240
|
+
if "percent" in key or "rate" in key:
|
|
241
|
+
formatted_value = format_percentage(value)
|
|
242
|
+
else:
|
|
243
|
+
formatted_value = format_number(value)
|
|
244
|
+
else:
|
|
245
|
+
formatted_value = str(value)
|
|
246
|
+
|
|
247
|
+
fields.append((label, formatted_value))
|
|
248
|
+
|
|
249
|
+
self.add_fields(fields)
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def add_code_block(self, code: str, language: str = "") -> "SlackBlockBuilder":
|
|
253
|
+
"""Add a code block."""
|
|
254
|
+
self.blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"```{language}\n{code}\n```"}})
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def build(self) -> List[Dict]:
|
|
258
|
+
"""Build and return the blocks list."""
|
|
259
|
+
# Ensure we don't exceed Slack's 50 block limit
|
|
260
|
+
return self.blocks[:50]
|
|
261
|
+
|
|
262
|
+
def clear(self) -> "SlackBlockBuilder":
|
|
263
|
+
"""Clear all blocks and start fresh."""
|
|
264
|
+
self.blocks = []
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Convenience functions for common patterns
|
|
269
|
+
def format_daily_header(report_name: str, time_window: Optional[Dict] = None) -> List[Dict]:
|
|
270
|
+
"""Create a standard daily report header."""
|
|
271
|
+
builder = SlackBlockBuilder()
|
|
272
|
+
builder.add_header(f"Daily {report_name}", emoji="đ")
|
|
273
|
+
|
|
274
|
+
if time_window:
|
|
275
|
+
builder.add_context(f"*Period:* {time_window.get('start', 'N/A')} - {time_window.get('end', 'N/A')}")
|
|
276
|
+
else:
|
|
277
|
+
builder.add_context(f"*Generated:* {format_nz_time()}")
|
|
278
|
+
|
|
279
|
+
builder.add_divider()
|
|
280
|
+
return builder.build()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def format_weekly_header(report_name: str, week_start: datetime) -> List[Dict]:
|
|
284
|
+
"""Create a standard weekly report header."""
|
|
285
|
+
builder = SlackBlockBuilder()
|
|
286
|
+
builder.add_header(f"Weekly {report_name}", emoji="đ")
|
|
287
|
+
|
|
288
|
+
week_end = week_start + timedelta(days=6)
|
|
289
|
+
builder.add_context(f"*Week of:* {format_date_range(week_start, week_end)}")
|
|
290
|
+
builder.add_divider()
|
|
291
|
+
return builder.build()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def format_error_alert(service: str, error_rate: float, top_errors: List[Dict]) -> List[Dict]:
|
|
295
|
+
"""Create a standard error alert."""
|
|
296
|
+
builder = SlackBlockBuilder()
|
|
297
|
+
severity = "critical" if error_rate > 20 else "warning"
|
|
298
|
+
|
|
299
|
+
builder.add_alert(
|
|
300
|
+
severity=severity,
|
|
301
|
+
service=service,
|
|
302
|
+
message=f"Error rate {format_percentage(error_rate)} exceeds threshold",
|
|
303
|
+
details={"threshold": "10%", "current": format_percentage(error_rate)},
|
|
304
|
+
)
|
|
305
|
+
builder.add_error_summary(top_errors)
|
|
306
|
+
|
|
307
|
+
return builder.build()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slack workspace automation utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .channel_creator import ChannelCreator
|
|
6
|
+
from .channel_definitions import ChannelDefinition, load_channel_config
|
|
7
|
+
from .setup_helpers import SlackSetupHelper
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ChannelCreator",
|
|
11
|
+
"ChannelDefinition",
|
|
12
|
+
"load_channel_config",
|
|
13
|
+
"SlackSetupHelper",
|
|
14
|
+
]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core channel creation logic for Slack setup.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import logging
|
|
8
|
+
from typing import List, Dict, Optional
|
|
9
|
+
from slack_sdk import WebClient
|
|
10
|
+
from slack_sdk.errors import SlackApiError
|
|
11
|
+
|
|
12
|
+
from .channel_definitions import ChannelDefinition
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChannelCreator:
|
|
18
|
+
"""Handles Slack channel creation with bot management."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, token: Optional[str] = None):
|
|
21
|
+
"""
|
|
22
|
+
Initialize channel creator.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
token: Slack bot token (uses SLACK_BOT_TOKEN env var if not provided)
|
|
26
|
+
"""
|
|
27
|
+
self.token = token or os.environ.get("SLACK_BOT_TOKEN")
|
|
28
|
+
if not self.token:
|
|
29
|
+
raise ValueError("No Slack bot token provided. Set SLACK_BOT_TOKEN environment variable.")
|
|
30
|
+
|
|
31
|
+
self.client = WebClient(token=self.token)
|
|
32
|
+
self.bot_user_id = None
|
|
33
|
+
self._get_bot_info()
|
|
34
|
+
|
|
35
|
+
def _get_bot_info(self):
|
|
36
|
+
"""Get bot user ID for self-invitation."""
|
|
37
|
+
try:
|
|
38
|
+
response = self.client.auth_test()
|
|
39
|
+
self.bot_user_id = response["user_id"]
|
|
40
|
+
self.bot_name = response["user"]
|
|
41
|
+
log.info(f"Bot authenticated as {self.bot_name} (ID: {self.bot_user_id})")
|
|
42
|
+
except SlackApiError as e:
|
|
43
|
+
log.error(f"Failed to get bot info: {e}")
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
def create_channels(self, definitions: List[ChannelDefinition]) -> Dict[str, str]:
|
|
47
|
+
"""
|
|
48
|
+
Create channels from definitions.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
definitions: List of channel definitions
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Dict mapping channel names to IDs
|
|
55
|
+
"""
|
|
56
|
+
channel_map = {}
|
|
57
|
+
|
|
58
|
+
for definition in definitions:
|
|
59
|
+
try:
|
|
60
|
+
channel_id = self._create_or_get_channel(definition)
|
|
61
|
+
if channel_id:
|
|
62
|
+
channel_map[definition.name] = channel_id
|
|
63
|
+
|
|
64
|
+
# Bot joins the channel
|
|
65
|
+
self._bot_join_channel(channel_id, definition.name)
|
|
66
|
+
|
|
67
|
+
# Set topic and purpose
|
|
68
|
+
self._configure_channel(channel_id, definition)
|
|
69
|
+
|
|
70
|
+
# Invite users if specified
|
|
71
|
+
if definition.invite_users:
|
|
72
|
+
self._invite_users(channel_id, definition.invite_users, definition.name)
|
|
73
|
+
|
|
74
|
+
# Post welcome message
|
|
75
|
+
self._post_welcome_message(channel_id, definition)
|
|
76
|
+
|
|
77
|
+
# Small delay to avoid rate limits
|
|
78
|
+
time.sleep(1)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
log.error(f"Error processing channel {definition.name}: {e}")
|
|
82
|
+
|
|
83
|
+
return channel_map
|
|
84
|
+
|
|
85
|
+
def _create_or_get_channel(self, definition: ChannelDefinition) -> Optional[str]:
|
|
86
|
+
"""Create channel or get existing channel ID."""
|
|
87
|
+
try:
|
|
88
|
+
# Try to create the channel
|
|
89
|
+
response = self.client.conversations_create(
|
|
90
|
+
name=definition.name, is_private=False # Always create public channels
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
channel_id = response["channel"]["id"]
|
|
94
|
+
log.info(f"â
Created channel: #{definition.name} (ID: {channel_id})")
|
|
95
|
+
return channel_id
|
|
96
|
+
|
|
97
|
+
except SlackApiError as e:
|
|
98
|
+
if e.response["error"] == "name_taken":
|
|
99
|
+
# Channel exists, try to find it
|
|
100
|
+
log.info(f"Channel #{definition.name} already exists, looking up ID...")
|
|
101
|
+
return self._find_channel_id(definition.name)
|
|
102
|
+
else:
|
|
103
|
+
log.error(f"Failed to create channel {definition.name}: {e}")
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
def _find_channel_id(self, channel_name: str) -> Optional[str]:
|
|
107
|
+
"""Find channel ID by name."""
|
|
108
|
+
try:
|
|
109
|
+
# Get all channels (handles pagination)
|
|
110
|
+
channels = []
|
|
111
|
+
cursor = None
|
|
112
|
+
|
|
113
|
+
while True:
|
|
114
|
+
response = self.client.conversations_list(limit=1000, cursor=cursor, exclude_archived=True)
|
|
115
|
+
channels.extend(response["channels"])
|
|
116
|
+
|
|
117
|
+
cursor = response.get("response_metadata", {}).get("next_cursor")
|
|
118
|
+
if not cursor:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
# Find our channel
|
|
122
|
+
for channel in channels:
|
|
123
|
+
if channel["name"] == channel_name:
|
|
124
|
+
log.info(f"Found existing channel ID: {channel['id']}")
|
|
125
|
+
return channel["id"]
|
|
126
|
+
|
|
127
|
+
log.warning(f"Could not find channel {channel_name}")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
except SlackApiError as e:
|
|
131
|
+
log.error(f"Error listing channels: {e}")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _bot_join_channel(self, channel_id: str, channel_name: str):
|
|
135
|
+
"""Make bot join the channel."""
|
|
136
|
+
try:
|
|
137
|
+
self.client.conversations_join(channel=channel_id)
|
|
138
|
+
log.info(f"đ¤ Bot joined #{channel_name}")
|
|
139
|
+
except SlackApiError as e:
|
|
140
|
+
if e.response["error"] == "already_in_channel":
|
|
141
|
+
log.info(f"Bot already in #{channel_name}")
|
|
142
|
+
else:
|
|
143
|
+
log.error(f"Failed to join #{channel_name}: {e}")
|
|
144
|
+
|
|
145
|
+
def _configure_channel(self, channel_id: str, definition: ChannelDefinition):
|
|
146
|
+
"""Set channel purpose and topic."""
|
|
147
|
+
# Set purpose
|
|
148
|
+
if definition.purpose:
|
|
149
|
+
try:
|
|
150
|
+
self.client.conversations_setPurpose(channel=channel_id, purpose=definition.purpose)
|
|
151
|
+
log.info(f"Set purpose: {definition.purpose}")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
log.warning(f"Could not set purpose: {e}")
|
|
154
|
+
|
|
155
|
+
# Set topic
|
|
156
|
+
if definition.topic:
|
|
157
|
+
try:
|
|
158
|
+
self.client.conversations_setTopic(channel=channel_id, topic=definition.topic)
|
|
159
|
+
log.info(f"Set topic: {definition.topic}")
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log.warning(f"Could not set topic: {e}")
|
|
162
|
+
|
|
163
|
+
def _invite_users(self, channel_id: str, user_identifiers: List[str], channel_name: str):
|
|
164
|
+
"""Invite users to channel by username or user ID."""
|
|
165
|
+
user_ids = []
|
|
166
|
+
|
|
167
|
+
for identifier in user_identifiers:
|
|
168
|
+
if identifier.startswith("U"):
|
|
169
|
+
# Already a user ID
|
|
170
|
+
user_ids.append(identifier)
|
|
171
|
+
else:
|
|
172
|
+
# Try to look up by username
|
|
173
|
+
user_id = self._get_user_id_by_name(identifier)
|
|
174
|
+
if user_id:
|
|
175
|
+
user_ids.append(user_id)
|
|
176
|
+
else:
|
|
177
|
+
log.warning(f"Could not find user: {identifier}")
|
|
178
|
+
|
|
179
|
+
if user_ids:
|
|
180
|
+
try:
|
|
181
|
+
self.client.conversations_invite(channel=channel_id, users=",".join(user_ids))
|
|
182
|
+
log.info(f"đĨ Invited {len(user_ids)} users to #{channel_name}")
|
|
183
|
+
except SlackApiError as e:
|
|
184
|
+
if e.response["error"] == "already_in_channel":
|
|
185
|
+
log.info(f"Some users already in #{channel_name}")
|
|
186
|
+
else:
|
|
187
|
+
log.error(f"Failed to invite users: {e}")
|
|
188
|
+
|
|
189
|
+
def _get_user_id_by_name(self, username: str) -> Optional[str]:
|
|
190
|
+
"""Look up user ID by username."""
|
|
191
|
+
try:
|
|
192
|
+
# Remove @ if present
|
|
193
|
+
username = username.lstrip("@")
|
|
194
|
+
|
|
195
|
+
# Get all users (handles pagination)
|
|
196
|
+
users = []
|
|
197
|
+
cursor = None
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
response = self.client.users_list(limit=1000, cursor=cursor)
|
|
201
|
+
users.extend(response["members"])
|
|
202
|
+
|
|
203
|
+
cursor = response.get("response_metadata", {}).get("next_cursor")
|
|
204
|
+
if not cursor:
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
# Find user by name or display name
|
|
208
|
+
for user in users:
|
|
209
|
+
if not user.get("deleted", False):
|
|
210
|
+
if (
|
|
211
|
+
user.get("name") == username
|
|
212
|
+
or user.get("real_name", "").lower() == username.lower()
|
|
213
|
+
or user.get("profile", {}).get("display_name", "").lower() == username.lower()
|
|
214
|
+
):
|
|
215
|
+
return user["id"]
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
except SlackApiError as e:
|
|
220
|
+
log.error(f"Error looking up user {username}: {e}")
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _post_welcome_message(self, channel_id: str, definition: ChannelDefinition):
|
|
224
|
+
"""Post welcome message to channel."""
|
|
225
|
+
try:
|
|
226
|
+
blocks = [
|
|
227
|
+
{"type": "header", "text": {"type": "plain_text", "text": "đ Channel Initialized!"}},
|
|
228
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{definition.description}*"}},
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
if definition.service:
|
|
232
|
+
blocks.append(
|
|
233
|
+
{
|
|
234
|
+
"type": "context",
|
|
235
|
+
"elements": [
|
|
236
|
+
{
|
|
237
|
+
"type": "mrkdwn",
|
|
238
|
+
"text": f"This channel receives automated messages from *{definition.service}*",
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
self.client.chat_postMessage(
|
|
245
|
+
channel=channel_id, text=f"Channel initialized: {definition.description}", blocks=blocks
|
|
246
|
+
)
|
|
247
|
+
log.info("Posted welcome message")
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
log.warning(f"Could not post welcome message: {e}")
|
|
251
|
+
|
|
252
|
+
def check_existing_channels(self, channel_names: List[str]) -> Dict[str, Optional[str]]:
|
|
253
|
+
"""
|
|
254
|
+
Check which channels already exist.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
channel_names: List of channel names to check
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Dict mapping channel names to IDs (None if not found)
|
|
261
|
+
"""
|
|
262
|
+
result = {}
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# Get all channels
|
|
266
|
+
channels = []
|
|
267
|
+
cursor = None
|
|
268
|
+
|
|
269
|
+
while True:
|
|
270
|
+
response = self.client.conversations_list(limit=1000, cursor=cursor, exclude_archived=True)
|
|
271
|
+
channels.extend(response["channels"])
|
|
272
|
+
|
|
273
|
+
cursor = response.get("response_metadata", {}).get("next_cursor")
|
|
274
|
+
if not cursor:
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# Check each requested channel
|
|
278
|
+
for name in channel_names:
|
|
279
|
+
found = False
|
|
280
|
+
for channel in channels:
|
|
281
|
+
if channel["name"] == name:
|
|
282
|
+
result[name] = channel["id"]
|
|
283
|
+
found = True
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
if not found:
|
|
287
|
+
result[name] = None
|
|
288
|
+
|
|
289
|
+
except SlackApiError as e:
|
|
290
|
+
log.error(f"Error checking channels: {e}")
|
|
291
|
+
# Return all as not found on error
|
|
292
|
+
for name in channel_names:
|
|
293
|
+
result[name] = None
|
|
294
|
+
|
|
295
|
+
return result
|