Coverage for slack_bot / shell_main.py: 0%
119 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 17:44 +0800
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 17:44 +0800
1import os
2import sys
3import click
4from slack_bolt import App
5from slack_bolt.adapter.socket_mode import SocketModeHandler
6from dotenv import load_dotenv
7import re
8import subprocess
10# Add project root to path
11sys.path.append(os.getcwd())
13from health.utils.logging_config import setup_logger
14from health.utils.env_loader import load_env_with_extras
15from slack_bot.dispatcher import MessageDispatcher
16from slack_bot.shell.help_text import SHELL_HELP_TEXT
17from health.utils.time_utils import get_current_time_str
19logger = setup_logger(__name__)
21# Load environment variables
22load_env_with_extras()
24@click.command()
25@click.option("--model", default=None, help="Gemini model to use (overrides .env)")
26def main(model: str):
27 """Butler Slack Bot - Shell Edition."""
29 # Guard: require explicit opt-in to prevent accidental startup
30 if os.environ.get("SHELL_BOT_ENABLED", "").lower() != "true":
31 logger.error("Shell Bot is disabled on this machine. Set SHELL_BOT_ENABLED=true in .env to enable.")
32 sys.exit(1)
34 # Override model in environment only if specified via CLI
35 if model:
36 os.environ["GEMINI_MODEL"] = model
37 logger.info(f"Using model from CLI: {model}")
39 # Get the actual model being used (from CLI or .env)
40 actual_model = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash-exp")
41 logger.info(f"Starting Shell Bot with Gemini model: {actual_model}")
43 # Initialize Slack App with SHELL-specific tokens
44 bot_token = os.environ.get("SHELL_SLACK_BOT_TOKEN")
45 app_token = os.environ.get("SHELL_SLACK_APP_TOKEN")
47 if not bot_token or not app_token:
48 logger.error("Missing SHELL_SLACK_BOT_TOKEN or SHELL_SLACK_APP_TOKEN in .env")
49 sys.exit(1)
51 app = App(token=bot_token)
53 # --- SHELL CONFIGURATION ---
54 MAX_TRACKED_MESSAGES = 1000
56 # NOTE: We can't easily inject dynamic time into the *static* dispatcher init here
57 # if we want it to update per-request without recreating the dispatcher.
58 # However, ShellBot is often single-session.
59 # Better approach: We'll recreate the dispatcher or update the prompt inside handle_message?
60 # Actually, MessageDispatcher init takes system_instruction.
61 # We can just Init it here with STARTUP time, which is better than nothing,
62 # but ideally we want per-request time.
63 # The simplest fix for now: Inject it at module level (restart required for day change anyway).
65 SHELL_SYSTEM_PROMPT = f"""You are a Shell Assistant, an expert in zsh and system administration.
66Current Time: {get_current_time_str()}
68Your Goal: Help the user execute commands and understand their system.
70Capabilities:
71- You have a persistent zsh session available via the `execute_shell` tool.
72- You can query files, check processes, and manage the system.
74CRITICAL ANTI-HALLUCINATION RULES:
751. **TOOL-FIRST POLICY**: When user asks about CURRENT system state (e.g., "what files are on my desktop", "what processes are running", "system load"), you MUST call `execute_shell` FIRST. NEVER answer based on conversation history or memory.
762. **ALWAYS PREFER EXECUTION**: If user asks for a command (e.g., "list files"), EXECUTE IT using `execute_shell("ls -la")`. DO NOT just explain it.
773. **100% OUTPUT FIDELITY**: Report EXACTLY what the terminal returns. If `execute_shell` returns empty string, error, or loading indicator, say "The command returned no output" or "Command failed with error: X". NEVER invent, guess, or assume what the output "should" look like.
784. **EXPLICIT UNCERTAINTY**: If you haven't executed a command in the current turn, DO NOT provide specific file names, process lists, or system state details.
80Guidelines:
815. **Numbered Shortcuts**: Provide numbered lists (1, 2, 3). If user replies with a number, EXECUTE that option immediately.
826. **Be Concise**: Summarize long outputs, but show relevant parts.
837. **Safety**: Warn before destructive commands (rm -rf), but respect user's decision.
84"""
86 # Only include the shell tool
87 from slack_bot.tools.registry import TOOLS_SCHEMA
88 SHELL_TOOLS = [t for t in TOOLS_SCHEMA if t["function"]["name"] == "execute_shell"]
90 # Initialize Dispatcher with Shell Identity
91 dispatcher = MessageDispatcher(
92 bot_token=bot_token,
93 system_instruction=SHELL_SYSTEM_PROMPT,
94 tools=SHELL_TOOLS
95 )
97 # Message deduplication: Track processed message IDs
98 processed_messages = set()
99 MAX_TRACKED_MESSAGES = 1000
101 # Message Handler
102 @app.event("message")
103 def handle_message(message, say):
104 try:
105 # Deduplicate using client_msg_id or event_ts
106 msg_id = message.get("client_msg_id") or message.get("ts")
108 if msg_id in processed_messages:
109 logger.debug(f"Skipping duplicate message: {msg_id}")
110 return
112 processed_messages.add(msg_id)
113 if len(processed_messages) > MAX_TRACKED_MESSAGES:
114 to_remove = list(processed_messages)[:MAX_TRACKED_MESSAGES // 2]
115 for old_id in to_remove:
116 processed_messages.discard(old_id)
118 text = message.get("text", "")
119 channel = message["channel"]
120 user = message["user"]
121 files = message.get("files", [])
123 # --- HANDLE HELP COMMAND ---
124 if text.strip().lower() in ["help", "help?", "帮助", "menu"]:
125 say(SHELL_HELP_TEXT)
126 return
128 # Generate Request ID
129 import uuid
130 import time
131 request_id = str(uuid.uuid4())[:8]
133 logger.info(f"[{request_id}] 📥 ShellBot received: {text}")
135 # --- FAILSAFE: CONFIG SWITCHING ---
136 gmode_match = re.match(r"^### gmode (.*?) ###$", text.strip())
137 if gmode_match:
138 target_config = gmode_match.group(1)
139 logger.warning(f"[{request_id}] 🚨 Received FAILSAFE cmd: {target_config}")
141 try:
142 # Execute switch script
143 script_path = os.path.join(os.getcwd(), "scripts", "select_gemini.py")
144 result = subprocess.run(
145 [sys.executable, script_path, target_config],
146 capture_output=True,
147 text=True
148 )
150 if result.returncode == 0:
151 # Special handling for 'check' and 'check-detailed' commands - show output, don't restart
152 if target_config in ["check", "check-detailed"]:
153 output = result.stdout + result.stderr
154 say(f"🔍 **Health Check Results**:\n```\n{output}\n```")
155 return
157 say(f"♻️ **System Reset**: Switched to `{target_config}`. Restarting process...")
158 # Allow message to be sent
159 time.sleep(1)
161 # Self-Restart
162 logger.warning("RESTARTING PROCESS via os.execv...")
163 os.execv(sys.executable, [sys.executable] + sys.argv)
164 else:
165 say(f"❌ Command Failed:\n```\n{result.stderr}\n```")
166 return
168 except Exception as e:
169 logger.error(f"Failsafe Error: {e}")
170 say(f"❌ Failsafe Error: {e}")
171 return
172 # ----------------------------------
174 # --- FAILSAFE: BOT COMMANDS ---
175 bot_match = re.match(r"^### bot (.*?) ###$", text.strip())
176 if bot_match:
177 bot_args = bot_match.group(1).split()
178 logger.warning(f"[{request_id}] 🚨 Received FAILSAFE cmd: bot {' '.join(bot_args)}")
180 try:
181 # Execute bot_manager.py
182 script_path = os.path.join(os.getcwd(), "scripts", "bot_manager.py")
183 result = subprocess.run(
184 [sys.executable, script_path] + bot_args,
185 capture_output=True,
186 text=True
187 )
189 output = result.stdout + "\n" + result.stderr
190 if result.returncode == 0:
191 say(f"🤖 **Bot Manager Output**:\n```\n{output.strip()}\n```")
192 else:
193 say(f"❌ Command Failed:\n```\n{output.strip()}\n```")
194 return
196 except Exception as e:
197 logger.error(f"Failsafe Error: {e}")
198 say(f"❌ Failsafe Error: {e}")
199 return
200 # ----------------------------------
202 # Send temporary "Processing..." message
203 ack_response = say(f"⏳ ShellBot is thinking...")
204 ack_ts = ack_response["ts"]
206 # Dispatch
207 dispatcher.dispatch(text, channel, user, response_ts=ack_ts, request_id=request_id, files=files)
209 except Exception as e:
210 logger.error(f"Error handling message: {e}", exc_info=True)
211 try:
212 say(f"🐛 ShellBot Error: {e}")
213 except Exception:
214 pass
216 # Start Socket Mode
217 try:
218 handler = SocketModeHandler(app, app_token)
219 logger.info("⚡️ Shell Bot is connected to Slack!")
220 handler.start()
221 except Exception as e:
222 logger.error(f"Failed to start SocketModeHandler: {e}")
223 sys.exit(1)
225if __name__ == "__main__":
226 main()