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,187 @@
1
+ """
2
+ Channel definition models and configuration parser.
3
+ """
4
+
5
+ import os
6
+ import yaml
7
+ from typing import List, Dict, Optional, Any
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass
12
+ class ChannelDefinition:
13
+ """Represents a Slack channel configuration."""
14
+
15
+ name: str
16
+ purpose: str
17
+ description: str
18
+ topic: str
19
+ invite_users: List[str] = field(default_factory=list)
20
+ service: Optional[str] = None
21
+
22
+ def to_dict(self) -> Dict[str, Any]:
23
+ """Convert to dictionary representation."""
24
+ return {
25
+ "name": self.name,
26
+ "purpose": self.purpose,
27
+ "description": self.description,
28
+ "topic": self.topic,
29
+ "invite_users": self.invite_users,
30
+ "service": self.service,
31
+ }
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: Dict[str, Any], service: Optional[str] = None) -> "ChannelDefinition":
35
+ """Create from dictionary."""
36
+ return cls(
37
+ name=data["name"],
38
+ purpose=data["purpose"],
39
+ description=data["description"],
40
+ topic=data["topic"],
41
+ invite_users=data.get("invite_users", []),
42
+ service=service or data.get("service"),
43
+ )
44
+
45
+
46
+ def load_channel_config(config_path: str) -> List[ChannelDefinition]:
47
+ """
48
+ Load channel definitions from YAML config file.
49
+
50
+ Args:
51
+ config_path: Path to YAML config file
52
+
53
+ Returns:
54
+ List of channel definitions
55
+
56
+ Example YAML:
57
+ service: business-insights
58
+ default_invite_users:
59
+ - tim
60
+ channels:
61
+ - name: insights-daily
62
+ purpose: Daily operational metrics
63
+ description: Daily business insights for platform operations
64
+ topic: šŸ“Š Daily business metrics
65
+ - name: insights-executive
66
+ purpose: Executive summaries
67
+ description: Weekly executive reports
68
+ topic: šŸŽÆ Weekly executive summaries
69
+ invite_users:
70
+ - tim
71
+ - john
72
+ """
73
+ if not os.path.exists(config_path):
74
+ raise FileNotFoundError(f"Config file not found: {config_path}")
75
+
76
+ with open(config_path, "r") as f:
77
+ config = yaml.safe_load(f)
78
+
79
+ if not config or "channels" not in config:
80
+ raise ValueError("Invalid config file: missing 'channels' section")
81
+
82
+ service = config.get("service")
83
+ default_invite_users = config.get("default_invite_users", [])
84
+
85
+ definitions = []
86
+ for channel_data in config["channels"]:
87
+ # Apply defaults
88
+ if "invite_users" not in channel_data and default_invite_users:
89
+ channel_data["invite_users"] = default_invite_users
90
+
91
+ definition = ChannelDefinition.from_dict(channel_data, service)
92
+ definitions.append(definition)
93
+
94
+ return definitions
95
+
96
+
97
+ def generate_serverless_config(channel_map: Dict[str, str], service_name: str, output_format: str = "yaml") -> str:
98
+ """
99
+ Generate serverless.yml configuration snippet.
100
+
101
+ Args:
102
+ channel_map: Dict mapping channel names to IDs
103
+ service_name: Name of the service
104
+ output_format: 'yaml' or 'env'
105
+
106
+ Returns:
107
+ Configuration snippet
108
+ """
109
+ if output_format == "yaml":
110
+ lines = ["# Add this to your serverless.yml custom section:", "custom:", " slack:", " channels:"]
111
+
112
+ # Map channel names to config keys
113
+ key_mapping = {
114
+ "alerts": "alerts",
115
+ "health": "health",
116
+ "debug": "debug",
117
+ "daily": "daily",
118
+ "weekly": "weekly",
119
+ "executive": "executive",
120
+ "insights": "insights",
121
+ "operations": "operations",
122
+ "activity": "activity",
123
+ }
124
+
125
+ for channel_name, channel_id in channel_map.items():
126
+ # Extract key from channel name
127
+ key = None
128
+ for keyword, config_key in key_mapping.items():
129
+ if keyword in channel_name:
130
+ key = config_key
131
+ break
132
+
133
+ if not key:
134
+ # Default to last part of channel name
135
+ key = channel_name.split("-")[-1]
136
+
137
+ lines.append(f' {key}: "{channel_id}" # {channel_name}')
138
+
139
+ return "\n".join(lines)
140
+
141
+ elif output_format == "env":
142
+ lines = [
143
+ "# Environment variables for your application:",
144
+ ]
145
+
146
+ for channel_name, channel_id in channel_map.items():
147
+ env_key = channel_name.upper().replace("-", "_") + "_CHANNEL"
148
+ lines.append(f"{env_key}={channel_id}")
149
+
150
+ return "\n".join(lines)
151
+
152
+ else:
153
+ raise ValueError(f"Unknown output format: {output_format}")
154
+
155
+
156
+ def validate_channel_names(definitions: List[ChannelDefinition]) -> List[str]:
157
+ """
158
+ Validate channel names according to Slack rules.
159
+
160
+ Args:
161
+ definitions: List of channel definitions
162
+
163
+ Returns:
164
+ List of validation errors (empty if all valid)
165
+ """
166
+ errors = []
167
+
168
+ for definition in definitions:
169
+ name = definition.name
170
+
171
+ # Check length
172
+ if len(name) > 80:
173
+ errors.append(f"{name}: Channel name too long (max 80 chars)")
174
+
175
+ # Check for valid characters
176
+ if not all(c.isalnum() or c in "-_" for c in name):
177
+ errors.append(f"{name}: Invalid characters (use only letters, numbers, hyphens, underscores)")
178
+
179
+ # Check lowercase
180
+ if name != name.lower():
181
+ errors.append(f"{name}: Channel names must be lowercase")
182
+
183
+ # Check doesn't start with special chars
184
+ if name and name[0] in "-_":
185
+ errors.append(f"{name}: Channel name cannot start with hyphen or underscore")
186
+
187
+ return errors
@@ -0,0 +1,211 @@
1
+ """
2
+ Helper functions for Slack setup operations.
3
+ """
4
+
5
+ import os
6
+ import sys
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
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ class SlackSetupHelper:
16
+ """Helper utilities for Slack setup operations."""
17
+
18
+ def __init__(self, token: Optional[str] = None):
19
+ """Initialize with Slack token."""
20
+ self.token = token or os.environ.get("SLACK_BOT_TOKEN")
21
+ if not self.token:
22
+ raise ValueError("No Slack bot token provided")
23
+
24
+ self.client = WebClient(token=self.token)
25
+
26
+ def test_channel_access(self, channel_ids: List[str]) -> Dict[str, bool]:
27
+ """
28
+ Test if bot can post to channels.
29
+
30
+ Args:
31
+ channel_ids: List of channel IDs to test
32
+
33
+ Returns:
34
+ Dict mapping channel IDs to success status
35
+ """
36
+ results = {}
37
+
38
+ for channel_id in channel_ids:
39
+ try:
40
+ self.client.chat_postMessage(channel=channel_id, text="šŸ”§ Testing channel access...")
41
+ results[channel_id] = True
42
+ log.info(f"āœ… Can post to channel {channel_id}")
43
+ except SlackApiError as e:
44
+ results[channel_id] = False
45
+ log.error(f"āŒ Cannot post to channel {channel_id}: {e.response['error']}")
46
+
47
+ return results
48
+
49
+ def get_channel_info(self, channel_id: str) -> Optional[Dict]:
50
+ """Get detailed channel information."""
51
+ try:
52
+ response = self.client.conversations_info(channel=channel_id)
53
+ return response["channel"]
54
+ except SlackApiError as e:
55
+ log.error(f"Error getting channel info: {e}")
56
+ return None
57
+
58
+ def list_bot_channels(self) -> List[Dict]:
59
+ """List all channels the bot is a member of."""
60
+ channels = []
61
+ cursor = None
62
+
63
+ try:
64
+ while True:
65
+ response = self.client.conversations_list(exclude_archived=True, types="public_channel", cursor=cursor)
66
+
67
+ # Filter to only channels bot is member of
68
+ for channel in response["channels"]:
69
+ if channel.get("is_member", False):
70
+ channels.append(
71
+ {
72
+ "id": channel["id"],
73
+ "name": channel["name"],
74
+ "purpose": channel.get("purpose", {}).get("value", ""),
75
+ "topic": channel.get("topic", {}).get("value", ""),
76
+ "num_members": channel.get("num_members", 0),
77
+ }
78
+ )
79
+
80
+ cursor = response.get("response_metadata", {}).get("next_cursor")
81
+ if not cursor:
82
+ break
83
+
84
+ except SlackApiError as e:
85
+ log.error(f"Error listing channels: {e}")
86
+
87
+ return channels
88
+
89
+ def validate_bot_permissions(self) -> Dict[str, bool]:
90
+ """Check if bot has required permissions."""
91
+ required_scopes = ["channels:manage", "channels:join", "chat:write", "channels:read", "users:read"]
92
+
93
+ try:
94
+ response = self.client.auth_test()
95
+ # Note: auth.test doesn't return scopes in newer apps
96
+ # This would need to be checked via the app's OAuth settings
97
+
98
+ return {
99
+ "authenticated": True,
100
+ "bot_id": response.get("user_id"),
101
+ "bot_name": response.get("user"),
102
+ "team": response.get("team"),
103
+ "url": response.get("url"),
104
+ }
105
+
106
+ except SlackApiError as e:
107
+ log.error(f"Auth test failed: {e}")
108
+ return {"authenticated": False, "error": str(e)}
109
+
110
+
111
+ def prompt_for_token() -> str:
112
+ """Interactive prompt for Slack bot token."""
113
+ print("\n" + "=" * 60)
114
+ print("Slack Bot Token Required")
115
+ print("=" * 60)
116
+ print("\nTo get your bot token:")
117
+ print("1. Go to https://api.slack.com/apps")
118
+ print("2. Select your app (or create a new one)")
119
+ print("3. Go to 'OAuth & Permissions'")
120
+ print("4. Copy the 'Bot User OAuth Token' (starts with xoxb-)")
121
+ print("\nRequired OAuth Scopes:")
122
+ print(" - channels:manage (create channels)")
123
+ print(" - channels:join (join channels)")
124
+ print(" - chat:write (send messages)")
125
+ print(" - channels:read (list channels)")
126
+ print(" - users:read (look up users)")
127
+ print(" - channels:write.invites (invite users)")
128
+ print("\n" + "=" * 60)
129
+
130
+ token = input("\nEnter Bot Token (xoxb-...): ").strip()
131
+
132
+ if not token.startswith("xoxb-"):
133
+ print("\nāš ļø Warning: Token doesn't start with 'xoxb-'")
134
+ confirm = input("Continue anyway? (y/n): ")
135
+ if confirm.lower() != "y":
136
+ sys.exit(1)
137
+
138
+ return token
139
+
140
+
141
+ def print_channel_summary(channel_map: Dict[str, str], service_name: str):
142
+ """Print summary of created channels."""
143
+ print("\n" + "=" * 60)
144
+ print(f"Channel Setup Complete for {service_name}")
145
+ print("=" * 60)
146
+
147
+ if not channel_map:
148
+ print("\nāŒ No channels were created")
149
+ return
150
+
151
+ print("\nāœ… Channels ready:")
152
+ for name, channel_id in channel_map.items():
153
+ print(f" #{name:<30} ID: {channel_id}")
154
+
155
+ print("\nšŸ“‹ Next steps:")
156
+ print("1. Use the channel IDs in your application configuration")
157
+ print("2. Deploy your application")
158
+ print("3. Test by sending messages to the channels")
159
+
160
+ print("\nšŸ’” Tips:")
161
+ print("- Bot is already in all channels")
162
+ print("- Invited users have been added to channels")
163
+ print("- Channels are public for transparency")
164
+
165
+
166
+ def confirm_channel_creation(definitions: List["ChannelDefinition"], existing: Dict[str, Optional[str]]) -> bool:
167
+ """
168
+ Interactive confirmation for channel creation.
169
+
170
+ Args:
171
+ definitions: Channel definitions to create
172
+ existing: Map of existing channels
173
+
174
+ Returns:
175
+ True if user confirms, False otherwise
176
+ """
177
+ print("\n" + "=" * 60)
178
+ print("Channel Creation Plan")
179
+ print("=" * 60)
180
+
181
+ to_create = []
182
+ already_exist = []
183
+
184
+ for definition in definitions:
185
+ if existing.get(definition.name):
186
+ already_exist.append(definition)
187
+ else:
188
+ to_create.append(definition)
189
+
190
+ if already_exist:
191
+ print("\nāœ… Existing channels (will configure/join):")
192
+ for definition in already_exist:
193
+ print(f" #{definition.name}")
194
+ print(f" Purpose: {definition.purpose}")
195
+
196
+ if to_create:
197
+ print("\nšŸ†• Channels to create:")
198
+ for definition in to_create:
199
+ print(f" #{definition.name}")
200
+ print(f" Purpose: {definition.purpose}")
201
+ print(f" Topic: {definition.topic}")
202
+ if definition.invite_users:
203
+ print(f" Inviting: {', '.join(definition.invite_users)}")
204
+
205
+ if not to_create and not already_exist:
206
+ print("\nāŒ No channels to process")
207
+ return False
208
+
209
+ print("\n" + "=" * 60)
210
+ response = input("\nProceed with channel setup? (y/n): ")
211
+ return response.lower() == "y"
@@ -0,0 +1,117 @@
1
+ """
2
+ Timezone utilities for multi-timezone handling (NZ primary, AUS/US/EUR secondary).
3
+ """
4
+
5
+ from datetime import datetime, timezone
6
+ import pytz
7
+ from typing import List, Tuple, Optional
8
+
9
+ # Supported timezones
10
+ NZ_TZ = pytz.timezone("Pacific/Auckland") # Primary timezone
11
+ AUS_TZ = pytz.timezone("Australia/Sydney")
12
+ US_EAST_TZ = pytz.timezone("US/Eastern")
13
+ US_WEST_TZ = pytz.timezone("US/Pacific")
14
+ EUR_TZ = pytz.timezone("Europe/London")
15
+
16
+
17
+ def nz_time(utc_dt: Optional[datetime] = None) -> datetime:
18
+ """
19
+ Convert UTC datetime to NZ time or get current NZ time.
20
+
21
+ Args:
22
+ utc_dt: UTC datetime to convert (defaults to now)
23
+
24
+ Returns:
25
+ Datetime in NZ timezone
26
+ """
27
+ if utc_dt is None:
28
+ utc_dt = datetime.now(timezone.utc)
29
+
30
+ if utc_dt.tzinfo is None:
31
+ utc_dt = pytz.UTC.localize(utc_dt)
32
+
33
+ return utc_dt.astimezone(NZ_TZ)
34
+
35
+
36
+ def format_nz_time(dt: Optional[datetime] = None, fmt: str = "%Y-%m-%d %H:%M:%S %Z") -> str:
37
+ """
38
+ Format datetime as NZ time string.
39
+
40
+ Args:
41
+ dt: Datetime to format (defaults to now)
42
+ fmt: strftime format string
43
+
44
+ Returns:
45
+ Formatted time string
46
+ """
47
+ nz_dt = nz_time(dt)
48
+ return nz_dt.strftime(fmt)
49
+
50
+
51
+ def format_multi_timezone(dt: Optional[datetime] = None, primary_tz: str = "nz", secondary_tz: str = "aus") -> str:
52
+ """
53
+ Format datetime with primary and secondary timezone display.
54
+
55
+ Example output: "Fri 26th 10am NZDT (Thu 8pm AEDT)"
56
+
57
+ Args:
58
+ dt: Datetime to format (defaults to now)
59
+ primary_tz: Primary timezone ("nz", "aus", "us_east", "us_west", "eur")
60
+ secondary_tz: Secondary timezone for parenthetical display
61
+
62
+ Returns:
63
+ Formatted multi-timezone string
64
+ """
65
+ if dt is None:
66
+ dt = datetime.now(timezone.utc)
67
+
68
+ if dt.tzinfo is None:
69
+ dt = pytz.UTC.localize(dt)
70
+
71
+ # Timezone mapping
72
+ tz_map = {"nz": NZ_TZ, "aus": AUS_TZ, "us_east": US_EAST_TZ, "us_west": US_WEST_TZ, "eur": EUR_TZ}
73
+
74
+ primary_tzinfo = tz_map.get(primary_tz, NZ_TZ)
75
+ secondary_tzinfo = tz_map.get(secondary_tz, AUS_TZ)
76
+
77
+ # Convert to both timezones
78
+ primary_dt = dt.astimezone(primary_tzinfo)
79
+ secondary_dt = dt.astimezone(secondary_tzinfo)
80
+
81
+ # Format primary timezone with ordinal
82
+ def format_ordinal(day):
83
+ if 10 <= day % 100 <= 20:
84
+ suffix = "th"
85
+ else:
86
+ suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
87
+ return f"{day}{suffix}"
88
+
89
+ # Primary format: "Fri 26th 10am NZDT"
90
+ primary_hour = primary_dt.strftime("%I").lstrip("0") or "12"
91
+ primary_tz_abbr = primary_dt.strftime("%Z")
92
+ primary_formatted = f"{primary_dt.strftime('%a')} {format_ordinal(primary_dt.day)} {primary_hour}{primary_dt.strftime('%p').lower()} {primary_tz_abbr}"
93
+
94
+ # Secondary format: "Thu 8pm AEDT"
95
+ secondary_hour = secondary_dt.strftime("%I").lstrip("0") or "12"
96
+ secondary_tz_abbr = secondary_dt.strftime("%Z")
97
+ secondary_formatted = (
98
+ f"{secondary_dt.strftime('%a')} {secondary_hour}{secondary_dt.strftime('%p').lower()} {secondary_tz_abbr}"
99
+ )
100
+
101
+ return f"{primary_formatted} ({secondary_formatted})"
102
+
103
+
104
+ def get_timezone_options() -> List[Tuple[str, str]]:
105
+ """
106
+ Get available timezone options for configuration.
107
+
108
+ Returns:
109
+ List of (key, description) tuples
110
+ """
111
+ return [
112
+ ("nz", "New Zealand (Pacific/Auckland)"),
113
+ ("aus", "Australia (Australia/Sydney)"),
114
+ ("us_east", "US Eastern (US/Eastern)"),
115
+ ("us_west", "US Pacific (US/Pacific)"),
116
+ ("eur", "Europe (Europe/London)"),
117
+ ]