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,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