1+ #!/usr/bin/env python3
2+
3+ """
4+ Shared utilities for Discord integration commands
5+ Provides common functions for JSON handling, path detection, validation, and formatting
6+ """
7+
8+ import json
9+ import os
10+ import sys
11+ import re
12+ from pathlib import Path
13+ from typing import Dict , Any , Optional , List , Tuple
14+
15+ class DiscordUtils :
16+ """Utility class for Discord integration operations"""
17+
18+ # Color codes for output formatting
19+ COLORS = {
20+ 'SUCCESS' : '✅' ,
21+ 'ERROR' : '❌' ,
22+ 'INFO' : 'ℹ️' ,
23+ 'WARNING' : '⚠️' ,
24+ 'ACTIVE' : '🟢' ,
25+ 'INACTIVE' : '🔴' ,
26+ 'LOCAL' : '📍' ,
27+ 'GLOBAL' : '🌐' ,
28+ 'THREAD' : '🧵' ,
29+ 'CHANNEL' : '📢' ,
30+ 'AUTH' : '🔐' ,
31+ 'SETTINGS' : '🔧' ,
32+ 'HOOKS' : '🪝'
33+ }
34+
35+ @staticmethod
36+ def get_installation_type () -> Tuple [str , str ]:
37+ """
38+ Determine installation type and return paths
39+ Returns: (installation_type, commands_base, hooks_base)
40+ """
41+ local_commands = Path (".claude/commands/discord" )
42+ local_hooks = Path (".claude/hooks" )
43+
44+ if local_commands .exists () and local_hooks .exists ():
45+ return "local" , ".claude/commands/discord" , ".claude/hooks"
46+ else :
47+ home_commands = Path .home () / ".claude/commands/discord"
48+ home_hooks = Path .home () / ".claude/hooks"
49+ return "global" , str (home_commands ), str (home_hooks )
50+
51+ @staticmethod
52+ def load_state (state_file : str = ".claude/discord-state.json" ) -> Dict [str , Any ]:
53+ """Load Discord state from JSON file"""
54+ try :
55+ with open (state_file , 'r' , encoding = 'utf-8' ) as f :
56+ return json .load (f )
57+ except (FileNotFoundError , json .JSONDecodeError ):
58+ return {}
59+
60+ @staticmethod
61+ def save_state (state : Dict [str , Any ], state_file : str = ".claude/discord-state.json" ) -> bool :
62+ """Save Discord state to JSON file"""
63+ try :
64+ # Ensure directory exists
65+ os .makedirs (os .path .dirname (state_file ), exist_ok = True )
66+
67+ with open (state_file , 'w' , encoding = 'utf-8' ) as f :
68+ json .dump (state , f , indent = 2 )
69+ return True
70+ except Exception as e :
71+ print (f"{ DiscordUtils .COLORS ['ERROR' ]} Failed to save state: { e } " )
72+ return False
73+
74+ @staticmethod
75+ def validate_webhook_url (url : str ) -> bool :
76+ """Validate Discord webhook URL format"""
77+ if not url :
78+ return False
79+
80+ pattern = r'^https://discord\.com/api/webhooks/\d+/[a-zA-Z0-9_-]+$'
81+ return bool (re .match (pattern , url ))
82+
83+ @staticmethod
84+ def mask_webhook_url (url : str ) -> str :
85+ """Mask webhook URL for display"""
86+ if not url :
87+ return "Not configured"
88+
89+ # Extract webhook ID and mask the token
90+ match = re .match (r'^(https://discord\.com/api/webhooks/\d+)/' , url )
91+ if match :
92+ return f"{ match .group (1 )} /..."
93+ return "Invalid URL"
94+
95+ @staticmethod
96+ def get_project_name () -> str :
97+ """Get current project name (directory name)"""
98+ return os .path .basename (os .getcwd ())
99+
100+ @staticmethod
101+ def parse_arguments (args_string : str ) -> List [str ]:
102+ """Parse space-separated arguments string"""
103+ if not args_string or not args_string .strip ():
104+ return []
105+ return args_string .strip ().split ()
106+
107+ @staticmethod
108+ def print_header (title : str , char : str = "=" ) -> None :
109+ """Print formatted header"""
110+ print (f"{ DiscordUtils .COLORS ['SETTINGS' ]} { title } " )
111+ print (char * (len (title ) + 2 ))
112+
113+ @staticmethod
114+ def print_status_line (label : str , value : str , emoji : str = "" ) -> None :
115+ """Print formatted status line"""
116+ if emoji :
117+ print (f"{ label } : { emoji } { value } " )
118+ else :
119+ print (f"{ label } : { value } " )
120+
121+ @staticmethod
122+ def print_success (message : str ) -> None :
123+ """Print success message"""
124+ print (f"{ DiscordUtils .COLORS ['SUCCESS' ]} { message } " )
125+
126+ @staticmethod
127+ def print_error (message : str ) -> None :
128+ """Print error message"""
129+ print (f"{ DiscordUtils .COLORS ['ERROR' ]} { message } " )
130+
131+ @staticmethod
132+ def print_info (message : str ) -> None :
133+ """Print info message"""
134+ print (f"{ DiscordUtils .COLORS ['INFO' ]} { message } " )
135+
136+ @staticmethod
137+ def print_warning (message : str ) -> None :
138+ """Print warning message"""
139+ print (f"{ DiscordUtils .COLORS ['WARNING' ]} { message } " )
140+
141+ @staticmethod
142+ def create_state_config (webhook_url : str , project_name : str ,
143+ auth_token : Optional [str ] = None ,
144+ thread_id : Optional [str ] = None ) -> Dict [str , Any ]:
145+ """Create initial state configuration"""
146+ config = {
147+ "active" : False ,
148+ "webhook_url" : webhook_url ,
149+ "project_name" : project_name
150+ }
151+
152+ if auth_token :
153+ config ["auth_token" ] = auth_token
154+
155+ if thread_id :
156+ config ["thread_id" ] = thread_id
157+
158+ return config
159+
160+ @staticmethod
161+ def merge_hooks_config (settings_file : str = ".claude/settings.json" ) -> bool :
162+ """Merge Discord hooks into settings.json"""
163+ try :
164+ # Get installation paths
165+ install_type , commands_base , hooks_base = DiscordUtils .get_installation_type ()
166+
167+ # Discord hooks configuration
168+ discord_hooks = {
169+ "Stop" : [
170+ {
171+ "matcher" : "" ,
172+ "hooks" : [
173+ {
174+ "type" : "command" ,
175+ "command" : f"{ hooks_base } /stop-discord.py"
176+ }
177+ ]
178+ }
179+ ],
180+ "Notification" : [
181+ {
182+ "matcher" : "" ,
183+ "hooks" : [
184+ {
185+ "type" : "command" ,
186+ "command" : f"{ hooks_base } /notification-discord.py"
187+ }
188+ ]
189+ }
190+ ],
191+ "PostToolUse" : [
192+ {
193+ "matcher" : "" ,
194+ "hooks" : [
195+ {
196+ "type" : "command" ,
197+ "command" : f"{ hooks_base } /posttooluse-discord.py"
198+ }
199+ ]
200+ }
201+ ]
202+ }
203+
204+ # Load existing settings
205+ try :
206+ with open (settings_file , 'r' , encoding = 'utf-8' ) as f :
207+ settings = json .load (f )
208+ except (FileNotFoundError , json .JSONDecodeError ):
209+ settings = {}
210+
211+ # Ensure hooks section exists
212+ if 'hooks' not in settings :
213+ settings ['hooks' ] = {}
214+
215+ # Merge Discord hooks
216+ settings ['hooks' ].update (discord_hooks )
217+
218+ # Save back to file
219+ with open (settings_file , 'w' , encoding = 'utf-8' ) as f :
220+ json .dump (settings , f , indent = 2 )
221+
222+ return True
223+
224+ except Exception as e :
225+ DiscordUtils .print_error (f"Failed to merge hooks: { e } " )
226+ return False
227+
228+ @staticmethod
229+ def backup_settings (settings_file : str = ".claude/settings.json" ) -> bool :
230+ """Create backup of settings file"""
231+ try :
232+ if os .path .exists (settings_file ):
233+ import shutil
234+ backup_file = f"{ settings_file } .backup"
235+ shutil .copy2 (settings_file , backup_file )
236+ return True
237+ return False
238+ except Exception :
239+ return False
240+
241+ @staticmethod
242+ def check_state_exists (state_file : str = ".claude/discord-state.json" ) -> bool :
243+ """Check if Discord state file exists"""
244+ return os .path .exists (state_file )
245+
246+ @staticmethod
247+ def get_available_commands () -> List [str ]:
248+ """Get list of available Discord commands"""
249+ return [
250+ "/user:discord:setup WEBHOOK_URL [AUTH_TOKEN] [THREAD_ID] - Setup Discord integration" ,
251+ "/user:discord:start [THREAD_ID] - Enable notifications" ,
252+ "/user:discord:stop - Disable notifications" ,
253+ "/user:discord:status - Check current status" ,
254+ "/user:discord:remove - Remove integration"
255+ ]
256+
257+ @staticmethod
258+ def exit_with_error (message : str , code : int = 1 ) -> None :
259+ """Print error and exit"""
260+ DiscordUtils .print_error (message )
261+ sys .exit (code )
262+
263+ @staticmethod
264+ def exit_with_success (message : str ) -> None :
265+ """Print success and exit"""
266+ DiscordUtils .print_success (message )
267+ sys .exit (0 )
0 commit comments