     1→from typing import Optional, List, Dict, Any
     2→from slack_sdk import WebClient
     3→import os
     4→
     5→from health.utils.logging_config import setup_logger
     6→from slack_bot.llm.gemini import GeminiLLM
     7→from slack_bot.context.storage import ContextStorage
     8→from slack_bot.tools.registry import TOOLS_SCHEMA, TOOL_FUNCTIONS
     9→from slack_bot.tools.groups import get_tool_preset
    10→
    11→logger = setup_logger(__name__)
    12→
    13→
    14→class MessageDispatcher:
    15→    """Routes incoming Slack messages to Gemini and executes tools."""
    16→
    17→    def __init__(self, bot_token: Optional[str] = None, system_instruction: Optional[str] = None, tools: Optional[List[Dict]] = None, tool_mode: str = "light"):
    18→        """
    19→        Initialize dispatcher.
    20→        Args:
    21→            bot_token: Optional specific Slack token
    22→            system_instruction: Custom system prompt (e.g. for Shell Bot)
    23→            tools: specific tools list to use (if None, uses preset based on tool_mode)
    24→            tool_mode: 'none' (no tools), 'light' (5 tools), 'standard' (10 tools), 'full' (15 tools), 'all' (21 tools)
    25→        """
    26→        self.llm = GeminiLLM(system_instruction=system_instruction)
    27→
    28→        # Use tool preset if tools not explicitly provided
    29→        if tools is not None:
    30→            self.tools = tools
    31→            self.tool_functions = TOOL_FUNCTIONS
    32→        elif tool_mode == "none":
    33→            self.tools = None
    34→            self.tool_functions = {}
    35→            logger.info("Running in NO-TOOLS mode (pure text)")
    36→        else:
    37→            self.tools, self.tool_functions = get_tool_preset(tool_mode)
    38→            logger.info(f"Using tool preset: {tool_mode} ({len(self.tools)} tools)")
    39→
    40→        # Initialize Slack WebClient for posting replies
    41→        self.bot_token = bot_token or os.environ.get("SLACK_BOT_TOKEN")
    42→        self.client = WebClient(token=self.bot_token)
    43→
    44→        logger.info("Dispatcher initialized with Gemini")
    45→
    46→    def dispatch(self, message_text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None, request_id: str = "N/A", files: Optional[List[Dict]] = None) -> None:
    47→        """
    48→        Process a message and trigger responses.
    49→        """
    50→        import time
    51→        t0 = time.time()
    52→
    53→        prefix = f"[{request_id}]"
    54→        logger.info(f"{prefix} Processing message in {channel_id} from {user_id}")
    55→        if files:
    56→            logger.info(f"{prefix} Message contains {len(files)} files")
    57→
    58→        # 1. Initialize Context
    59→        storage = ContextStorage(channel_id)
    60→
    61→        # Handle "clear" command
    62→        if message_text.strip().lower() in ["clear", "reset", "清除"]:
    63→            storage.clear()
    64→            self.client.chat_postMessage(channel=channel_id, text="🧹 Context cleared. (对话历史已清除)")
    65→            return
    66→
    67→        storage.add_message("user", message_text)
    68→
    69→        # Download images if any
    70→        image_data_list = []
    71→        download_failed = False
    72→        if files:
    73→            image_data_list = self._download_files(files, prefix)
    74→            # Anti-Hallucination: Check if download failed
    75→            if len(image_data_list) == 0:
    76→                failure_msg = "\n[SYSTEM WARNING: User uploaded an image but it FAILED to download/process. You CANNOT see the image. DO NOT GUESS. Inform the user that the image access failed.]"
    77→                logger.warning(f"{prefix} Image download failed! Appending warning to LLM.")
    78→                message_text += failure_msg
    79→                download_failed = True
    80→
    81→        try:
    82→            # 2. Get conversation context
    83→            context = storage.get_context()
    84→            logger.debug(f"{prefix} Context size: {len(context)}")
    85→
    86→            # 3. Process with Gemini (with tools)
    87→            # CRITICAL: If image download failed, disable tools to prevent "400 INVALID_ARGUMENT"
    88→            # (which happens when mixing text-only warning prompts with complex tool schemas in some proxies)
    89→            #
    90→            # NOTE: gemini-3-flash has a bug where it returns empty response with tools,
    91→            # but we use SAFETY OVERRIDE below to manually trigger tools based on keywords
    92→            current_tools = None if download_failed else self.tools
    93→
    94→            logger.info(f"{prefix} Calling Gemini for initial response...")
    95→            logger.info(f"{prefix} Request config: model={self.llm.get_model_name()}, tools={len(current_tools) if current_tools else 0}, context={len(context)-1} msgs")
    96→            t_llm_start = time.time()
    97→            response_text, tool_calls = self.llm.generate_response(
    98→                message=message_text,
    99→                context=context[:-1],
   100→                tools=current_tools,
   101→                images=image_data_list
   102→            )
   103→            # Defensive check for None/empty response
   104→            if response_text is None:
   105→                logger.error(f"{prefix} Gemini returned None for response_text!")
   106→                response_text = ""
   107→            elif not response_text.strip():
   108→                logger.warning(f"{prefix} Gemini returned empty response_text! (This usually means the proxy has issues)")
   109→
   110→            # Initialize tool_calls
   111→            if tool_calls is None:
   112→                tool_calls = []
   113→
   114→            # Log what we got back
   115→            logger.info(f"{prefix} Gemini response: text={len(response_text)} chars, tool_calls={len(tool_calls) if tool_calls else 0}")
   116→
   117→            # --- SAFETY OVERRIDE START ---
   118→            # If the LLM failed to trigger tools (common with local proxies + tools bug),
   119→            # manually detect intent and force tool calls based on keywords.
   120→            # This is a FALLBACK mechanism when Gemini returns empty response or MALFORMED_FUNCTION_CALL.
   121→            if not tool_calls:
   122→                logger.info(f"{prefix} No tool calls from Gemini - checking SAFETY OVERRIDE keywords...")
   123→                lower_msg = message_text.lower()
   124→
   125→                # 1. Sync Garmin data
   126→                if any(k in lower_msg for k in ["sync", "update", "fetch", "同步", "拉取", "更新"]) and \
   127→                   any(k in lower_msg for k in ["garmin", "佳明", "data", "数据"]):
   128→                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing sync_garmin tool")
   129→                    from datetime import datetime, timedelta
   130→                    from health.utils.time_utils import get_cst_today
   131→                    # Try to infer sync target date from user message keywords
   132→                    if any(k in lower_msg for k in ["昨天", "昨晚", "昨日", "yesterday", "last night"]):
   133→                        _sync_date = (get_cst_today() - timedelta(days=1)).isoformat()
   134→                    elif any(k in lower_msg for k in ["前天", "day before yesterday"]):
   135→                        _sync_date = (get_cst_today() - timedelta(days=2)).isoformat()
   136→                    else:
   137→                        _sync_date = get_cst_today().isoformat()  # today in CST
   138→                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: sync target_date={_sync_date}")
   139→                    tool_calls.append({
   140→                        "name": "sync_garmin",
   141→                        "args": {"target_date": _sync_date}
   142→                    })
   143→
   144→                # 2. Query health data (sleep, steps, activities, etc.)
   145→                elif any(k in lower_msg for k in ["睡眠", "sleep", "步数", "steps", "心率", "heart", "hrv", "运动", "activity", "workout", "锻炼", "昨天", "yesterday", "今天", "today", "上午", "下午", "morning", "afternoon", "查询", "query", "变化", "趋势", "trend"]):
   146→                    from datetime import timedelta
   147→                    from health.utils.time_utils import get_cst_now, get_cst_today
   148→
   149→                    # CRITICAL: First check for single-day keywords to avoid false positives
   150→                    has_single_day_keyword = any(k in lower_msg for k in [
   151→                        "昨天", "昨晚", "昨日", "last night", "yesterday",
   152→                        "今天", "今晚", "今日", "today", "tonight",
   153→                        "前天", "前晚", "day before yesterday",
   154→                        "大前天", "three days ago"
   155→                    ])
   156→
   157→                    # Check if asking for time-range trends (past X days/weeks/months)
   158→                    is_trend_query = (not has_single_day_keyword) and any(k in lower_msg for k in [
   159→                        "过去", "最近", "近", "past", "recent",
   160→                        "变化", "趋势", "trend", "change", "历史", "history",
   161→                        "个月", "month", "周", "week", "年", "year",
   162→                    ])
   163→
   164→                    if is_trend_query:
   165→                        # Parse time range from query
   166→                        import re
   167→                        today_cst = get_cst_today()
   168→                        end_date = today_cst.strftime("%Y-%m-%d")
   169→                        days_ago = 30  # default
   170→
   171→                        if re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg):
   172→                            months = int(re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg).group(1) or re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg).group(2))
   173→                            days_ago = months * 30
   174→                        elif re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg):
   175→                            weeks = int(re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg).group(1) or re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg).group(2))
   176→                            days_ago = weeks * 7
   177→                        elif re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg):
   178→                            days_ago = int(re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg).group(1) or re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg).group(2))
   179→                        elif re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg):
   180→                            years = int(re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg).group(1) or re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg).group(2))
   181→                            days_ago = years * 365
   182→                        elif "半年" in lower_msg or "six month" in lower_msg:
   183→                            days_ago = 180
   184→                        elif "三个月" in lower_msg or "three month" in lower_msg:
   185→                            days_ago = 90
   186→
   187→                        from datetime import timedelta
   188→                        start_date = (today_cst - timedelta(days=days_ago)).strftime("%Y-%m-%d")
   189→
   190→                        # Determine metric type
   191→                        metric_type = "rhr"  # default
   192→                        if any(k in lower_msg for k in ["hrv", "心率变异"]):
   193→                            metric_type = "hrv"
   194→                        elif any(k in lower_msg for k in ["睡眠", "sleep"]):
   195→                            metric_type = "sleep"
   196→                        elif any(k in lower_msg for k in ["步数", "steps"]):
   197→                            metric_type = "steps"
   198→                        elif any(k in lower_msg for k in ["压力", "stress"]):
   199→                            metric_type = "stress"
   200→
   201→                        logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_metric_history for {metric_type} ({start_date} to {end_date})")
   202→                        tool_calls.append({
   203→                            "name": "get_metric_history",
   204→                            "args": {
   205→                                "metric_type": metric_type,
   206→                                "start_date": start_date,
   207→                                "end_date": end_date
   208→                            }
   209→                        })
   210→                    else:
   211→                        # Single-day query — all dates computed in CST (UTC+8)
   212→                        today_cst = get_cst_today()
   213→                        if any(k in lower_msg for k in ["昨天", "昨晚", "昨日", "last night", "yesterday"]):
   214→                            target = (today_cst - timedelta(days=1)).strftime("%Y-%m-%d")
   215→                        elif any(k in lower_msg for k in ["前天", "前晚", "day before yesterday"]):
   216→                            target = (today_cst - timedelta(days=2)).strftime("%Y-%m-%d")
   217→                        else:
   218→                            target = today_cst.strftime("%Y-%m-%d")
   219→
   220→                        # Check if specifically asking for activity history
   221→                        if any(k in lower_msg for k in ["运动", "activity", "workout", "锻炼"]):
   222→                            logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_activity_history for {target}")
   223→                            tool_calls.append({
   224→                                "name": "get_activity_history",
   225→                                "args": {"start_date": target, "end_date": target}
   226→                            })
   227→                        else:
   228→                            logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_daily_detailed_stats for {target}")
   229→                            tool_calls.append({
   230→                                "name": "get_daily_detailed_stats",
   231→                                "args": {"target_date": target}
   232→                            })
   233→
   234→                # 3. Web search
   235→                elif any(k in lower_msg for k in ["搜索", "search", "查一下", "look up", "最新", "latest"]):
   236→                    query = message_text
   237→                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing search_web")
   238→                    tool_calls.append({
   239→                        "name": "search_web",
   240→                        "args": {"query": query}
   241→                    })
   242→
   243→                # 4. Detect diet logging intent (for logging, not forcing tool call yet)
   244→                food_items = ["鸡", "肉", "菜", "蛋", "饭", "面", "粉", "汤", "鱼", "虾", "蟹", "牛", "猪", "羊", "豆", "奶"]
   245→                meal_indicators = ["晚餐", "午餐", "早餐", "夜宵", "零食", "breakfast", "lunch", "dinner", "snack"]
   246→                has_ate_pattern = any(k in lower_msg for k in ["吃了", "ate", "吃的", "eating"])
   247→                has_meal_indicator = any(k in lower_msg for k in meal_indicators)
   248→                has_food_item = any(k in lower_msg for k in food_items)
   249→
   250→                if (has_ate_pattern and has_food_item) or has_meal_indicator:
   251→                    logger.warning(f"{prefix} Detected diet logging intent: ate={has_ate_pattern}, meal={has_meal_indicator}, food={has_food_item}")
   252→                    # Don't force tool - let user confirm first or LLM will handle in second round
   253→                else:
   254→                    logger.info(f"{prefix} No SAFETY OVERRIDE match found - will return empty response or text")
   255→            else:
   256→                logger.info(f"{prefix} Tool calls successfully generated by Gemini (no SAFETY OVERRIDE needed)")
   257→            # --- SAFETY OVERRIDE END ---
   258→
   259→            tool_count = len(tool_calls) if tool_calls else 0
   260→            logger.info(f"{prefix} Gemini response in {time.time()-t_llm_start:.2f}s: {len(response_text)} chars, {tool_count} tools")
   261→
   262→            # 4. Execute tools if requested
   263→            tool_results = []
   264→            if tool_calls:
   265→                logger.info(f"{prefix} Executing {len(tool_calls)} tools")
   266→
   267→                for tool_call in tool_calls:
   268→                    tool_name = tool_call["name"]
   269→                    tool_args = tool_call["args"]
   270→                    logger.info(f"{prefix} Tool: {tool_name} Args: {tool_args}")
   271→
   272→                    t_tool_start = time.time()
   273→                    if tool_name in self.tool_functions:
   274→                        try:
   275→                            # Inject channel_id for shell tool
   276→                            if tool_name == "execute_shell":
   277→                                tool_args["channel_id"] = channel_id
   278→
   279→                            result = self.tool_functions[tool_name](**tool_args)
   280→
   281→                            # Safety Truncation: Prevent massive JSONs from blowing up context
   282→                            str_result = str(result)
   283→                            if len(str_result) > 8000:
   284→                                str_result = str_result[:8000] + "... (truncated)"
   285→
   286→                            tool_results.append({
   287→                                "tool": tool_name,
   288→                                "args": tool_args,
   289→                                "result": str_result
   290→                            })
   291→                            logger.info(f"{prefix} ✓ {tool_name} finished in {time.time()-t_tool_start:.2f}s")
   292→                        except Exception as e:
   293→                            error_msg = f"Error executing {tool_name}: {str(e)}"
   294→                            logger.error(f"{prefix} {error_msg}")
   295→                            tool_results.append({
   296→                                "tool": tool_name,
   297→                                "args": tool_args,
   298→                                "result": f"❌ {error_msg}"
   299→                            })
   300→                    else:
   301→                        logger.warning(f"{prefix} Unknown tool: {tool_name}")
   302→
   303→                # 4b. Second round: Get final response with tool results
   304→                logger.info(f"{prefix} Requesting final analysis...")
   305→
   306→                tool_names = ", ".join([tr['tool'] for tr in tool_results])
   307→
   308→                # Format specific tool results for context
   309→                tool_results_text = "\n".join([
   310→                    f"Tool '{tr['tool']}' (Args: {tr['args']}) returned:\n{tr['result']}"
   311→                    for tr in tool_results
   312→                ])
   313→
   314→                # Inject thoughts into memory
   315→                storage.add_message("assistant", f"I checked: {tool_names}", model="gemini")
   316→
   317→                # Stronger Prompt for Analysis
   318→                analysis_prompt = (
   319→                    f"Here are the execution results from the tools:\n{tool_results_text}\n\n"
   320→                    f"CRITICAL INSTRUCTION: You represent the 'Butler' bot. You must now answer the user's original question based ONLY on the above results.\n"
   321→                    f"1. Summarize the key data points found (e.g., steps, sleep score).\n"
   322→                    f"2. Provide direct, natural language insights.\n"
   323→                    f"3. If the tool indicated an error, explain it to the user.\n\n"
   324→                    f"Do NOT output raw JSON. Write a helpful paragraph."
   325→                )
   326→
   327→                # Do NOT add this massive data blob to persistent storage.
   328→                # Just use it for this generation context.
   329→                final_context = storage.get_context()
   330→
   331→                t_llm_2_start = time.time()
   332→                response_text, _ = self.llm.generate_response(
   333→                    message=analysis_prompt,
   334→                    context=final_context,
   335→                    tools=None
   336→                )
   337→                logger.info(f"{prefix} Final analysis received in {time.time()-t_llm_2_start:.2f}s")
   338→
   339→                # Fallback
   340→                if not response_text or not response_text.strip():
   341→                    logger.warning(f"{prefix} Empty analysis received!")
   342→                    if files:
   343→                        response_text = "⚠ 图片已接收，但 AI 模型未能返回描述。这可能是由于网络波动或模型暂时无法识别该图像，请稍后重试。"
   344→                    else:
   345→                        response_text = "✅ 数据已同步，但我似乎暂时无法生成文字分析。请参考上方的工具执行结果。"
   346→
   347→            # 5. Format and send response
   348→            # Relaxing internal limit to allow for multi-message splitting (max 3 messages of ~4000 chars)
   349→            APP_LIMIT = 12000
   350→            full_response = self._format_response(response_text, tool_results, max_length=APP_LIMIT)
   351→
   352→            # Split into chunks
   353→            chunks = self._split_text(full_response, max_chunk_size=3800)
   354→
   355→            # Limit to 3 chunks as requested
   356→            if len(chunks) > 3:
   357→                chunks = chunks[:3]
   358→                chunks[-1] += "\n...(remaining content truncated)"
   359→
   360→            # Post to Slack
   361→            if response_ts:
   362→                # Update the first message (Thinking...)
   363→                try:
   364→                    self.client.chat_update(channel=channel_id, ts=response_ts, text=chunks[0])
   365→                    logger.info(f"{prefix} 📤 Message updated {response_ts}")
   366→                except Exception as e:
   367→                    logger.warning(f"{prefix} Update failed, posting new: {e}")
   368→                    self.client.chat_postMessage(channel=channel_id, text=chunks[0])
   369→
   370→                # Post subsequent chunks as new messages
   371→                for chunk in chunks[1:]:
   372→                    # Small delay to ensure order
   373→                    time.sleep(0.5)
   374→                    self.client.chat_postMessage(channel=channel_id, text=chunk)
   375→                    logger.info(f"{prefix} 📤 Continuation message posted")
   376→
   377→            else:
   378→                # Post all as new messages
   379→                for i, chunk in enumerate(chunks):
   380→                    if i > 0: time.sleep(0.5)
   381→                    self.client.chat_postMessage(channel=channel_id, text=chunk)
   382→                logger.info(f"{prefix} 📤 {len(chunks)} message(s) posted")
   383→
   384→
   385→            # 6. Save to Context
   386→            storage.add_message("assistant", full_response, model="gemini")
   387→            logger.info(f"{prefix} Dispatch completed in {time.time()-t0:.2f}s")
   388→
   389→        except Exception as e:
   390→            logger.error(f"{prefix} Dispatch failed: {e}", exc_info=True)
   391→            try:
   392→                self.client.chat_postMessage(
   393→                    channel=channel_id,
   394→                    text=f"⚠ Internal Error: {str(e)}"
   395→                )
   396→                logger.info("Error message posted to Slack")
   397→            except Exception as slack_error:
   398→                logger.error(f"Failed to post error message to Slack: {slack_error}", exc_info=True)
   399→
   400→    def _split_text(self, text: str, max_chunk_size: int = 3800) -> List[str]:
   401→        """Split text into chunks avoiding breaking code blocks if possible."""
   402→        if len(text) <= max_chunk_size:
   403→            return [text]
   404→
   405→        chunks = []
   406→        current_chunk = ""
   407→        lines = text.split('\n')
   408→
   409→        in_code_block = False
   410→
   411→        for line in lines:
   412→            # Check for code block toggle
   413→            if line.strip().startswith('```'):
   414→                in_code_block = not in_code_block
   415→
   416→            # If adding this line exceeds chunk size
   417→            if len(current_chunk) + len(line) + 1 > max_chunk_size:
   418→                # Close code block if open
   419→                if in_code_block:
   420→                    current_chunk += "\n```"
   421→
   422→                chunks.append(current_chunk)
   423→
   424→                # Start new chunk
   425→                current_chunk = ""
   426→                # Re-open code block if it was open
   427→                if in_code_block:
   428→                    current_chunk += "```\n" + "(...continued)\n"
   429→
   430→            if current_chunk:
   431→                current_chunk += "\n"
   432→            current_chunk += line
   433→
   434→        if current_chunk:
   435→            chunks.append(current_chunk)
   436→
   437→        return chunks
   438→
   439→    def _format_response(self, text: str, tool_results: list, max_length: int = 39900) -> str:
   440→        """
   441→        Format the response with tool execution results.
   442→
   443→        Args:
   444→            text: The main response text
   445→            tool_results: List of tool execution results
   446→            max_length: Maximum allowed message length (default for new messages)
   447→
   448→        Returns:
   449→            Formatted response string, guaranteed to be under max_length
   450→        """
   451→        parts = []
   452→
   453→        # Add tool execution results first (with smart truncation)
   454→        if tool_results:
   455→            parts.append("🛠 *Tool Executions:*")
   456→            for tr in tool_results:
   457→                # Format args nicely
   458→                args_str = ", ".join(f"{k}={v}" for k, v in tr['args'].items())
   459→
   460→                # Truncate individual tool result if needed (more aggressive for multiple tools)
   461→                result_str = str(tr['result'])
   462→                # Allow larger tool outputs since we have multi-message support
   463→                max_per_tool = 5000
   464→                if len(result_str) > max_per_tool:
   465→                    result_str = result_str[:max_per_tool] + "... (truncated)"
   466→
   467→                parts.append(f"• `{tr['tool']}({args_str})`: {result_str}")
   468→            parts.append("")  # Blank line
   469→
   470→        # Add text response with formatting
   471→        if text:
   472→            from slack_bot.utils.mrkdwn import SlackFormatter
   473→            formatted_text = SlackFormatter.convert(text)
   474→            logger.info(f"Formatted text (len {len(text)} -> {len(formatted_text)})")
   475→            parts.append(formatted_text)
   476→
   477→        # Join all parts
   478→        if parts:
   479→            full_response = "\n".join(parts)
   480→        else:
   481→            # No tools and no response - this is unusual
   482→            logger.warning("No response text and no tool results - returning error message")
   483→
   484→            # Enhanced debugging info for empty Gemini responses
   485→            debug_info = f"\n\n🔍 调试信息:\n- 响应文本长度: {len(text)} 字符\n- 工具调用数: {len(tool_results)}\n- 模型: {self.llm.get_model_name()}"
   486→
   487→            full_response = (
   488→                "⚠ 抱歉，我似乎没有理解你的请求。请尝试更具体的描述，或者直接说明你想查询什么数据（例如：今天的步数、昨天的睡眠、上午的运动等）。"
   489→                + debug_info
   490→            )
   491→
   492→        # Final safety check: ensure we're under the limit
   493→        if len(full_response) > max_length:
   494→            truncation_msg = "\n\n⚠ (Response truncated due to Slack length limit)"
   495→            allowed_length = max_length - len(truncation_msg)
   496→            full_response = full_response[:allowed_length] + truncation_msg
   497→            logger.warning(f"Final response truncated from {len(full_response)} to {max_length} chars")
   498→
   499→        return full_response
   500→
   501→    def _download_files(self, files: List[Dict], prefix: str) -> List[Dict[str, Any]]:
   502→        """Download files from Slack."""
   503→        import requests
   504→
   505→        token = self.bot_token
   506→        from PIL import Image
   507→        import io
   508→
   509→        downloaded_files = []
   510→
   511→        for file_info in files:
   512→            logger.info(f"{prefix} found file: {file_info.get('name')} Type: {file_info.get('mimetype')} URL_P: {bool(file_info.get('url_private'))}")
   513→            # Check if it's an image
   514→            if 'mimetype' in file_info and file_info['mimetype'].startswith('image/'):
   515→                url = file_info.get('url_private_download') or file_info.get('url_private')
   516→                if not url:
   517→                    continue
   518→
   519→                headers = {'Authorization': f'Bearer {token}'}
   520→                # Debug Auth (Masked)
   521→                token_masked = f"{token[:5]}...{token[-5:]}" if token else "None"
   522→                logger.info(f"{prefix} Attempting download from {url} with token {token_masked}")
   523→
   524→                # Try using url_private with token in query param
   525→                # This often bypasses redirect auth stripping issues
   526→                base_url = file_info.get('url_private')
   527→                if not base_url:
   528→                    continue
   529→
   530→                # Use Session to handle cookies/redirects better
   531→                session = requests.Session()
   532→                session.headers.update({'Authorization': f'Bearer {token}'})
   533→
   534→                # Debug Auth (Masked)
   535→                token_masked = f"{token[:5]}...{token[-5:]}" if token else "None"
   536→                logger.info(f"{prefix} Attempting download from {url} using Session (Token: {token_masked})")
   537→
   538→                try:
   539→                    response = session.get(url, timeout=10)
   540→
   541→                    # Log final URL to check for login page redirect
   542→                    if response.url != url:
   543→                        logger.info(f"{prefix} Redirected to: {response.url}")
   544→
   545→                    if response.status_code == 200:
   546→                        # DEBUG: Check for HTML (Login page)
   547→                        ct = response.headers.get('Content-Type', 'unknown')
   548→                        if 'html' in ct.lower():
   549→                            logger.error(f"{prefix} Download returned HTML (likely Login Page) instead of image. Auth failed.")
   550→                            continue
   551→
   552→                        # Process image: Resize and Compress
   553→                        try:
   554→                            img = Image.open(io.BytesIO(response.content))
   555→                        except Exception as img_err:
   556→                            logger.error(f"{prefix} PIL Open Failed! Content preamble: {response.content[:200]!r}")
   557→                            continue # Skip this file
   558→
   559→                        # Convert to RGB if necessary (e.g., PNG with alpha)
   560→                        if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
   561→                            bg = Image.new('RGB', img.size, (255, 255, 255))
   562→                            if img.mode != 'RGBA':
   563→                                img = img.convert('RGBA')
   564→                            bg.paste(img, mask=img.split()[3])
   565→                            img = bg
   566→                        elif img.mode != 'RGB':
   567→                            img = img.convert('RGB')
   568→
   569→                        # Resize if too large (max dimension 1024)
   570→                        max_dim = 1024
   571→                        if max(img.size) > max_dim:
   572→                            img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
   573→
   574→                        # Compress to JPEG
   575→                        buf = io.BytesIO()
   576→                        img.save(buf, format='JPEG', quality=85)
   577→                        optimized_data = buf.getvalue()
   578→
   579→                        logger.info(f"{prefix} Downloaded and optimized image: {file_info.get('name')} "
   580→                                  f"({len(response.content)/1024:.1f}KB -> {len(optimized_data)/1024:.1f}KB)")
   581→
   582→                        downloaded_files.append({
   583→                            "mime_type": "image/jpeg", # Always normalize to JPEG for consistency
   584→                            "data": optimized_data
   585→                        })
   586→                    else:
   587→                        logger.error(f"{prefix} Failed to download file: status {response.status_code}")
   588→                except Exception as e:
   589→                    logger.error(f"{prefix} Error downloading file: {e}")
   590→
   591→        return downloaded_files
   592→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from typing import Optional, List, Dict, Any
from slack_sdk import WebClient
import os

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.context.storage import ContextStorage
from slack_bot.tools.registry import TOOLS_SCHEMA, TOOL_FUNCTIONS
from slack_bot.tools.groups import get_tool_preset

logger = setup_logger(__name__)

class MessageDispatcher:
    """Routes incoming Slack messages to Gemini and executes tools."""

    def __init__(self, bot_token: Optional[str] = None, system_instruction: Optional[str] = None, tools: Optional[List[Dict]] = None, tool_mode: str = "light"):
        """
        Initialize dispatcher.
        Args:
            bot_token: Optional specific Slack token
            system_instruction: Custom system prompt (e.g. for Shell Bot)
            tools: specific tools list to use (if None, uses preset based on tool_mode)
            tool_mode: 'none' (no tools), 'light' (5 tools), 'standard' (10 tools), 'full' (15 tools), 'all' (21 tools)
        """
        self.llm = GeminiLLM(system_instruction=system_instruction)

        # Use tool preset if tools not explicitly provided
        if tools is not None:
            self.tools = tools
            self.tool_functions = TOOL_FUNCTIONS
        elif tool_mode == "none":
            self.tools = None
            self.tool_functions = {}
            logger.info("Running in NO-TOOLS mode (pure text)")
        else:
            self.tools, self.tool_functions = get_tool_preset(tool_mode)
            logger.info(f"Using tool preset: {tool_mode} ({len(self.tools)} tools)")

        # Initialize Slack WebClient for posting replies
        self.bot_token = bot_token or os.environ.get("SLACK_BOT_TOKEN")
        self.client = WebClient(token=self.bot_token)

        logger.info("Dispatcher initialized with Gemini")

    def dispatch(self, message_text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None, request_id: str = "N/A", files: Optional[List[Dict]] = None) -> None:
        """
        Process a message and trigger responses.
        """
        import time
        t0 = time.time()

        prefix = f"[{request_id}]"
        logger.info(f"{prefix} Processing message in {channel_id} from {user_id}")
        if files:
            logger.info(f"{prefix} Message contains {len(files)} files")

        # 1. Initialize Context
        storage = ContextStorage(channel_id)

        # Handle "clear" command
        if message_text.strip().lower() in ["clear", "reset", "清除"]:
            storage.clear()
            self.client.chat_postMessage(channel=channel_id, text="🧹 Context cleared. (对话历史已清除)")
            return

        storage.add_message("user", message_text)

        # Download images if any
        image_data_list = []
        download_failed = False
        if files:
            image_data_list = self._download_files(files, prefix)
            # Anti-Hallucination: Check if download failed
            if len(image_data_list) == 0:
                failure_msg = "\n[SYSTEM WARNING: User uploaded an image but it FAILED to download/process. You CANNOT see the image. DO NOT GUESS. Inform the user that the image access failed.]"
                logger.warning(f"{prefix} Image download failed! Appending warning to LLM.")
                message_text += failure_msg
                download_failed = True

        try:
            # 2. Get conversation context
            context = storage.get_context()
            logger.debug(f"{prefix} Context size: {len(context)}")

            # 3. Process with Gemini (with tools)
            # CRITICAL: If image download failed, disable tools to prevent "400 INVALID_ARGUMENT"
            # (which happens when mixing text-only warning prompts with complex tool schemas in some proxies)
            #
            # NOTE: gemini-3-flash has a bug where it returns empty response with tools,
            # but we use SAFETY OVERRIDE below to manually trigger tools based on keywords
            current_tools = None if download_failed else self.tools

            logger.info(f"{prefix} Calling Gemini for initial response...")
            logger.info(f"{prefix} Request config: model={self.llm.get_model_name()}, tools={len(current_tools) if current_tools else 0}, context={len(context)-1} msgs")
            t_llm_start = time.time()
            response_text, tool_calls = self.llm.generate_response(
                message=message_text,
                context=context[:-1],
                tools=current_tools,
                images=image_data_list
            )
            # Defensive check for None/empty response
            if response_text is None:
                logger.error(f"{prefix} Gemini returned None for response_text!")
                response_text = ""
            elif not response_text.strip():
                logger.warning(f"{prefix} Gemini returned empty response_text! (This usually means the proxy has issues)")

            # Initialize tool_calls
            if tool_calls is None:
                tool_calls = []

            # Log what we got back
            logger.info(f"{prefix} Gemini response: text={len(response_text)} chars, tool_calls={len(tool_calls) if tool_calls else 0}")

            # --- SAFETY OVERRIDE START ---
            # If the LLM failed to trigger tools (common with local proxies + tools bug),
            # manually detect intent and force tool calls based on keywords.
            # This is a FALLBACK mechanism when Gemini returns empty response or MALFORMED_FUNCTION_CALL.
            if not tool_calls:
                logger.info(f"{prefix} No tool calls from Gemini - checking SAFETY OVERRIDE keywords...")
                lower_msg = message_text.lower()

                # 1. Sync Garmin data
                if any(k in lower_msg for k in ["sync", "update", "fetch", "同步", "拉取", "更新"]) and \
                   any(k in lower_msg for k in ["garmin", "佳明", "data", "数据"]):
                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing sync_garmin tool")
                    from datetime import datetime, timedelta
                    from health.utils.time_utils import get_cst_today
                    # Try to infer sync target date from user message keywords
                    if any(k in lower_msg for k in ["昨天", "昨晚", "昨日", "yesterday", "last night"]):
                        _sync_date = (get_cst_today() - timedelta(days=1)).isoformat()
                    elif any(k in lower_msg for k in ["前天", "day before yesterday"]):
                        _sync_date = (get_cst_today() - timedelta(days=2)).isoformat()
                    else:
                        _sync_date = get_cst_today().isoformat()  # today in CST
                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: sync target_date={_sync_date}")
                    tool_calls.append({
                        "name": "sync_garmin",
                        "args": {"target_date": _sync_date}
                    })

                # 2. Query health data (sleep, steps, activities, etc.)
                elif any(k in lower_msg for k in ["睡眠", "sleep", "步数", "steps", "心率", "heart", "hrv", "运动", "activity", "workout", "锻炼", "昨天", "yesterday", "今天", "today", "上午", "下午", "morning", "afternoon", "查询", "query", "变化", "趋势", "trend"]):
                    from datetime import timedelta
                    from health.utils.time_utils import get_cst_now, get_cst_today

                    # CRITICAL: First check for single-day keywords to avoid false positives
                    has_single_day_keyword = any(k in lower_msg for k in [
                        "昨天", "昨晚", "昨日", "last night", "yesterday",
                        "今天", "今晚", "今日", "today", "tonight",
                        "前天", "前晚", "day before yesterday",
                        "大前天", "three days ago"
                    ])

                    # Check if asking for time-range trends (past X days/weeks/months)
                    is_trend_query = (not has_single_day_keyword) and any(k in lower_msg for k in [
                        "过去", "最近", "近", "past", "recent",
                        "变化", "趋势", "trend", "change", "历史", "history",
                        "个月", "month", "周", "week", "年", "year",
                    ])

                    if is_trend_query:
                        # Parse time range from query
                        import re
                        today_cst = get_cst_today()
                        end_date = today_cst.strftime("%Y-%m-%d")
                        days_ago = 30  # default

                        if re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg):
                            months = int(re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg).group(1) or re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg).group(2))
                            days_ago = months * 30
                        elif re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg):
                            weeks = int(re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg).group(1) or re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg).group(2))
                            days_ago = weeks * 7
                        elif re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg):
                            days_ago = int(re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg).group(1) or re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg).group(2))
                        elif re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg):
                            years = int(re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg).group(1) or re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg).group(2))
                            days_ago = years * 365
                        elif "半年" in lower_msg or "six month" in lower_msg:
                            days_ago = 180
                        elif "三个月" in lower_msg or "three month" in lower_msg:
                            days_ago = 90

                        from datetime import timedelta
                        start_date = (today_cst - timedelta(days=days_ago)).strftime("%Y-%m-%d")

                        # Determine metric type
                        metric_type = "rhr"  # default
                        if any(k in lower_msg for k in ["hrv", "心率变异"]):
                            metric_type = "hrv"
                        elif any(k in lower_msg for k in ["睡眠", "sleep"]):
                            metric_type = "sleep"
                        elif any(k in lower_msg for k in ["步数", "steps"]):
                            metric_type = "steps"
                        elif any(k in lower_msg for k in ["压力", "stress"]):
                            metric_type = "stress"

                        logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_metric_history for {metric_type} ({start_date} to {end_date})")
                        tool_calls.append({
                            "name": "get_metric_history",
                            "args": {
                                "metric_type": metric_type,
                                "start_date": start_date,
                                "end_date": end_date
                            }
                        })
                    else:
                        # Single-day query — all dates computed in CST (UTC+8)
                        today_cst = get_cst_today()
                        if any(k in lower_msg for k in ["昨天", "昨晚", "昨日", "last night", "yesterday"]):
                            target = (today_cst - timedelta(days=1)).strftime("%Y-%m-%d")
                        elif any(k in lower_msg for k in ["前天", "前晚", "day before yesterday"]):
                            target = (today_cst - timedelta(days=2)).strftime("%Y-%m-%d")
                        else:
                            target = today_cst.strftime("%Y-%m-%d")

                        # Check if specifically asking for activity history
                        if any(k in lower_msg for k in ["运动", "activity", "workout", "锻炼"]):
                            logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_activity_history for {target}")
                            tool_calls.append({
                                "name": "get_activity_history",
                                "args": {"start_date": target, "end_date": target}
                            })
                        else:
                            logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_daily_detailed_stats for {target}")
                            tool_calls.append({
                                "name": "get_daily_detailed_stats",
                                "args": {"target_date": target}
                            })

                # 3. Web search
                elif any(k in lower_msg for k in ["搜索", "search", "查一下", "look up", "最新", "latest"]):
                    query = message_text
                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing search_web")
                    tool_calls.append({
                        "name": "search_web",
                        "args": {"query": query}
                    })

                # 4. Detect diet logging intent (for logging, not forcing tool call yet)
                food_items = ["鸡", "肉", "菜", "蛋", "饭", "面", "粉", "汤", "鱼", "虾", "蟹", "牛", "猪", "羊", "豆", "奶"]
                meal_indicators = ["晚餐", "午餐", "早餐", "夜宵", "零食", "breakfast", "lunch", "dinner", "snack"]
                has_ate_pattern = any(k in lower_msg for k in ["吃了", "ate", "吃的", "eating"])
                has_meal_indicator = any(k in lower_msg for k in meal_indicators)
                has_food_item = any(k in lower_msg for k in food_items)

                if (has_ate_pattern and has_food_item) or has_meal_indicator:
                    logger.warning(f"{prefix} Detected diet logging intent: ate={has_ate_pattern}, meal={has_meal_indicator}, food={has_food_item}")
                    # Don't force tool - let user confirm first or LLM will handle in second round
                else:
                    logger.info(f"{prefix} No SAFETY OVERRIDE match found - will return empty response or text")
            else:
                logger.info(f"{prefix} Tool calls successfully generated by Gemini (no SAFETY OVERRIDE needed)")
            # --- SAFETY OVERRIDE END ---

            tool_count = len(tool_calls) if tool_calls else 0
            logger.info(f"{prefix} Gemini response in {time.time()-t_llm_start:.2f}s: {len(response_text)} chars, {tool_count} tools")

            # 4. Execute tools if requested
            tool_results = []
            if tool_calls:
                logger.info(f"{prefix} Executing {len(tool_calls)} tools")

                for tool_call in tool_calls:
                    tool_name = tool_call["name"]
                    tool_args = tool_call["args"]
                    logger.info(f"{prefix} Tool: {tool_name} Args: {tool_args}")

                    t_tool_start = time.time()
                    if tool_name in self.tool_functions:
                        try:
                            # Inject channel_id for shell tool
                            if tool_name == "execute_shell":
                                tool_args["channel_id"] = channel_id

                            result = self.tool_functions[tool_name](**tool_args)

                            # Safety Truncation: Prevent massive JSONs from blowing up context
                            str_result = str(result)
                            if len(str_result) > 8000:
                                str_result = str_result[:8000] + "... (truncated)"

                            tool_results.append({
                                "tool": tool_name,
                                "args": tool_args,
                                "result": str_result
                            })
                            logger.info(f"{prefix} ✓ {tool_name} finished in {time.time()-t_tool_start:.2f}s")
                        except Exception as e:
                            error_msg = f"Error executing {tool_name}: {str(e)}"
                            logger.error(f"{prefix} {error_msg}")
                            tool_results.append({
                                "tool": tool_name,
                                "args": tool_args,
                                "result": f"❌ {error_msg}"
                            })
                    else:
                        logger.warning(f"{prefix} Unknown tool: {tool_name}")

                # 4b. Second round: Get final response with tool results
                logger.info(f"{prefix} Requesting final analysis...")

                tool_names = ", ".join([tr['tool'] for tr in tool_results])

                # Format specific tool results for context
                tool_results_text = "\n".join([
                    f"Tool '{tr['tool']}' (Args: {tr['args']}) returned:\n{tr['result']}"
                    for tr in tool_results
                ])

                # Inject thoughts into memory
                storage.add_message("assistant", f"I checked: {tool_names}", model="gemini")

                # Stronger Prompt for Analysis
                analysis_prompt = (
                    f"Here are the execution results from the tools:\n{tool_results_text}\n\n"
                    f"CRITICAL INSTRUCTION: You represent the 'Butler' bot. You must now answer the user's original question based ONLY on the above results.\n"
                    f"1. Summarize the key data points found (e.g., steps, sleep score).\n"
                    f"2. Provide direct, natural language insights.\n"
                    f"3. If the tool indicated an error, explain it to the user.\n\n"
                    f"Do NOT output raw JSON. Write a helpful paragraph."
                )

                # Do NOT add this massive data blob to persistent storage.
                # Just use it for this generation context.
                final_context = storage.get_context()

                t_llm_2_start = time.time()
                response_text, _ = self.llm.generate_response(
                    message=analysis_prompt,
                    context=final_context,
                    tools=None
                )
                logger.info(f"{prefix} Final analysis received in {time.time()-t_llm_2_start:.2f}s")

                # Fallback
                if not response_text or not response_text.strip():
                    logger.warning(f"{prefix} Empty analysis received!")
                    if files:
                        response_text = "⚠ 图片已接收，但 AI 模型未能返回描述。这可能是由于网络波动或模型暂时无法识别该图像，请稍后重试。"
                    else:
                        response_text = "✅ 数据已同步，但我似乎暂时无法生成文字分析。请参考上方的工具执行结果。"

            # 5. Format and send response
            # Relaxing internal limit to allow for multi-message splitting (max 3 messages of ~4000 chars)
            APP_LIMIT = 12000
            full_response = self._format_response(response_text, tool_results, max_length=APP_LIMIT)

            # Split into chunks
            chunks = self._split_text(full_response, max_chunk_size=3800)

            # Limit to 3 chunks as requested
            if len(chunks) > 3:
                chunks = chunks[:3]
                chunks[-1] += "\n...(remaining content truncated)"

            # Post to Slack
            if response_ts:
                # Update the first message (Thinking...)
                try:
                    self.client.chat_update(channel=channel_id, ts=response_ts, text=chunks[0])
                    logger.info(f"{prefix} 📤 Message updated {response_ts}")
                except Exception as e:
                    logger.warning(f"{prefix} Update failed, posting new: {e}")
                    self.client.chat_postMessage(channel=channel_id, text=chunks[0])

                # Post subsequent chunks as new messages
                for chunk in chunks[1:]:
                    # Small delay to ensure order
                    time.sleep(0.5)
                    self.client.chat_postMessage(channel=channel_id, text=chunk)
                    logger.info(f"{prefix} 📤 Continuation message posted")

            else:
                # Post all as new messages
                for i, chunk in enumerate(chunks):
                    if i > 0: time.sleep(0.5)
                    self.client.chat_postMessage(channel=channel_id, text=chunk)
                logger.info(f"{prefix} 📤 {len(chunks)} message(s) posted")

            # 6. Save to Context
            storage.add_message("assistant", full_response, model="gemini")
            logger.info(f"{prefix} Dispatch completed in {time.time()-t0:.2f}s")

        except Exception as e:
            logger.error(f"{prefix} Dispatch failed: {e}", exc_info=True)
            try:
                self.client.chat_postMessage(
                    channel=channel_id,
                    text=f"⚠ Internal Error: {str(e)}"
                )
                logger.info("Error message posted to Slack")
            except Exception as slack_error:
                logger.error(f"Failed to post error message to Slack: {slack_error}", exc_info=True)

    def _split_text(self, text: str, max_chunk_size: int = 3800) -> List[str]:
        """Split text into chunks avoiding breaking code blocks if possible."""
        if len(text) <= max_chunk_size:
            return [text]

        chunks = []
        current_chunk = ""
        lines = text.split('\n')

        in_code_block = False

        for line in lines:
            # Check for code block toggle
            if line.strip().startswith('```'):
                in_code_block = not in_code_block

            # If adding this line exceeds chunk size
            if len(current_chunk) + len(line) + 1 > max_chunk_size:
                # Close code block if open
                if in_code_block:
                    current_chunk += "\n```"

                chunks.append(current_chunk)

                # Start new chunk
                current_chunk = ""
                # Re-open code block if it was open
                if in_code_block:
                    current_chunk += "```\n" + "(...continued)\n"

            if current_chunk:
                current_chunk += "\n"
            current_chunk += line

        if current_chunk:
            chunks.append(current_chunk)

        return chunks

    def _format_response(self, text: str, tool_results: list, max_length: int = 39900) -> str:
        """
        Format the response with tool execution results.

        Args:
            text: The main response text
            tool_results: List of tool execution results
            max_length: Maximum allowed message length (default for new messages)

        Returns:
            Formatted response string, guaranteed to be under max_length
        """
        parts = []

        # Add tool execution results first (with smart truncation)
        if tool_results:
            parts.append("🛠 *Tool Executions:*")
            for tr in tool_results:
                # Format args nicely
                args_str = ", ".join(f"{k}={v}" for k, v in tr['args'].items())

                # Truncate individual tool result if needed (more aggressive for multiple tools)
                result_str = str(tr['result'])
                # Allow larger tool outputs since we have multi-message support
                max_per_tool = 5000
                if len(result_str) > max_per_tool:
                    result_str = result_str[:max_per_tool] + "... (truncated)"

                parts.append(f"• `{tr['tool']}({args_str})`: {result_str}")
            parts.append("")  # Blank line

        # Add text response with formatting
        if text:
            from slack_bot.utils.mrkdwn import SlackFormatter
            formatted_text = SlackFormatter.convert(text)
            logger.info(f"Formatted text (len {len(text)} -> {len(formatted_text)})")
            parts.append(formatted_text)

        # Join all parts
        if parts:
            full_response = "\n".join(parts)
        else:
            # No tools and no response - this is unusual
            logger.warning("No response text and no tool results - returning error message")

            # Enhanced debugging info for empty Gemini responses
            debug_info = f"\n\n🔍 调试信息:\n- 响应文本长度: {len(text)} 字符\n- 工具调用数: {len(tool_results)}\n- 模型: {self.llm.get_model_name()}"

            full_response = (
                "⚠ 抱歉，我似乎没有理解你的请求。请尝试更具体的描述，或者直接说明你想查询什么数据（例如：今天的步数、昨天的睡眠、上午的运动等）。"
                + debug_info
            )

        # Final safety check: ensure we're under the limit
        if len(full_response) > max_length:
            truncation_msg = "\n\n⚠ (Response truncated due to Slack length limit)"
            allowed_length = max_length - len(truncation_msg)
            full_response = full_response[:allowed_length] + truncation_msg
            logger.warning(f"Final response truncated from {len(full_response)} to {max_length} chars")

        return full_response

    def _download_files(self, files: List[Dict], prefix: str) -> List[Dict[str, Any]]:
        """Download files from Slack."""
        import requests

        token = self.bot_token
        from PIL import Image
        import io

        downloaded_files = []

        for file_info in files:
            logger.info(f"{prefix} found file: {file_info.get('name')} Type: {file_info.get('mimetype')} URL_P: {bool(file_info.get('url_private'))}")
            # Check if it's an image
            if 'mimetype' in file_info and file_info['mimetype'].startswith('image/'):
                url = file_info.get('url_private_download') or file_info.get('url_private')
                if not url:
                    continue

                headers = {'Authorization': f'Bearer {token}'}
                # Debug Auth (Masked)
                token_masked = f"{token[:5]}...{token[-5:]}" if token else "None"
                logger.info(f"{prefix} Attempting download from {url} with token {token_masked}")

                # Try using url_private with token in query param
                # This often bypasses redirect auth stripping issues
                base_url = file_info.get('url_private')
                if not base_url:
                    continue

                # Use Session to handle cookies/redirects better
                session = requests.Session()
                session.headers.update({'Authorization': f'Bearer {token}'})

                # Debug Auth (Masked)
                token_masked = f"{token[:5]}...{token[-5:]}" if token else "None"
                logger.info(f"{prefix} Attempting download from {url} using Session (Token: {token_masked})")

                try:
                    response = session.get(url, timeout=10)

                    # Log final URL to check for login page redirect
                    if response.url != url:
                        logger.info(f"{prefix} Redirected to: {response.url}")

                    if response.status_code == 200:
                        # DEBUG: Check for HTML (Login page)
                        ct = response.headers.get('Content-Type', 'unknown')
                        if 'html' in ct.lower():
                            logger.error(f"{prefix} Download returned HTML (likely Login Page) instead of image. Auth failed.")
                            continue

                        # Process image: Resize and Compress
                        try:
                            img = Image.open(io.BytesIO(response.content))
                        except Exception as img_err:
                            logger.error(f"{prefix} PIL Open Failed! Content preamble: {response.content[:200]!r}")
                            continue # Skip this file

                        # Convert to RGB if necessary (e.g., PNG with alpha)
                        if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
                            bg = Image.new('RGB', img.size, (255, 255, 255))
                            if img.mode != 'RGBA':
                                img = img.convert('RGBA')
                            bg.paste(img, mask=img.split()[3])
                            img = bg
                        elif img.mode != 'RGB':
                            img = img.convert('RGB')

                        # Resize if too large (max dimension 1024)
                        max_dim = 1024
                        if max(img.size) > max_dim:
                            img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)

                        # Compress to JPEG
                        buf = io.BytesIO()
                        img.save(buf, format='JPEG', quality=85)
                        optimized_data = buf.getvalue()

                        logger.info(f"{prefix} Downloaded and optimized image: {file_info.get('name')} "
                                  f"({len(response.content)/1024:.1f}KB -> {len(optimized_data)/1024:.1f}KB)")

                        downloaded_files.append({
                            "mime_type": "image/jpeg", # Always normalize to JPEG for consistency
                            "data": optimized_data
                        })
                    else:
                        logger.error(f"{prefix} Failed to download file: status {response.status_code}")
                except Exception as e:
                    logger.error(f"{prefix} Error downloading file: {e}")

        return downloaded_files


好的，我找到了问题所在！在 dispatcher.py 文件中，第 130 行使用了 `get_cst_today()` 函数，这个函数可能是根据中国标准时间（UTC+8）来计算日期的。而当前系统的时区可能不同，导致计算出来的日期有差异。

让我查看一下 get_cst_today() 函数的实现。


现在我需要查看 get_cst_today() 函数的实现，看看它是如何计算日期的：


Found 1 file
health/utils/time_utils.py


     1→from datetime import datetime, timedelta, timezone, date
     2→
     3→
     4→def get_cst_now() -> datetime:
     5→    """Returns the current datetime in UTC+8 (China Standard Time)."""
     6→    tz = timezone(timedelta(hours=8))
     7→    return datetime.now(tz)
     8→
     9→
    10→def get_cst_today() -> date:
    11→    """Returns today's date in UTC+8 (China Standard Time)."""
    12→    return get_cst_now().date()
    13→
    14→
    15→def get_current_time_str() -> str:
    16→    """
    17→    Returns the current time in UTC+8 (China Standard Time).
    18→    Format: YYYY-MM-DD HH:MM:SS (CST)
    19→    """
    20→    return get_cst_now().strftime("%Y-%m-%d %H:%M:%S (CST)")
    21→
    22→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

from datetime import datetime, timedelta, timezone, date

def get_cst_now() -> datetime:
    """Returns the current datetime in UTC+8 (China Standard Time)."""
    tz = timezone(timedelta(hours=8))
    return datetime.now(tz)

def get_cst_today() -> date:
    """Returns today's date in UTC+8 (China Standard Time)."""
    return get_cst_now().date()

def get_current_time_str() -> str:
    """
    Returns the current time in UTC+8 (China Standard Time).
    Format: YYYY-MM-DD HH:MM:SS (CST)
    """
    return get_cst_now().strftime("%Y-%m-%d %H:%M:%S (CST)")


现在我需要检查服务器的当前时间和时区，看看是否与 CST 时区有差异，导致计算出来的日期不正确。


让我检查服务器的当前时间和时区：


Bash

显示服务器当前时间


检查服务器时区


Tue Mar 10 01:28:15 PM CST 2026


lrwxrwxrwx 1 root root 34 Jun 19  2025 /etc/localtime -> ../usr/share/zoneinfo/Asia/Beijing


服务器时区是 CST（Asia/Beijing），日期是 2026-03-10（周二）。让我再检查一下时间差异是否更大，让我写一个简单的测试脚本看看 get_cst_today() 和 date.today() 分别返回什么。


让我写一个简单的测试脚本，查看当前的日期计算：


Write

#!/usr/bin/env python3
from datetime import date
from health.utils.time_utils import get_cst_today, get_cst_now

print(f"date.today() (local system time): {date.today()}")
print(f"get_cst_now(): {get_cst_now()}")
print(f"get_cst_today(): {get_cst_today()}")


File created successfully at: /root/projects/butler/test_time.py

#!/usr/bin/env python3
from datetime import date
from health.utils.time_utils import get_cst_today, get_cst_now

print(f"date.today() (local system time): {date.today()}")
print(f"get_cst_now(): {get_cst_now()}")
print(f"get_cst_today(): {get_cst_today()}")


现在让我运行这个测试脚本，看看输出结果。


Bash

运行测试脚本查看日期计算


Error: Exit code 127
/bin/bash: line 1: python: command not found


好的，python 命令找不到，让我尝试使用 python3。


使用 python3 运行测试脚本


date.today() (local system time): 2026-03-10
get_cst_now(): 2026-03-10 13:31:21.121701+08:00
get_cst_today(): 2026-03-10


好的，两个函数返回的都是今天的日期（2026-03-10），所以时区问题不是导致同步昨天数据的原因。让我检查一下用户刚才使用 slack 命令时的日志，看看实际同步的日期是什么。


让我查看 Slack 机器人的日志文件，看看刚才同步佳明数据时的详细信息：


查看日志文件目录


total 2280
drwxr-xr-x  2  501 games   4096 Mar  9 11:00 .
drwxr-xr-x 20 root root    4096 Mar 10 13:30 ..
-rw-r--r--  1 root root   73005 Mar 10 08:00 cron_daily_sync.log
-rw-r--r--  1 root root    2653 Mar  9 11:00 cron_weekly_report.log
-rw-r--r--  1  501 games  27291 Mar 10 08:00 daily_sync.log
-rw-r--r--  1  501 games 773838 Mar 10 13:16 health.log
-rw-r--r--  1  501 games 858357 Mar 10 12:01 health_sync.log
-rw-r--r--  1 root root   12170 Mar  3 13:57 init_vault_indexer.log
-rw-r--r--  1 root root    3730 Mar 10 01:00 memory_reminder.log
-rw-r--r--  1  501 games 411870 Mar 10 12:01 obsidian.log
-rw-r--r--  1  501 games 146730 Feb 28 13:55 shell.log
-rw-r--r--  1  501 games   3256 Feb  5 20:51 slack_bot.log
-rw-r--r--  1 root root    3064 Mar  9 11:00 weekly_report.log


好的，我们有几个相关的日志文件，包括 health_sync.log、health.log 和 cron_daily_sync.log。让我检查一下 health_sync.log，看看用户刚才同步时的记录。


让我查看 health_sync.log 文件，查看刚才同步时的详细记录：


查看同步日志的最后 200 行


2026-02-02 10:32:09 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-02 10:48:48 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-02 10:51:38 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-02 10:55:01 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-02 10:56:19 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:11:13 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:11:13 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:11:15 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:15:10 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:16:04 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:16:13 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:18:58 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:23:44 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:23:45 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:23:45 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:39:07 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:39:07 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 11:39:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:30:43 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:30:43 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:30:44 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:36:38 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:36:38 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:36:40 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:43:20 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:43:20 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:43:21 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:51:17 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:51:17 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 15:51:19 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:10:01 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:10:01 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:10:02 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:15:36 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:15:36 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:15:37 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:16:06 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:16:07 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:16:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:30:48 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:30:49 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:30:49 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:53:05 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:53:05 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 16:53:07 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:00:21 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:00:21 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:00:23 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:14:38 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:14:38 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:14:39 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:39:10 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:39:10 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-04 17:39:12 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 12:52:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 12:52:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 12:52:48 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 13:11:30 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 13:11:30 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 13:11:32 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 17:57:13 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:02:22 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:12:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:49:28 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:49:28 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:49:29 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:57:53 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:57:53 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:57:55 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:58:56 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:58:57 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 20:58:57 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:01:59 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:06:07 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:06:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:06:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:06:40 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:06:40 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-05 21:06:42 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 09:57:30 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 09:57:30 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 09:57:32 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 10:22:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 10:22:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 10:22:48 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 10:55:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:04:53 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:05:10 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:11:55 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:14:11 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:22:03 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:22:43 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:23:28 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:31:59 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:34:43 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:34:43 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:34:44 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:36:42 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:37:27 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:38:19 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:42:57 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:44:45 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:46:26 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:50:33 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 11:50:58 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:12:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:12:09 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:12:10 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:34:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:34:08 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:34:09 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:40:27 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.local.env
2026-02-06 14:46:01 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.local.env
2026-02-06 14:47:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:47:46 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 14:47:48 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 15:33:36 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 15:33:36 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-06 15:33:37 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-26 09:57:00 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-26 09:57:03 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-02-26 09:57:03 - health.utils.env_loader - INFO - Loading extra configuration from: /Users/lili/workspace/bulter/.gemini.current.env
2026-03-02 10:08:00 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 10:16:13 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 10:18:16 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 13:52:24 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 14:29:33 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 14:54:24 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 16:28:45 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 17:49:09 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-02 19:08:03 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 12:13:35 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 12:41:22 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 12:44:52 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 12:51:24 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 16:26:19 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 16:26:23 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 16:53:03 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 16:58:11 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 17:09:08 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 17:17:21 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 19:20:24 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:01:57 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:05:20 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:10:29 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:15:41 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:17:43 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:29:11 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:30:38 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:41:05 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 20:52:52 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:07:44 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:19:08 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:26:30 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:34:15 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:41:26 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:47:44 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:54:59 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:55:18 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:55:31 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 21:57:22 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 22:13:26 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 22:17:18 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 22:19:24 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 22:37:55 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:07:56 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:21:06 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:33:08 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:39:08 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:42:21 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:53:38 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-03 23:55:10 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 00:01:30 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 00:15:07 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 10:59:33 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 11:16:16 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 12:16:25 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 12:29:45 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 12:31:11 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 12:39:41 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 12:41:44 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 13:00:46 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 13:02:30 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 13:28:59 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 13:41:17 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-04 13:54:56 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-05 18:47:10 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-05 18:47:14 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-05 18:50:34 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-05 18:51:22 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-05 18:59:30 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-05 18:59:42 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 09:32:47 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 09:43:17 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 10:02:44 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 10:09:03 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 10:21:00 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-06 10:33:58 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-09 11:00:02 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env
2026-03-10 12:01:14 - health.utils.env_loader - INFO - Loading extra configuration from: /root/projects/butler/.gemini.current.env


日志文件很长，但是我发现这个日志文件包含了很多旧的日志记录（从 2月2日开始），而最近的同步记录可能在 health.log 文件中。让我检查一下 health.log 文件。


让我查看 health.log 文件，查看最近的同步记录：


查看 health.log 文件的最后 300 行