diff --git a/docs/remote-connect/feishu-bot-setup.md b/docs/remote-connect/feishu-bot-setup.md index 2a39ee96..7e9767ac 100644 --- a/docs/remote-connect/feishu-bot-setup.md +++ b/docs/remote-connect/feishu-bot-setup.md @@ -34,7 +34,7 @@ Credentials & Basic Info - Copy App ID and App Secret ### Step6 -Open BitFun - Remote Connect - SMS Bot - Feishu Bot - Fill in App ID and App Secret - Connect +Open BitFun - Remote Connect - IM Bot - Feishu Bot - Fill in App ID and App Secret - Connect ### Step7 diff --git a/docs/remote-connect/feishu-bot-setup.zh-CN.md b/docs/remote-connect/feishu-bot-setup.zh-CN.md index 12f24a2b..8af06191 100644 --- a/docs/remote-connect/feishu-bot-setup.zh-CN.md +++ b/docs/remote-connect/feishu-bot-setup.zh-CN.md @@ -30,7 +30,7 @@ ### 第六步 -打开 BitFun - 远程连接 - SMS 机器人 - Feishu 机器人 - 填写 App ID 和 App Secret - 连接 +打开 BitFun - 远程连接 - IM 机器人 - Feishu 机器人 - 填写 App ID 和 App Secret - 连接 ### 第七步 diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index 688acd34..b419b8fc 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -13,6 +13,12 @@ pub struct Theme { pub border: Color, } +impl Default for Theme { + fn default() -> Self { + Self::dark() + } +} + impl Theme { pub fn dark() -> Self { Self { diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index edb6d77b..84011444 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1,7 +1,7 @@ //! Tauri commands for Remote Connect. use bitfun_core::service::remote_connect::{ - bot::{self, BotConfig}, + bot::{self, weixin, BotConfig}, lan, ConnectionMethod, ConnectionResult, PairingState, RemoteConnectConfig, RemoteConnectService, }; @@ -363,6 +363,12 @@ pub async fn remote_connect_get_methods() -> Result, S available: true, description: "Via Telegram".into(), }, + ConnectionMethod::BotWeixin => ConnectionMethodInfo { + id: "bot_weixin".into(), + name: "WeChat (Weixin)".into(), + available: true, + description: "Via WeChat iLink bot".into(), + }, }) .collect(); @@ -382,6 +388,7 @@ fn parse_connection_method( }), "bot_feishu" => Ok(ConnectionMethod::BotFeishu), "bot_telegram" => Ok(ConnectionMethod::BotTelegram), + "bot_weixin" => Ok(ConnectionMethod::BotWeixin), _ => Err(format!("unknown connection method: {method}")), } } @@ -485,6 +492,20 @@ pub struct ConfigureBotRequest { pub app_id: Option, pub app_secret: Option, pub bot_token: Option, + pub weixin_ilink_token: Option, + pub weixin_base_url: Option, + pub weixin_bot_account_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WeixinQrStartRequest { + pub base_url: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WeixinQrPollRequest { + pub session_key: String, + pub base_url: Option, } #[tauri::command] @@ -500,6 +521,11 @@ pub async fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Resul "telegram" => BotConfig::Telegram { bot_token: request.bot_token.unwrap_or_default(), }, + "weixin" => BotConfig::Weixin { + ilink_token: request.weixin_ilink_token.unwrap_or_default(), + base_url: request.weixin_base_url.unwrap_or_default(), + bot_account_id: request.weixin_bot_account_id.unwrap_or_default(), + }, _ => return Err(format!("unknown bot type: {}", request.bot_type)), }; @@ -509,6 +535,7 @@ pub async fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Resul match &bot_config { BotConfig::Feishu { .. } => config.bot_feishu = Some(bot_config), BotConfig::Telegram { .. } => config.bot_telegram = Some(bot_config), + BotConfig::Weixin { .. } => config.bot_weixin = Some(bot_config), } let service = RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; *guard = Some(service); @@ -519,6 +546,24 @@ pub async fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Resul Ok(()) } +#[tauri::command] +pub async fn remote_connect_weixin_qr_start( + request: WeixinQrStartRequest, +) -> Result { + weixin::weixin_qr_start(request.base_url) + .await + .map_err(|e| format!("weixin qr start: {e}")) +} + +#[tauri::command] +pub async fn remote_connect_weixin_qr_poll( + request: WeixinQrPollRequest, +) -> Result { + weixin::weixin_qr_poll(&request.session_key, request.base_url) + .await + .map_err(|e| format!("weixin qr poll: {e}")) +} + #[tauri::command] pub async fn remote_connect_get_bot_verbose_mode() -> Result { let data = bot::load_bot_persistence(); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b5aecab8..94c2a37b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -607,6 +607,8 @@ pub async fn run() { api::remote_connect_api::remote_connect_set_form_state, api::remote_connect_api::remote_connect_configure_custom_server, api::remote_connect_api::remote_connect_configure_bot, + api::remote_connect_api::remote_connect_weixin_qr_start, + api::remote_connect_api::remote_connect_weixin_qr_poll, api::remote_connect_api::remote_connect_get_bot_verbose_mode, api::remote_connect_api::remote_connect_set_bot_verbose_mode, // MiniApp API diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index a479bea8..25446f54 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -34,6 +34,8 @@ regex = { workspace = true } base64 = { workspace = true } image = { workspace = true } md5 = { workspace = true } +aes = "0.8" +hex = "0.4" dashmap = { workspace = true } indexmap = { workspace = true } diff --git a/src/crates/core/src/agentic/agents/explore_agent.rs b/src/crates/core/src/agentic/agents/explore_agent.rs index 2ea412b5..1da96625 100644 --- a/src/crates/core/src/agentic/agents/explore_agent.rs +++ b/src/crates/core/src/agentic/agents/explore_agent.rs @@ -32,7 +32,7 @@ impl Agent for ExploreAgent { } fn description(&self) -> &str { - r#"Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions."# + r#"Subagent for **wide** codebase exploration only. Use when the main agent would need many sequential search/read rounds across multiple areas, or the user asks for an architectural survey. Do **not** use for narrow tasks: a known path, a single class/symbol lookup, one obvious Grep pattern, or reading a handful of files — the main agent should use Grep, Glob, and Read for those. When calling, set thoroughness in the prompt: \"quick\", \"medium\", or \"very thorough\"."# } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md index 9f4df204..958848df 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md @@ -88,20 +88,20 @@ The user will primarily request you perform software engineering tasks. This inc # Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. +- For routine codebase lookups (known or guessable paths, a single symbol or class name, one Grep/Glob pattern, or reading a few files), use Read, Grep, and Glob directly. That is usually faster than spawning a subagent. +- Use the Task tool with specialized subagents only when the work clearly matches that subagent and is substantial enough to justify the extra session (multi-step autonomous work, or genuinely broad exploration as described below). - When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. - If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. - Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. -- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool with subagent_type=Explore instead of running search commands directly. +- Use Task with subagent_type=Explore only for **broad** exploration: the location is unknown across several areas, you need a survey of many modules, or the question is architectural ("how is X wired end-to-end?") and would otherwise take many sequential search rounds. If you can answer with a few Grep/Glob/Read calls, do that yourself instead of Explore. -user: Where are errors from the client handled? -assistant: [Uses the Task tool with subagent_type=Explore to find the files that handle client errors instead of using Glob or Grep directly] +user: Give me a high-level map of how authentication flows through this monorepo +assistant: [Uses the Task tool with subagent_type=Explore because multiple services and layers must be traced] -user: What is the codebase structure? -assistant: [Uses the Task tool with subagent_type=Explore] +user: Where is class ClientError defined? +assistant: [Uses Grep or Glob directly — a needle query; do not spawn Explore] IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md index 60032a83..cae65d1b 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md +++ b/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md @@ -31,8 +31,8 @@ IMPORTANT: Never generate or guess URLs for the user unless you are confident th # Tools - Use TodoWrite for non-trivial or multi-step tasks, and keep it updated. - Use AskUserQuestion only when a decision materially changes the result and cannot be inferred safely. -- Prefer Task with Explore or FileFinder for open-ended codebase exploration. -- Prefer Read, Grep, and Glob for targeted lookups. +- Use Read, Grep, and Glob by default for codebase lookups; they are faster for narrow or single-location questions. +- Use Task with Explore or FileFinder only for genuinely open-ended or multi-area exploration (many modules, unclear ownership, architectural surveys). - Prefer specialized file tools over Bash for reading and editing files. - Use Bash for builds, tests, git, and scripts. - Run independent tool calls in parallel when possible. diff --git a/src/crates/core/src/agentic/session/compression_manager.rs b/src/crates/core/src/agentic/session/compression_manager.rs index 82e31b8a..ac94e961 100644 --- a/src/crates/core/src/agentic/session/compression_manager.rs +++ b/src/crates/core/src/agentic/session/compression_manager.rs @@ -283,7 +283,9 @@ impl CompressionManager { self.compressed_histories .insert(session_id.to_string(), compressed_messages.clone()); - // Persist compression history (similar to MessageHistoryManager pattern) + // Persist compression history (similar to MessageHistoryManager pattern). + // Persistence is intentionally off until the storage contract is finalized. + #[allow(clippy::overly_complex_bool_expr)] if false && self.config.enable_persistence { if let Err(e) = self .persistence diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index bd847a8b..dd92a23f 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -53,6 +53,7 @@ When NOT to use the Task tool: - If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly - If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly - If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly +- For subagent_type=Explore: do not use it for simple lookups above; reserve it for broad or multi-area exploration where many tool rounds would be needed - Other tasks that are not related to the agent descriptions above diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 3e81b208..38512240 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -57,6 +57,10 @@ pub struct BotChatState { /// Not persisted — cleared on bot restart. #[serde(skip)] pub pending_files: std::collections::HashMap, + /// Commands for the last bot message that had quick actions (1 → `actions[0].command`). + /// Not persisted — used so numeric replies work like OpenClaw menu numbers. + #[serde(skip, default)] + pub last_menu_commands: Vec, } impl BotChatState { @@ -70,6 +74,7 @@ impl BotChatState { display_mode: BotDisplayMode::Assistant, pending_action: None, pending_files: std::collections::HashMap::new(), + last_menu_commands: Vec::new(), } } } @@ -218,8 +223,32 @@ impl BotAction { // ── Command parsing ───────────────────────────────────────────────── +fn normalize_im_command_text(text: &str) -> String { + text.trim() + .chars() + .map(|c| match c { + '\u{FF10}'..='\u{FF19}' => { + char::from_u32(c as u32 - 0xFF10 + u32::from(b'0')).unwrap_or(c) + } + c => c, + }) + .collect() +} + +/// Strip trailing list punctuation so "1." / "1、" / "1)" still parse as menu numbers. +fn strip_numeric_reply_suffix(s: &str) -> &str { + s.trim_end_matches(|c: char| { + matches!( + c, + '.' | '。' | '、' | ',' | ',' | ':' | ':' | ';' | ';' | ')' | ')' | ']' | '】' + ) + }) + .trim() +} + pub fn parse_command(text: &str) -> BotCommand { - let trimmed = text.trim(); + let normalized = normalize_im_command_text(text); + let trimmed = normalized.trim(); if let Some(rest) = trimmed.strip_prefix("/cancel_task") { let arg = rest.trim(); return if arg.is_empty() { @@ -243,14 +272,17 @@ pub fn parse_command(text: &str) -> BotCommand { _ => { if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { BotCommand::PairingCode(trimmed.to_string()) - } else if let Ok(n) = trimmed.parse::() { - if (1..=99).contains(&n) { - BotCommand::NumberSelection(n) + } else { + let num_token = strip_numeric_reply_suffix(trimmed); + if let Ok(n) = num_token.parse::() { + if (1..=99).contains(&n) { + BotCommand::NumberSelection(n) + } else { + BotCommand::ChatMessage(trimmed.to_string()) + } } else { BotCommand::ChatMessage(trimmed.to_string()) } - } else { - BotCommand::ChatMessage(trimmed.to_string()) } } } @@ -265,14 +297,14 @@ pub fn welcome_message(language: BotLanguage) -> &'static str { 要连接你的 BitFun 桌面端,请发送 BitFun Remote Connect 面板里显示的 6 位配对码。 -如果你还没有配对码,请打开 BitFun Desktop -> Remote Connect -> Telegram/飞书机器人,复制 6 位配对码并发送到这里。" +如果你还没有配对码,请打开 BitFun Desktop -> Remote Connect -> Telegram/飞书/微信机器人,复制 6 位配对码并发送到这里。" } else { "\ Welcome to BitFun! To connect your BitFun desktop app, please enter the 6-digit pairing code shown in your BitFun Remote Connect panel. -Need a pairing code? Open BitFun Desktop -> Remote Connect -> Telegram/Feishu Bot -> copy the 6-digit code and send it here." +Need a pairing code? Open BitFun Desktop -> Remote Connect -> Telegram/Feishu/WeChat bot -> copy the 6-digit code and send it here." } } @@ -319,6 +351,167 @@ pub fn paired_success_message(language: BotLanguage) -> String { } } +/// After IM pairing: assistant mode, default assistant workspace, resume latest Claw (else any) session or create Claw. +/// Mutates `state` (`display_mode`, `current_assistant`, `current_session_id`). Does not set `paired`. +pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String { + use crate::agentic::persistence::PersistenceManager; + use crate::infrastructure::PathManager; + use crate::service::workspace::get_global_workspace_service; + use std::path::PathBuf; + + state.display_mode = BotDisplayMode::Assistant; + let language = current_bot_language().await; + + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return if language.is_chinese() { + "自动准备未能完成:工作区服务不可用。请稍后在 BitFun 桌面端打开工作区后再试。".to_string() + } else { + "Auto-setup incomplete: workspace service unavailable. Open a workspace in BitFun Desktop and try again." + .to_string() + }; + } + }; + + let mut assistants = ws_service.get_assistant_workspaces().await; + if assistants.is_empty() { + match ws_service.create_assistant_workspace(None).await { + Ok(w) => assistants.push(w), + Err(e) => { + return if language.is_chinese() { + format!("自动准备未能完成:无法创建助理工作区({e})。请使用 /switch_assistant。") + } else { + format!( + "Auto-setup incomplete: could not create assistant workspace ({e}). Use /switch_assistant." + ) + }; + } + } + } + + let picked = assistants + .iter() + .find(|w| w.assistant_id.is_none()) + .cloned() + .or_else(|| assistants.first().cloned()); + + let Some(ws_info) = picked else { + return if language.is_chinese() { + "自动准备未能完成:没有可用助理。请使用 /switch_assistant。".to_string() + } else { + "Auto-setup incomplete: no assistant found. Use /switch_assistant.".to_string() + }; + }; + + let path_str = ws_info.root_path.to_string_lossy().to_string(); + let path_buf = ws_info.root_path.clone(); + if let Err(e) = ws_service.open_workspace(path_buf.clone()).await { + return if language.is_chinese() { + format!("自动准备未能完成:无法打开助理工作区({e})。") + } else { + format!("Auto-setup incomplete: failed to open assistant workspace ({e}).") + }; + } + if let Err(e) = + crate::service::snapshot::initialize_snapshot_manager_for_workspace(path_buf, None).await + { + error!("IM bot bootstrap: snapshot init after pairing: {e}"); + } + + state.current_assistant = Some(path_str.clone()); + state.current_session_id = None; + + let pm = match PathManager::new() { + Ok(pm) => std::sync::Arc::new(pm), + Err(e) => { + return if language.is_chinese() { + format!("自动准备部分完成:无法访问会话索引({e})。可直接尝试发消息。") + } else { + format!("Partial auto-setup: cannot access session index ({e}). You can try sending a message.") + }; + } + }; + let store = match PersistenceManager::new(pm) { + Ok(s) => s, + Err(e) => { + return if language.is_chinese() { + format!("自动准备部分完成:无法访问会话索引({e})。可直接尝试发消息。") + } else { + format!("Partial auto-setup: cannot access session index ({e}). You can try sending a message.") + }; + } + }; + + let mut metas = match store.list_session_metadata(&PathBuf::from(&path_str)).await { + Ok(m) => m, + Err(e) => { + return if language.is_chinese() { + format!("自动准备部分完成:列出会话失败({e})。可直接尝试发消息。") + } else { + format!("Partial auto-setup: failed to list sessions ({e}). You can try sending a message.") + }; + } + }; + metas.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); + + let latest = metas + .iter() + .find(|m| m.agent_type == "Claw") + .or_else(|| metas.first()); + + if let Some(m) = latest { + state.current_session_id = Some(m.session_id.clone()); + let name = m.session_name.as_str(); + return if language.is_chinese() { + format!( + "已为你进入助理模式,并恢复最近会话「{name}」。直接发送消息即可继续对话。" + ) + } else { + format!( + "Assistant mode is on; resumed your latest session \"{name}\". Send a message to continue." + ) + }; + } + + let create_res = handle_new_session(state, "Claw").await; + if state.current_session_id.is_none() { + return if language.is_chinese() { + format!( + "已进入助理模式,但未能自动创建会话:{}", + create_res.reply.lines().next().unwrap_or("未知错误") + ) + } else { + format!( + "Assistant mode is on, but session creation failed: {}", + create_res.reply.lines().next().unwrap_or("unknown error") + ) + }; + } + + if language.is_chinese() { + "已进入助理模式;尚无历史会话,已为你新建助理会话。直接发送消息即可开始。".to_string() + } else { + "Assistant mode is on; no prior sessions were found, so a new assistant session was created. Send a message to start." + .to_string() + } +} + +/// Mark chat paired, run assistant/session bootstrap, return first user-visible message + main menu actions. +pub async fn complete_im_bot_pairing(state: &mut BotChatState) -> HandleResult { + state.paired = true; + let language = current_bot_language().await; + let note = bootstrap_im_chat_after_pairing(state).await; + let reply = format!("{}\n\n{}", paired_success_message(language), note); + let actions = main_menu_actions(language, state.display_mode); + state.last_menu_commands = actions.iter().map(|a| a.command.clone()).collect(); + HandleResult { + reply, + actions, + forward_to_session: None, + } +} + fn label_switch_workspace(language: BotLanguage) -> &'static str { if language.is_chinese() { "切换工作区" @@ -524,19 +717,24 @@ fn cancel_task_actions(language: BotLanguage, command: impl Into) -> Vec // ── Main dispatch ─────────────────────────────────────────────────── -pub async fn handle_command( +async fn dispatch_im_bot_command( state: &mut BotChatState, cmd: BotCommand, - images: Vec, + image_contexts: Vec, ) -> HandleResult { - let language = current_bot_language().await; - let image_contexts: Vec = - super::super::remote_server::images_to_contexts(if images.is_empty() { - None - } else { - Some(&images) - }); + let r = dispatch_im_bot_command_inner(state, cmd, image_contexts).await; + if !r.actions.is_empty() { + state.last_menu_commands = r.actions.iter().map(|a| a.command.clone()).collect(); + } + r +} +async fn dispatch_im_bot_command_inner( + state: &mut BotChatState, + cmd: BotCommand, + image_contexts: Vec, +) -> HandleResult { + let language = current_bot_language().await; match cmd { BotCommand::Start | BotCommand::Help => { if state.paired { @@ -555,39 +753,48 @@ pub async fn handle_command( } BotCommand::SwitchMode(new_mode) => { if !state.paired { - return not_paired(language); - } - state.display_mode = new_mode; - let mode_name = if new_mode == BotDisplayMode::Pro { - if language.is_chinese() { "专业模式" } else { "Expert Mode" } + not_paired(language) } else { - if language.is_chinese() { "助理模式" } else { "Assistant Mode" } - }; - let desc = if new_mode == BotDisplayMode::Pro { - if language.is_chinese() { - "适合目标明确、一次完成的即时任务。" + state.display_mode = new_mode; + let mode_name = if new_mode == BotDisplayMode::Pro { + if language.is_chinese() { + "专业模式" + } else { + "Expert Mode" + } } else { - "Best for focused, one-shot tasks with a clear goal." - } - } else { - if language.is_chinese() { - "适合持续推进、需要延续上下文和个人偏好的任务。" + if language.is_chinese() { + "助理模式" + } else { + "Assistant Mode" + } + }; + let desc = if new_mode == BotDisplayMode::Pro { + if language.is_chinese() { + "适合目标明确、一次完成的即时任务。" + } else { + "Best for focused, one-shot tasks with a clear goal." + } } else { - "Best for ongoing work with context and personal preferences." + if language.is_chinese() { + "适合持续推进、需要延续上下文和个人偏好的任务。" + } else { + "Best for ongoing work with context and personal preferences." + } + }; + HandleResult { + reply: if language.is_chinese() { + format!("已切换到 {}\n\n{}\n\n你现在可以:", mode_name, desc) + } else { + format!("Switched to {}\n\n{}\n\nYou can now:", mode_name, desc) + }, + actions: if new_mode == BotDisplayMode::Pro { + pro_mode_actions(language) + } else { + assistant_mode_actions(language) + }, + forward_to_session: None, } - }; - HandleResult { - reply: if language.is_chinese() { - format!("已切换到 {}\n\n{}\n\n你现在可以:", mode_name, desc) - } else { - format!("Switched to {}\n\n{}\n\nYou can now:", mode_name, desc) - }, - actions: if new_mode == BotDisplayMode::Pro { - pro_mode_actions(language) - } else { - assistant_mode_actions(language) - }, - forward_to_session: None, } } BotCommand::PairingCode(_) => HandleResult { @@ -692,6 +899,20 @@ pub async fn handle_command( } } +pub async fn handle_command( + state: &mut BotChatState, + cmd: BotCommand, + images: Vec, +) -> HandleResult { + let image_contexts: Vec = + super::super::remote_server::images_to_contexts(if images.is_empty() { + None + } else { + Some(&images) + }); + dispatch_im_bot_command(state, cmd, image_contexts).await +} + // ── Helpers ───────────────────────────────────────────────────────── fn not_paired(language: BotLanguage) -> HandleResult { @@ -1496,7 +1717,15 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe ) .await } - None => handle_chat_message(state, &n.to_string(), vec![]).await, + None => { + if n >= 1 && n <= state.last_menu_commands.len() { + let cmd_str = state.last_menu_commands[n - 1].clone(); + let next_cmd = parse_command(&cmd_str); + Box::pin(dispatch_im_bot_command(state, next_cmd, vec![])).await + } else { + handle_chat_message(state, &n.to_string(), vec![]).await + } + } } } @@ -2573,3 +2802,19 @@ fn format_tool_params_slim(params: &serde_json::Value) -> Option { _ => None, } } + +#[cfg(test)] +mod parse_command_tests { + use super::{parse_command, BotCommand}; + + #[test] + fn numeric_menu_with_trailing_dot() { + assert!(matches!(parse_command("1."), BotCommand::NumberSelection(1))); + assert!(matches!(parse_command("2。"), BotCommand::NumberSelection(2))); + } + + #[test] + fn fullwidth_digit_one() { + assert!(matches!(parse_command("1"), BotCommand::NumberSelection(1))); + } +} diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs index 50032fc0..5ce027c4 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -14,9 +14,9 @@ use tokio::sync::RwLock; use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ - current_bot_language, execute_forwarded_turn, handle_command, main_menu_actions, - paired_success_message, parse_command, welcome_message, BotAction, BotActionStyle, - BotChatState, BotDisplayMode, BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, + complete_im_bot_pairing, current_bot_language, execute_forwarded_turn, handle_command, + parse_command, welcome_message, BotAction, BotActionStyle, + BotChatState, BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -1174,15 +1174,9 @@ impl FeishuBot { } else if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { info!("Feishu pairing successful, chat_id={chat_id}"); - let result = HandleResult { - reply: paired_success_message(language), - actions: main_menu_actions(language, BotDisplayMode::Assistant), - forward_to_session: None, - }; - self.send_handle_result(&chat_id, &result).await.ok(); - let mut state = BotChatState::new(chat_id.clone()); - state.paired = true; + let result = complete_im_bot_pairing(&mut state).await; + self.send_handle_result(&chat_id, &result).await.ok(); self.chat_states .write() .await @@ -1507,12 +1501,7 @@ impl FeishuBot { } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { - state.paired = true; - let result = HandleResult { - reply: paired_success_message(language), - actions: main_menu_actions(language, BotDisplayMode::Assistant), - forward_to_session: None, - }; + let result = complete_im_bot_pairing(state).await; self.send_handle_result(chat_id, &result).await.ok(); self.persist_chat_state(chat_id, state).await; return; diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index 6f5f6b75..3fbe03fa 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -1,12 +1,13 @@ //! Bot integration for Remote Connect. //! -//! Supports Feishu and Telegram bots as relay channels. +//! Supports Feishu, Telegram, and Weixin (iLink) bots as relay channels. //! Shared command logic lives in `command_router`; platform-specific -//! I/O is handled by `telegram` and `feishu`. +//! I/O is handled by `telegram`, `feishu`, and `weixin`. pub mod command_router; pub mod feishu; pub mod telegram; +pub mod weixin; use serde::{Deserialize, Serialize}; @@ -18,6 +19,11 @@ pub use command_router::{BotChatState, ForwardRequest, ForwardedTurnResult, Hand pub enum BotConfig { Feishu { app_id: String, app_secret: String }, Telegram { bot_token: String }, + Weixin { + ilink_token: String, + base_url: String, + bot_account_id: String, + }, } /// Pairing state for bot-based connections. @@ -46,6 +52,13 @@ pub struct RemoteConnectFormState { pub telegram_bot_token: String, pub feishu_app_id: String, pub feishu_app_secret: String, + /// Weixin iLink credentials after QR login (optional until user links WeChat). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_ilink_token: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_base_url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_bot_account_id: String, } /// All persisted bot connections (one per bot type at most). diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs index d9c9632e..7ebe07ba 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -12,9 +12,9 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::command_router::{ - current_bot_language, execute_forwarded_turn, handle_command, paired_success_message, - parse_command, welcome_message, BotAction, BotChatState, BotInteractionHandler, - BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, + complete_im_bot_pairing, current_bot_language, execute_forwarded_turn, handle_command, + parse_command, welcome_message, BotAction, BotChatState, + BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; use crate::service::remote_connect::remote_server::ImageAttachment; @@ -554,17 +554,15 @@ impl TelegramBot { if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { info!("Telegram pairing successful, chat_id={chat_id}"); - let success_msg = paired_success_message(language); - self.send_message(chat_id, &success_msg).await.ok(); - self.set_bot_commands().await.ok(); - let mut state = BotChatState::new(chat_id.to_string()); - state.paired = true; + let result = complete_im_bot_pairing(&mut state).await; self.chat_states .write() .await .insert(chat_id, state.clone()); self.persist_chat_state(chat_id, &state).await; + self.send_handle_result(chat_id, &result).await; + self.set_bot_commands().await.ok(); return Ok(chat_id); } else { @@ -651,11 +649,10 @@ impl TelegramBot { } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { - state.paired = true; - let msg = paired_success_message(language); - self.send_message(chat_id, &msg).await.ok(); - self.set_bot_commands().await.ok(); + let result = complete_im_bot_pairing(state).await; self.persist_chat_state(chat_id, state).await; + self.send_handle_result(chat_id, &result).await; + self.set_bot_commands().await.ok(); return; } else { self.send_message(chat_id, Self::invalid_pairing_code_message(language)) diff --git a/src/crates/core/src/service/remote_connect/bot/weixin.rs b/src/crates/core/src/service/remote_connect/bot/weixin.rs new file mode 100644 index 00000000..c5c9db80 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/bot/weixin.rs @@ -0,0 +1,1820 @@ +//! Weixin (微信) iLink bot integration for Remote Connect. +//! +//! Uses Tencent iLink HTTP APIs (`getupdates` long-poll, `sendmessage`) documented in +//! `@tencent-weixin/openclaw-weixin`. Login is QR-based; after login the same 6-digit +//! pairing flow as Telegram/Feishu binds the Weixin user to this desktop. + +use anyhow::{anyhow, Result}; +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::Aes128; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use log::{debug, error, info, warn}; +use rand::Rng; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use super::command_router::{ + complete_im_bot_pairing, current_bot_language, execute_forwarded_turn, handle_command, + parse_command, welcome_message, BotAction, BotChatState, BotInteractionHandler, + BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, +}; +use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; +use crate::service::remote_connect::remote_server::ImageAttachment; + +const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; +const DEFAULT_ILINK_BOT_TYPE: &str = "3"; +const CHANNEL_VERSION: &str = "1.0.2"; +const LONG_POLL_TIMEOUT_SECS: u64 = 36; +const API_TIMEOUT_SECS: u64 = 20; +const QR_POLL_TIMEOUT_SECS: u64 = 36; +const SESSION_EXPIRED_ERRCODE: i64 = -14; +const SESSION_PAUSE_SECS: u64 = 3600; +const MAX_TEXT_CHUNK: usize = 3500; +const MAX_QR_REFRESH: u32 = 3; +/// Weixin CDN host for encrypted upload (same as `@tencent-weixin/openclaw-weixin`). +const DEFAULT_CDN_BASE_URL: &str = "https://novac2c.cdn.weixin.qq.com/c2c"; +/// Same cap as Feishu bot file send. +const MAX_WEIXIN_FILE_BYTES: u64 = 30 * 1024 * 1024; +const CDN_UPLOAD_MAX_RETRIES: u32 = 3; +/// Same cap as Feishu inbound images. +const MAX_INBOUND_IMAGES: usize = 5; + +// ── AES-128-ECB (PKCS#7) + CDN upload helpers (iLink file/image/video send) ─ + +fn aes_ecb_ciphertext_len(plaintext_len: usize) -> usize { + let pad = 16 - (plaintext_len % 16); + let pad = if pad == 0 { 16 } else { pad }; + plaintext_len + pad +} + +fn encrypt_aes_128_ecb_pkcs7(plaintext: &[u8], key: &[u8; 16]) -> Vec { + let cipher = Aes128::new_from_slice(key).expect("AES-128 key len"); + let pad_len = 16 - (plaintext.len() % 16); + let pad_len = if pad_len == 0 { 16 } else { pad_len }; + let mut buf = plaintext.to_vec(); + buf.extend(std::iter::repeat(pad_len as u8).take(pad_len)); + let mut out = Vec::with_capacity(buf.len()); + for chunk in buf.chunks_exact(16) { + let mut block = aes::cipher::generic_array::GenericArray::clone_from_slice(chunk); + cipher.encrypt_block(&mut block); + out.extend_from_slice(&block); + } + out +} + +fn md5_hex_lower(data: &[u8]) -> String { + format!("{:x}", md5::compute(data)) +} + +fn build_cdn_upload_url(cdn_base: &str, upload_param: &str, filekey: &str) -> String { + let base = cdn_base.trim_end_matches('/'); + format!( + "{}/upload?encrypted_query_param={}&filekey={}", + base, + urlencoding::encode(upload_param), + urlencoding::encode(filekey) + ) +} + +/// CDN download URL (same as `@tencent-weixin/openclaw-weixin` `buildCdnDownloadUrl`). +fn build_cdn_download_url(cdn_base: &str, encrypted_query_param: &str) -> String { + let base = cdn_base.trim_end_matches('/'); + format!( + "{}/download?encrypted_query_param={}", + base, + urlencoding::encode(encrypted_query_param) + ) +} + +fn decrypt_aes_128_ecb_pkcs7(ciphertext: &[u8], key: &[u8; 16]) -> Result> { + if ciphertext.is_empty() || ciphertext.len() % 16 != 0 { + return Err(anyhow!( + "invalid ciphertext length {}", + ciphertext.len() + )); + } + let cipher = Aes128::new_from_slice(key).expect("AES-128 key len"); + let mut out = Vec::with_capacity(ciphertext.len()); + for chunk in ciphertext.chunks_exact(16) { + let mut block = aes::cipher::generic_array::GenericArray::clone_from_slice(chunk); + cipher.decrypt_block(&mut block); + out.extend_from_slice(&block); + } + let Some(&pad_byte) = out.last() else { + return Err(anyhow!("empty after decrypt")); + }; + let pad = pad_byte as usize; + if pad == 0 || pad > 16 || pad > out.len() { + return Err(anyhow!("invalid PKCS#7 padding (pad={pad})")); + } + if !out[out.len() - pad..].iter().all(|&b| b == pad_byte) { + return Err(anyhow!("invalid PKCS#7 padding bytes")); + } + out.truncate(out.len() - pad); + Ok(out) +} + +/// `CDNMedia.aes_key`: base64(raw 16 bytes) or base64(32-char hex) — OpenClaw `parseAesKey`. +fn parse_weixin_cdn_aes_key(aes_key_base64: &str) -> Result<[u8; 16]> { + let decoded = B64 + .decode(aes_key_base64.trim()) + .map_err(|e| anyhow!("aes_key base64: {e}"))?; + if decoded.len() == 16 { + let mut k = [0u8; 16]; + k.copy_from_slice(&decoded); + return Ok(k); + } + if decoded.len() == 32 { + let s = std::str::from_utf8(&decoded).map_err(|_| anyhow!("aes_key: expected utf8 hex"))?; + if s.len() == 32 && s.chars().all(|c| c.is_ascii_hexdigit()) { + let bytes = hex::decode(s).map_err(|e| anyhow!("aes_key inner hex: {e}"))?; + if bytes.len() == 16 { + let mut k = [0u8; 16]; + k.copy_from_slice(&bytes); + return Ok(k); + } + } + } + Err(anyhow!( + "aes_key: unsupported encoding (decoded {} bytes)", + decoded.len() + )) +} + +fn sniff_image_mime(bytes: &[u8]) -> &'static str { + if bytes.len() >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { + return "image/jpeg"; + } + if bytes.len() >= 8 + && bytes[..8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + { + return "image/png"; + } + if bytes.len() >= 6 + && (&bytes[..6] == b"GIF87a".as_slice() || &bytes[..6] == b"GIF89a".as_slice()) + { + return "image/gif"; + } + if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return "image/webp"; + } + "image/jpeg" +} + +#[derive(Debug)] +struct UploadedMediaInfo { + download_encrypted_query_param: String, + aeskey_hex: String, + file_size_plain: u64, + file_size_cipher: usize, +} + +// ── QR login session store (in-memory, same role as OpenClaw installer) ───── + +#[derive(Debug, Clone)] +struct QrLoginSession { + qrcode: String, + qr_image_url: String, + started_at_ms: i64, + refresh_count: u32, +} + +enum QrSessionLookup { + Missing, + TimedOut, + Found(QrLoginSession), +} + +fn qr_sessions() -> &'static Mutex> { + static CELL: OnceLock>> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn normalize_weixin_account_id(raw: &str) -> String { + raw.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect() +} + +fn random_wechat_uin_header() -> String { + let n: u32 = rand::thread_rng().gen(); + base64::engine::general_purpose::STANDARD.encode(n.to_string().as_bytes()) +} + +fn ensure_trailing_slash(url: &str) -> String { + if url.ends_with('/') { + url.to_string() + } else { + format!("{url}/") + } +} + +fn sync_buf_path(bot_account_id: &str) -> PathBuf { + let base = dirs::home_dir().unwrap_or_else(std::env::temp_dir); + base + .join(".bitfun") + .join("weixin") + .join(format!("{bot_account_id}_get_updates_buf.txt")) +} + +fn load_sync_buf(bot_account_id: &str) -> String { + let p = sync_buf_path(bot_account_id); + std::fs::read_to_string(&p).unwrap_or_default().trim().to_string() +} + +fn save_sync_buf(bot_account_id: &str, buf: &str) { + let p = sync_buf_path(bot_account_id); + if let Some(parent) = p.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&p, buf) { + warn!("weixin: failed to save sync buf {}: {e}", p.display()); + } +} + +// ── Public QR API (used from Tauri) ─────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct WeixinQrStartResponse { + pub session_key: String, + pub qr_image_url: String, + pub message: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WeixinQrPollStatus { + Wait, + Scanned, + Confirmed, + Expired, + Error, +} + +#[derive(Debug, Serialize)] +pub struct WeixinQrPollResponse { + pub status: WeixinQrPollStatus, + pub message: String, + /// Present when a new QR was issued after expiry (client should refresh image). + pub qr_image_url: Option, + pub ilink_token: Option, + pub bot_account_id: Option, + pub base_url: Option, +} + +#[derive(Debug, Deserialize)] +struct QrCodeApiResponse { + qrcode: Option, + qrcode_img_content: Option, +} + +#[derive(Debug, Deserialize)] +struct QrStatusApiResponse { + status: Option, + bot_token: Option, + ilink_bot_id: Option, + baseurl: Option, +} + +/// Start Weixin QR login: fetch QR from iLink and register a session. +pub async fn weixin_qr_start(base_url_override: Option) -> Result { + let base = ensure_trailing_slash( + base_url_override + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_BASE_URL), + ); + let url = format!( + "{}ilink/bot/get_bot_qrcode?bot_type={}", + base, + urlencoding::encode(DEFAULT_ILINK_BOT_TYPE) + ); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(API_TIMEOUT_SECS)) + .build()?; + + let resp = client.get(&url).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("get_bot_qrcode HTTP {status}: {body}")); + } + let parsed: QrCodeApiResponse = resp.json().await?; + let qrcode = parsed + .qrcode + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("get_bot_qrcode: missing qrcode"))?; + let qr_image_url = parsed + .qrcode_img_content + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("get_bot_qrcode: missing qrcode_img_content"))?; + + let session_key = uuid::Uuid::new_v4().to_string(); + let session = QrLoginSession { + qrcode, + qr_image_url: qr_image_url.clone(), + started_at_ms: now_ms(), + refresh_count: 0, + }; + qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))? + .insert(session_key.clone(), session); + + Ok(WeixinQrStartResponse { + session_key, + qr_image_url, + message: "Scan the QR code with WeChat.".to_string(), + }) +} + +/// Poll QR login status (long-poll once). Call repeatedly from the UI until `confirmed` or `error`. +pub async fn weixin_qr_poll( + session_key: &str, + base_url_override: Option, +) -> Result { + let base = ensure_trailing_slash( + base_url_override + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_BASE_URL), + ); + + let lookup = { + let mut map = qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))?; + match map.get(session_key) { + None => QrSessionLookup::Missing, + Some(s) => { + if now_ms() - s.started_at_ms > 5 * 60_000 { + map.remove(session_key); + QrSessionLookup::TimedOut + } else { + QrSessionLookup::Found(s.clone()) + } + } + } + }; + + match lookup { + QrSessionLookup::Missing => Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Error, + message: "No active QR session. Start login again.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }), + QrSessionLookup::TimedOut => Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Error, + message: "QR session expired. Start again.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }), + QrSessionLookup::Found(session) => { + let qrcode_enc = urlencoding::encode(&session.qrcode); + let url = format!("{}ilink/bot/get_qrcode_status?qrcode={}", base, qrcode_enc); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(QR_POLL_TIMEOUT_SECS)) + .build()?; + + let resp = client + .get(&url) + .header("iLink-App-ClientVersion", "1") + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + return Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Wait, + message: "waiting".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }); + } + qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))? + .remove(session_key); + return Err(anyhow!("get_qrcode_status: {e}")); + } + }; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))? + .remove(session_key); + return Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Error, + message: format!("HTTP {status}: {body}"), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }); + } + + let status_json: QrStatusApiResponse = resp.json().await?; + let st = status_json.status.as_deref().unwrap_or("wait"); + + match st { + "wait" => Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Wait, + message: "waiting".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }), + "scaned" => Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Scanned, + message: "Scanned; confirm on your phone.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }), + "confirmed" => { + let token = status_json + .bot_token + .clone() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("confirmed but bot_token missing"))?; + let raw_id = status_json + .ilink_bot_id + .clone() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("confirmed but ilink_bot_id missing"))?; + let normalized = normalize_weixin_account_id(&raw_id); + let baseurl = status_json + .baseurl + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| base.trim_end_matches('/').to_string()); + + qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))? + .remove(session_key); + + Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Confirmed, + message: "WeChat linked.".to_string(), + qr_image_url: None, + ilink_token: Some(token), + bot_account_id: Some(normalized), + base_url: Some(baseurl), + }) + } + "expired" => { + let over_limit = { + let mut map = qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))?; + let Some(s) = map.get_mut(session_key) else { + return Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Error, + message: "Session lost. Start again.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }); + }; + s.refresh_count += 1; + if s.refresh_count > MAX_QR_REFRESH { + map.remove(session_key); + true + } else { + false + } + }; + + if over_limit { + return Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Error, + message: "QR expired too many times; start again.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }); + } + + let refresh_url = format!( + "{}ilink/bot/get_bot_qrcode?bot_type={}", + base, + urlencoding::encode(DEFAULT_ILINK_BOT_TYPE) + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(API_TIMEOUT_SECS)) + .build()?; + let refresh = client.get(&refresh_url).send().await?; + if !refresh.status().is_success() { + qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))? + .remove(session_key); + return Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Error, + message: "Failed to refresh QR.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }); + } + let parsed: QrCodeApiResponse = refresh.json().await?; + let qrcode = parsed + .qrcode + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("refresh: missing qrcode"))?; + let qr_image_url = parsed + .qrcode_img_content + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("refresh: missing qrcode_img_content"))?; + + { + let mut m = qr_sessions() + .lock() + .map_err(|e| anyhow!("qr session lock: {e}"))?; + if let Some(s) = m.get_mut(session_key) { + s.qrcode = qrcode; + s.qr_image_url = qr_image_url.clone(); + s.started_at_ms = now_ms(); + } + } + + Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Expired, + message: "QR refreshed.".to_string(), + qr_image_url: Some(qr_image_url), + ilink_token: None, + bot_account_id: None, + base_url: None, + }) + } + _ => Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Wait, + message: st.to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }), + } + } + } +} + +// ── iLink authenticated client ────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeixinConfig { + pub ilink_token: String, + pub base_url: String, + /// Normalized ilink bot id (filesystem-safe); used for sync buffer path. + pub bot_account_id: String, +} + +#[derive(Debug, Clone)] +struct PendingPairing { + created_at: i64, +} + +pub struct WeixinBot { + config: WeixinConfig, + pending_pairings: Arc>>, + chat_states: Arc>>, + context_tokens: Arc>>, + session_pause_until_ms: Arc>>, +} + +impl WeixinBot { + pub fn new(config: WeixinConfig) -> Self { + Self { + config, + pending_pairings: Arc::new(RwLock::new(HashMap::new())), + chat_states: Arc::new(RwLock::new(HashMap::new())), + context_tokens: Arc::new(RwLock::new(HashMap::new())), + session_pause_until_ms: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn restore_chat_state(&self, peer_id: &str, state: BotChatState) { + self.chat_states + .write() + .await + .insert(peer_id.to_string(), state); + } + + fn base_url(&self) -> String { + ensure_trailing_slash(&self.config.base_url) + } + + async fn is_session_paused(&self) -> bool { + let id = &self.config.bot_account_id; + let mut m = self.session_pause_until_ms.write().await; + let now = now_ms(); + if let Some(until) = m.get(id).copied() { + if now >= until { + m.remove(id); + return false; + } + return true; + } + false + } + + async fn pause_session(&self) { + let until = now_ms() + (SESSION_PAUSE_SECS as i64) * 1000; + self.session_pause_until_ms + .write() + .await + .insert(self.config.bot_account_id.clone(), until); + warn!( + "weixin: session expired (err -14), pausing API for {}s", + SESSION_PAUSE_SECS + ); + } + + fn build_auth_headers(&self, body: &str) -> reqwest::header::HeaderMap { + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + let mut h = HeaderMap::new(); + h.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/json"), + ); + h.insert( + HeaderName::from_static("authorizationtype"), + HeaderValue::from_static("ilink_bot_token"), + ); + h.insert( + HeaderName::from_static("content-length"), + HeaderValue::from_str(&body.len().to_string()).unwrap_or(HeaderValue::from_static("0")), + ); + h.insert( + HeaderName::from_static("x-wechat-uin"), + HeaderValue::from_str(&random_wechat_uin_header()).unwrap_or(HeaderValue::from_static("MA==")), + ); + if let Ok(v) = HeaderValue::from_str(&format!("Bearer {}", self.config.ilink_token.trim())) { + h.insert(HeaderName::from_static("authorization"), v); + } + h + } + + async fn post_ilink(&self, endpoint: &str, body: Value, timeout: Duration) -> Result { + let url = format!("{}{}", self.base_url(), endpoint.trim_start_matches('/')); + let body_str = serde_json::to_string(&body)?; + let client = reqwest::Client::builder().timeout(timeout).build()?; + let resp = client + .post(&url) + .headers(self.build_auth_headers(&body_str)) + .body(body_str) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(anyhow!("ilink {endpoint} HTTP {status}: {text}")); + } + Ok(text) + } + + fn cdn_base_url(&self) -> &'static str { + DEFAULT_CDN_BASE_URL + } + + async fn fetch_weixin_cdn_bytes(&self, encrypted_query_param: &str) -> Result> { + let url = build_cdn_download_url(self.cdn_base_url(), encrypted_query_param); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + let resp = client.get(&url).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("weixin CDN GET {status}: {body}")); + } + Ok(resp.bytes().await?.to_vec()) + } + + /// Decrypt one inbound `image_item` (CDN download + AES-128-ECB), matching OpenClaw `downloadMediaFromItem`. + async fn inbound_image_bytes_from_item(&self, item: &Value) -> Result> { + let img = &item["image_item"]; + let param = img["media"]["encrypt_query_param"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("image: missing encrypt_query_param"))?; + + let key: Option<[u8; 16]> = + if let Some(hex_s) = img["aeskey"].as_str().filter(|s| !s.is_empty()) { + let bytes = hex::decode(hex_s.trim()).map_err(|e| anyhow!("image aeskey hex: {e}"))?; + if bytes.len() != 16 { + return Err(anyhow!("image aeskey must decode to 16 bytes")); + } + let mut k = [0u8; 16]; + k.copy_from_slice(&bytes); + Some(k) + } else if let Some(b64) = img["media"]["aes_key"].as_str().filter(|s| !s.is_empty()) { + Some(parse_weixin_cdn_aes_key(b64)?) + } else { + None + }; + + let enc = self.fetch_weixin_cdn_bytes(param).await?; + match key { + Some(k) => decrypt_aes_128_ecb_pkcs7(&enc, &k), + None => Ok(enc), + } + } + + /// Collect up to [`MAX_INBOUND_IMAGES`] images from `item_list` as Feishu-style `ImageAttachment` data URLs. + async fn inbound_image_attachments_from_message(&self, msg: &Value) -> (Vec, usize) { + const MAX_BYTES: usize = 1024 * 1024; + let Some(items) = msg["item_list"].as_array() else { + return (vec![], 0); + }; + let total_with_param = items + .iter() + .filter(|i| { + i["type"].as_i64() == Some(2) + && i["image_item"]["media"]["encrypt_query_param"] + .as_str() + .is_some_and(|s| !s.is_empty()) + }) + .count(); + let skipped = total_with_param.saturating_sub(MAX_INBOUND_IMAGES); + + let mut attachments = Vec::new(); + for item in items { + if attachments.len() >= MAX_INBOUND_IMAGES { + break; + } + if item["type"].as_i64() != Some(2) { + continue; + } + match self.inbound_image_bytes_from_item(item).await { + Ok(raw) => { + let mime = sniff_image_mime(&raw); + let data_url = if raw.len() <= MAX_BYTES { + let b64 = B64.encode(&raw); + format!("data:{mime};base64,{b64}") + } else { + let raw_fallback = raw.clone(); + match crate::agentic::image_analysis::optimize_image_with_size_limit( + raw, + "openai", + Some(mime), + Some(MAX_BYTES), + ) { + Ok(processed) => { + let b64 = B64.encode(&processed.data); + format!("data:{};base64,{}", processed.mime_type, b64) + } + Err(e) => { + warn!("Weixin image compression failed: {e}"); + let b64 = B64.encode(&raw_fallback); + format!("data:{mime};base64,{b64}") + } + } + }; + attachments.push(ImageAttachment { + name: format!("weixin_image_{}.jpg", attachments.len() + 1), + data_url, + }); + } + Err(e) => warn!("Weixin inbound image download failed: {e}"), + } + } + (attachments, skipped) + } + + /// `ilink/bot/getuploadurl` — returns `upload_param` for CDN POST. + async fn ilink_get_upload_url( + &self, + to_user_id: &str, + filekey: &str, + media_type: i64, + rawsize: u64, + rawfilemd5: &str, + filesize: usize, + aeskey_hex: &str, + ) -> Result { + let body = json!({ + "filekey": filekey, + "media_type": media_type, + "to_user_id": to_user_id, + "rawsize": rawsize, + "rawfilemd5": rawfilemd5, + "filesize": filesize, + "no_need_thumb": true, + "aeskey": aeskey_hex, + "base_info": { "channel_version": CHANNEL_VERSION } + }); + let raw = self + .post_ilink( + "ilink/bot/getuploadurl", + body, + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await?; + let v: Value = serde_json::from_str(&raw)?; + v["upload_param"] + .as_str() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("getuploadurl: missing upload_param")) + } + + async fn post_weixin_cdn_upload( + &self, + cdn_url: &str, + ciphertext: &[u8], + ) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + let mut last_err: Option = None; + for attempt in 1..=CDN_UPLOAD_MAX_RETRIES { + let resp = client + .post(cdn_url) + .header("Content-Type", "application/octet-stream") + .body(ciphertext.to_vec()) + .send() + .await; + let resp = match resp { + Ok(r) => r, + Err(e) => { + last_err = Some(anyhow!("CDN upload attempt {attempt}: {e}")); + if attempt < CDN_UPLOAD_MAX_RETRIES { + tokio::time::sleep(Duration::from_secs(1)).await; + } + continue; + } + }; + let status = resp.status(); + if status.is_client_error() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("CDN client error {status}: {body}")); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + last_err = Some(anyhow!("CDN server error {status}: {body}")); + if attempt < CDN_UPLOAD_MAX_RETRIES { + tokio::time::sleep(Duration::from_secs(1)).await; + } + continue; + } + let download_param = resp + .headers() + .get("x-encrypted-param") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + return download_param.ok_or_else(|| { + anyhow!("CDN response missing x-encrypted-param header") + }); + } + Err(last_err.unwrap_or_else(|| anyhow!("CDN upload failed"))) + } + + /// Read plaintext → encrypt → getuploadurl → POST to CDN (same pipeline as OpenClaw weixin plugin). + async fn upload_bytes_to_weixin_cdn( + &self, + to_user_id: &str, + plaintext: &[u8], + media_type: i64, + ) -> Result { + let rawsize = plaintext.len() as u64; + let rawfilemd5 = md5_hex_lower(plaintext); + let mut aeskey = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut aeskey); + let aeskey_hex = hex::encode(aeskey); + let filesize_cipher = aes_ecb_ciphertext_len(plaintext.len()); + let ciphertext = encrypt_aes_128_ecb_pkcs7(plaintext, &aeskey); + + let mut filekey_raw = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut filekey_raw); + let filekey = hex::encode(filekey_raw); + + let upload_param = self + .ilink_get_upload_url( + to_user_id, + &filekey, + media_type, + rawsize, + &rawfilemd5, + filesize_cipher, + &aeskey_hex, + ) + .await?; + + let cdn_url = build_cdn_upload_url(self.cdn_base_url(), &upload_param, &filekey); + debug!( + "weixin CDN upload: media_type={media_type} rawsize={rawsize} cipher_len={}", + ciphertext.len() + ); + let download_encrypted_query_param = self.post_weixin_cdn_upload(&cdn_url, &ciphertext).await?; + + Ok(UploadedMediaInfo { + download_encrypted_query_param, + aeskey_hex, + file_size_plain: rawsize, + file_size_cipher: ciphertext.len(), + }) + } + + /// `aes_key` in JSON: base64 of raw 16-byte key (standard); matches typical iLink clients. + fn media_aes_key_b64(aeskey_hex: &str) -> Result { + let bytes = hex::decode(aeskey_hex.trim()).map_err(|e| anyhow!("aeskey hex: {e}"))?; + if bytes.len() != 16 { + return Err(anyhow!("aeskey must decode to 16 bytes")); + } + Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) + } + + async fn send_message_with_items( + &self, + to_user_id: &str, + context_token: &str, + items: Vec, + ) -> Result<()> { + let client_id = format!("bitfun-wx-{}", uuid::Uuid::new_v4()); + let msg = json!({ + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": client_id, + "message_type": 2, + "message_state": 2, + "item_list": items, + "context_token": context_token, + }); + let body = json!({ + "msg": msg, + "base_info": { "channel_version": CHANNEL_VERSION } + }); + self.post_ilink( + "ilink/bot/sendmessage", + body, + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await?; + Ok(()) + } + + /// Upload a workspace file and send as image / video / file attachment (like Feishu `send_file_to_feishu_chat`). + async fn send_workspace_file_to_peer( + &self, + peer_id: &str, + raw_path: &str, + workspace_root: Option<&std::path::Path>, + ) -> Result<()> { + let content = super::read_workspace_file(raw_path, MAX_WEIXIN_FILE_BYTES, workspace_root).await?; + let mime = super::detect_mime_type(std::path::Path::new(&content.name)); + + let token = { + let m = self.context_tokens.read().await; + m.get(peer_id) + .cloned() + .ok_or_else(|| anyhow!("missing context_token for peer {peer_id}"))? + }; + + let item: Value = if mime.starts_with("image/") { + let up = self + .upload_bytes_to_weixin_cdn(peer_id, &content.bytes, 1) + .await?; + let aes_b64 = Self::media_aes_key_b64(&up.aeskey_hex)?; + json!({ + "type": 2, + "image_item": { + "media": { + "encrypt_query_param": up.download_encrypted_query_param, + "aes_key": aes_b64, + "encrypt_type": 1 + }, + "mid_size": up.file_size_cipher + } + }) + } else if mime.starts_with("video/") { + let up = self + .upload_bytes_to_weixin_cdn(peer_id, &content.bytes, 2) + .await?; + let aes_b64 = Self::media_aes_key_b64(&up.aeskey_hex)?; + json!({ + "type": 5, + "video_item": { + "media": { + "encrypt_query_param": up.download_encrypted_query_param, + "aes_key": aes_b64, + "encrypt_type": 1 + }, + "video_size": up.file_size_cipher + } + }) + } else { + let up = self + .upload_bytes_to_weixin_cdn(peer_id, &content.bytes, 3) + .await?; + let aes_b64 = Self::media_aes_key_b64(&up.aeskey_hex)?; + json!({ + "type": 4, + "file_item": { + "media": { + "encrypt_query_param": up.download_encrypted_query_param, + "aes_key": aes_b64, + "encrypt_type": 1 + }, + "file_name": content.name, + "len": format!("{}", up.file_size_plain) + } + }) + }; + + self.send_message_with_items(peer_id, &token, vec![item]).await?; + info!("Weixin file sent to peer={peer_id} name={}", content.name); + Ok(()) + } + + fn expired_download_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "这个下载链接已过期,请重新让助手发送一次。" + } else { + "This download link has expired. Please ask the agent again." + } + } + + fn sending_file_message(language: BotLanguage, file_name: &str) -> String { + if language.is_chinese() { + format!("正在发送“{file_name}”……") + } else { + format!("Sending \"{file_name}\"…") + } + } + + fn send_file_failed_message(language: BotLanguage, file_name: &str, error: &str) -> String { + if language.is_chinese() { + format!("无法发送“{file_name}”:{error}") + } else { + format!("Could not send \"{file_name}\": {error}") + } + } + + async fn handle_download_request( + &self, + peer_id: &str, + token: &str, + workspace_root: Option, + ) { + let (path, language) = { + let mut states = self.chat_states.write().await; + let state = states.get_mut(peer_id); + let language = current_bot_language().await; + let path = state.and_then(|s| s.pending_files.remove(token)); + (path, language) + }; + + match path { + None => { + let _ = self + .send_text(peer_id, Self::expired_download_message(language)) + .await; + } + Some(path) => { + let file_name = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let _ = self + .send_text(peer_id, &Self::sending_file_message(language, &file_name)) + .await; + let root = workspace_root.as_deref().map(std::path::Path::new); + match self.send_workspace_file_to_peer(peer_id, &path, root).await { + Ok(()) => info!("Weixin file delivered to {peer_id}: {path}"), + Err(e) => { + warn!("Weixin file send failed: {e}"); + let _ = self + .send_text( + peer_id, + &Self::send_file_failed_message(language, &file_name, &e.to_string()), + ) + .await; + } + } + } + } + } + + async fn get_updates_once(&self, buf: &str, timeout: Duration) -> Result { + if self.is_session_paused().await { + tokio::time::sleep(Duration::from_secs(2)).await; + return Ok(json!({ + "ret": 0, + "msgs": [], + "get_updates_buf": buf + })); + } + + let body = json!({ + "get_updates_buf": buf, + "base_info": { "channel_version": CHANNEL_VERSION } + }); + let raw = self + .post_ilink("ilink/bot/getupdates", body, timeout) + .await?; + let v: Value = serde_json::from_str(&raw)?; + let ret = v["ret"].as_i64().unwrap_or(0); + let errcode = v["errcode"].as_i64().unwrap_or(0); + if errcode == SESSION_EXPIRED_ERRCODE || ret == SESSION_EXPIRED_ERRCODE { + self.pause_session().await; + } + Ok(v) + } + + async fn send_message_raw(&self, to_user_id: &str, context_token: &str, text: &str) -> Result<()> { + let client_id = format!("bitfun-wx-{}", uuid::Uuid::new_v4()); + let item_list = if text.is_empty() { + None + } else { + Some(vec![json!({ + "type": 1, + "text_item": { "text": text } + })]) + }; + let msg = json!({ + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": client_id, + "message_type": 2, + "message_state": 2, + "item_list": item_list, + "context_token": context_token, + }); + let body = json!({ + "msg": msg, + "base_info": { "channel_version": CHANNEL_VERSION } + }); + self.post_ilink( + "ilink/bot/sendmessage", + body, + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await?; + Ok(()) + } + + /// Send text to peer; uses last known `context_token` for that peer. + pub async fn send_text(&self, peer_id: &str, text: &str) -> Result<()> { + let token = { + let m = self.context_tokens.read().await; + m.get(peer_id) + .cloned() + .ok_or_else(|| anyhow!("missing context_token for peer {peer_id}"))? + }; + for chunk in chunk_text_for_weixin(text) { + self.send_message_raw(peer_id, &token, &chunk).await?; + } + Ok(()) + } + + fn is_weixin_media_item_type(type_id: i64) -> bool { + matches!(type_id, 2 | 3 | 4 | 5) + } + + fn body_from_item_list(items: &[Value]) -> String { + for item in items { + let t = item["type"].as_i64().unwrap_or(0); + if t == 1 { + if let Some(tx) = item["text_item"]["text"].as_str() { + let text = tx.to_string(); + let ref_msg = &item["ref_msg"]; + if !ref_msg.is_object() { + return text; + } + let ref_title = ref_msg["title"].as_str(); + let ref_item = &ref_msg["message_item"]; + if ref_item.is_object() { + let mt = ref_item["type"].as_i64().unwrap_or(0); + if Self::is_weixin_media_item_type(mt) { + return text; + } + let ref_body = Self::body_from_item_list(std::slice::from_ref(ref_item)); + if ref_title.is_none() && ref_body.is_empty() { + return text; + } + let mut parts: Vec = Vec::new(); + if let Some(tt) = ref_title { + parts.push(tt.to_string()); + } + if !ref_body.is_empty() { + parts.push(ref_body); + } + if parts.is_empty() { + return text; + } + let joined = parts.join(" | "); + return format!("[引用: {joined}]\n{text}"); + } + if let Some(tt) = ref_title { + return format!("[引用: {tt}]\n{text}"); + } + return text; + } + } + if t == 3 { + if let Some(tx) = item["voice_item"]["text"].as_str() { + return tx.to_string(); + } + } + } + String::new() + } + + fn body_from_message(msg: &Value) -> String { + let Some(items) = msg["item_list"].as_array() else { + return String::new(); + }; + Self::body_from_item_list(items) + } + + /// True if the message carries at least one `image_item` (pairing wait UX / guards). + fn has_inbound_image_items(msg: &Value) -> bool { + let Some(items) = msg["item_list"].as_array() else { + return false; + }; + items.iter().any(|i| { + i["type"].as_i64() == Some(2) + && i["image_item"]["media"]["encrypt_query_param"] + .as_str() + .is_some_and(|s| !s.is_empty()) + }) + } + + fn is_user_message(msg: &Value) -> bool { + msg["message_type"].as_i64() == Some(1) + } + + fn peer_id(msg: &Value) -> Option { + msg["from_user_id"] + .as_str() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + } + + fn context_token(msg: &Value) -> Option { + msg["context_token"] + .as_str() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + } + + pub async fn register_pairing(&self, pairing_code: &str) -> Result<()> { + self.pending_pairings.write().await.insert( + pairing_code.to_string(), + PendingPairing { + created_at: chrono::Utc::now().timestamp(), + }, + ); + Ok(()) + } + + pub async fn verify_pairing_code(&self, code: &str) -> bool { + let mut pairings = self.pending_pairings.write().await; + if let Some(p) = pairings.remove(code) { + let age = chrono::Utc::now().timestamp() - p.created_at; + return age < 300; + } + false + } + + fn format_actions_footer(language: BotLanguage, actions: &[BotAction]) -> String { + if actions.is_empty() { + return String::new(); + } + let header = if language.is_chinese() { + "\n\n——\n快捷操作(可发送对应命令或回复数字):\n" + } else { + "\n\n——\nQuick actions (send the command or reply with the number):\n" + }; + let mut s = header.to_string(); + for (i, a) in actions.iter().enumerate() { + let n = i + 1; + if language.is_chinese() { + s.push_str(&format!("{n}. {} → {}\n", a.label, a.command)); + } else { + s.push_str(&format!("{n}. {} → {}\n", a.label, a.command)); + } + } + s + } + + fn clean_reply_text(language: BotLanguage, text: &str, has_actions: bool) -> String { + let mut lines: Vec = Vec::new(); + let mut replaced_cancel = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.contains("/cancel_task ") { + if has_actions && !replaced_cancel { + let hint = if language.is_chinese() { + "如需停止本次请求,请发送命令 /cancel_task 或下方列出的取消命令。" + } else { + "To stop this request, send /cancel_task or the cancel command listed below." + }; + lines.push(hint.to_string()); + replaced_cancel = true; + } + continue; + } + lines.push(line.to_string()); + } + lines.join("\n").trim().to_string() + } + + async fn send_handle_result(&self, peer_id: &str, result: &HandleResult) { + let language = current_bot_language().await; + let footer = Self::format_actions_footer(language, &result.actions); + let body = Self::clean_reply_text(language, &result.reply, !result.actions.is_empty()); + let combined = format!("{body}{footer}"); + if let Err(e) = self.send_text(peer_id, &combined).await { + warn!("weixin send_handle_result: {e}"); + } + } + + async fn notify_files_ready(&self, peer_id: &str, text: &str) { + let result = { + let mut states = self.chat_states.write().await; + let state = states.entry(peer_id.to_string()).or_insert_with(|| { + let mut s = BotChatState::new(peer_id.to_string()); + s.paired = true; + s + }); + let workspace_root = state.current_workspace.clone(); + super::prepare_file_download_actions( + text, + state, + workspace_root.as_deref().map(std::path::Path::new), + ) + }; + if let Some(result) = result { + self.send_handle_result(peer_id, &result).await; + } + } + + async fn persist_chat_state(&self, peer_id: &str, state: &BotChatState) { + let mut data = load_bot_persistence(); + data.upsert(SavedBotConnection { + bot_type: "weixin".to_string(), + chat_id: peer_id.to_string(), + config: BotConfig::Weixin { + ilink_token: self.config.ilink_token.clone(), + base_url: self.config.base_url.clone(), + bot_account_id: self.config.bot_account_id.clone(), + }, + chat_state: state.clone(), + connected_at: chrono::Utc::now().timestamp(), + }); + save_bot_persistence(&data); + } + + /// Pairing + message loop: long-poll getupdates. + pub async fn wait_for_pairing( + &self, + stop_rx: &mut tokio::sync::watch::Receiver, + ) -> Result { + info!("Weixin bot waiting for pairing code (getupdates)..."); + let mut buf = load_sync_buf(&self.config.bot_account_id); + + loop { + if *stop_rx.borrow() { + return Err(anyhow!("bot stop requested")); + } + + let poll = tokio::select! { + _ = stop_rx.changed() => { + return Err(anyhow!("bot stop requested")); + } + r = self.get_updates_once( + &buf, + Duration::from_secs(LONG_POLL_TIMEOUT_SECS), + ) => r, + }; + + let resp = match poll { + Ok(v) => v, + Err(e) => { + error!("weixin getupdates: {e}"); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + + let ret = resp["ret"].as_i64().unwrap_or(0); + let errcode = resp["errcode"].as_i64().unwrap_or(0); + if (ret != 0 && ret != SESSION_EXPIRED_ERRCODE) || (errcode != 0 && errcode != SESSION_EXPIRED_ERRCODE) { + if errcode == SESSION_EXPIRED_ERRCODE || ret == SESSION_EXPIRED_ERRCODE { + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + warn!("weixin getupdates ret={ret} errcode={errcode}"); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + + if let Some(new_buf) = resp["get_updates_buf"].as_str() { + buf = new_buf.to_string(); + save_sync_buf(&self.config.bot_account_id, &buf); + } + + if let Some(msgs) = resp["msgs"].as_array() { + for msg in msgs { + if !Self::is_user_message(msg) { + continue; + } + let Some(peer) = Self::peer_id(msg) else { continue }; + if let Some(ct) = Self::context_token(msg) { + self.context_tokens + .write() + .await + .insert(peer.clone(), ct); + } + let text = Self::body_from_message(msg).trim().to_string(); + let language = current_bot_language().await; + + if text == "/start" { + let _ = self.send_text(&peer, welcome_message(language)).await; + continue; + } + + if text.len() == 6 && text.chars().all(|c| c.is_ascii_digit()) { + if self.verify_pairing_code(&text).await { + info!("Weixin pairing successful peer={peer}"); + let mut state = BotChatState::new(peer.clone()); + let result = complete_im_bot_pairing(&mut state).await; + self.chat_states + .write() + .await + .insert(peer.clone(), state.clone()); + self.persist_chat_state(&peer, &state).await; + + let footer = + Self::format_actions_footer(language, &result.actions); + let _ = self + .send_text(&peer, &format!("{}{}", result.reply, footer)) + .await; + return Ok(peer); + } else { + let err = if language.is_chinese() { + "配对码无效或已过期,请重试。" + } else { + "Invalid or expired pairing code." + }; + let _ = self.send_text(&peer, err).await; + } + } else if !text.is_empty() { + let err = if language.is_chinese() { + "请输入 BitFun 桌面端远程连接中显示的 6 位配对码。" + } else { + "Please send the 6-digit pairing code from BitFun Desktop Remote Connect." + }; + let _ = self.send_text(&peer, err).await; + } else if Self::has_inbound_image_items(msg) { + let err = if language.is_chinese() { + "配对请直接发送 6 位数字配对码;完成配对后再发送图片与助手对话。" + } else { + "To pair, send the 6-digit code only. After pairing you can send images to chat." + }; + let _ = self.send_text(&peer, err).await; + } + } + } + } + } + + pub async fn run_message_loop(self: Arc, stop_rx: tokio::sync::watch::Receiver) { + info!("Weixin message loop started"); + let mut stop = stop_rx; + let mut buf = load_sync_buf(&self.config.bot_account_id); + + loop { + if *stop.borrow() { + break; + } + + let poll = tokio::select! { + _ = stop.changed() => break, + r = self.get_updates_once( + &buf, + Duration::from_secs(LONG_POLL_TIMEOUT_SECS), + ) => r, + }; + + let resp = match poll { + Ok(v) => v, + Err(e) => { + error!("weixin getupdates (loop): {e}"); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + + let ret = resp["ret"].as_i64().unwrap_or(0); + let errcode = resp["errcode"].as_i64().unwrap_or(0); + if (ret != 0 && ret != SESSION_EXPIRED_ERRCODE) || (errcode != 0 && errcode != SESSION_EXPIRED_ERRCODE) { + if errcode == SESSION_EXPIRED_ERRCODE || ret == SESSION_EXPIRED_ERRCODE { + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + + if let Some(new_buf) = resp["get_updates_buf"].as_str() { + buf = new_buf.to_string(); + save_sync_buf(&self.config.bot_account_id, &buf); + } + + let Some(msgs) = resp["msgs"].as_array() else { continue }; + + for msg in msgs { + if !Self::is_user_message(msg) { + continue; + } + let Some(peer) = Self::peer_id(msg) else { continue }; + if let Some(ct) = Self::context_token(msg) { + self.context_tokens + .write() + .await + .insert(peer.clone(), ct); + } + let msg_value = msg.clone(); + let bot = self.clone(); + tokio::spawn(async move { + let (images, skipped_images) = + bot.inbound_image_attachments_from_message(&msg_value).await; + let language = current_bot_language().await; + // Match Feishu: truncation is a separate user-visible message, not mixed into command text. + if skipped_images > 0 { + let note = if language.is_chinese() { + format!( + "仅会处理前 {} 张图片,其余 {} 张已丢弃。", + MAX_INBOUND_IMAGES, skipped_images + ) + } else { + format!( + "Only the first {} images will be processed; the remaining {} were discarded.", + MAX_INBOUND_IMAGES, skipped_images + ) + }; + let _ = bot.send_text(&peer, ¬e).await; + } + let body = WeixinBot::body_from_message(&msg_value); + let text = if body.trim().is_empty() && !images.is_empty() { + if language.is_chinese() { + "[用户发送了一张图片]".to_string() + } else { + "[User sent an image]".to_string() + } + } else { + body + }; + bot.handle_incoming_message(peer, &text, images).await; + }); + } + } + info!("Weixin message loop stopped"); + } + + async fn handle_incoming_message( + self: &Arc, + peer_id: String, + text: &str, + images: Vec, + ) { + let mut states = self.chat_states.write().await; + let state = states.entry(peer_id.clone()).or_insert_with(|| { + let mut s = BotChatState::new(peer_id.clone()); + s.paired = true; + s + }); + let language = current_bot_language().await; + + if !state.paired { + let trimmed = text.trim(); + if trimmed == "/start" { + drop(states); + let _ = self.send_text(&peer_id, welcome_message(language)).await; + return; + } + if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { + if self.verify_pairing_code(trimmed).await { + let result = complete_im_bot_pairing(state).await; + self.persist_chat_state(&peer_id, state).await; + drop(states); + let footer = + Self::format_actions_footer(language, &result.actions); + let _ = self + .send_text(&peer_id, &format!("{}{}", result.reply, footer)) + .await; + return; + } else { + let err = if language.is_chinese() { + "配对码无效或已过期。" + } else { + "Invalid or expired pairing code." + }; + drop(states); + let _ = self.send_text(&peer_id, err).await; + return; + } + } + drop(states); + let err = if language.is_chinese() { + "请输入 6 位配对码。" + } else { + "Please send the 6-digit pairing code." + }; + let _ = self.send_text(&peer_id, err).await; + return; + } + + let trimmed = text.trim(); + if trimmed.starts_with("download_file:") { + let token = trimmed["download_file:".len()..].trim().to_string(); + let workspace_root = state.current_workspace.clone(); + drop(states); + self.handle_download_request(&peer_id, &token, workspace_root) + .await; + return; + } + + let cmd = parse_command(text); + let result = handle_command(state, cmd, images).await; + self.persist_chat_state(&peer_id, state).await; + drop(states); + + self.send_handle_result(&peer_id, &result).await; + + if let Some(forward) = result.forward_to_session { + let bot = self.clone(); + let peer = peer_id.clone(); + tokio::spawn(async move { + let interaction_bot = bot.clone(); + let peer_c = peer.clone(); + let handler: BotInteractionHandler = Arc::new(move |interaction: BotInteractiveRequest| { + let interaction_bot = interaction_bot.clone(); + let peer_i = peer_c.clone(); + Box::pin(async move { + interaction_bot + .deliver_interaction(peer_i, interaction) + .await; + }) + }); + let msg_bot = bot.clone(); + let peer_m = peer.clone(); + let sender: BotMessageSender = Arc::new(move |t: String| { + let msg_bot = msg_bot.clone(); + let peer_s = peer_m.clone(); + Box::pin(async move { + let _ = msg_bot.send_text(&peer_s, &t).await; + }) + }); + let verbose_mode = load_bot_persistence().verbose_mode; + let turn_result = + execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode).await; + let _ = bot.send_text(&peer, &turn_result.display_text).await; + bot.notify_files_ready(&peer, &turn_result.full_text).await; + }); + } + } + + async fn deliver_interaction(&self, peer_id: String, interaction: BotInteractiveRequest) { + let mut states = self.chat_states.write().await; + let state = states.entry(peer_id.clone()).or_insert_with(|| { + let mut s = BotChatState::new(peer_id.clone()); + s.paired = true; + s + }); + state.pending_action = Some(interaction.pending_action.clone()); + self.persist_chat_state(&peer_id, state).await; + drop(states); + + let result = HandleResult { + reply: interaction.reply, + actions: interaction.actions, + forward_to_session: None, + }; + self.send_handle_result(&peer_id, &result).await; + } +} + +fn chunk_text_for_weixin(text: &str) -> Vec { + if text.len() <= MAX_TEXT_CHUNK { + return vec![text.to_string()]; + } + let mut out = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + if rest.len() <= MAX_TEXT_CHUNK { + out.push(rest.to_string()); + break; + } + let mut cut = MAX_TEXT_CHUNK; + while cut > 0 && !rest.is_char_boundary(cut) { + cut -= 1; + } + if cut == 0 { + cut = rest.chars().next().map(|c| c.len_utf8()).unwrap_or(1); + } + out.push(rest[..cut].to_string()); + rest = &rest[cut..]; + } + out +} + +#[cfg(test)] +mod weixin_inbound_tests { + use super::*; + use serde_json::json; + + #[test] + fn aes_ecb_roundtrip() { + let key = [9u8; 16]; + let plain = b"hello weixin cdn"; + let ct = encrypt_aes_128_ecb_pkcs7(plain, &key); + let back = decrypt_aes_128_ecb_pkcs7(&ct, &key).unwrap(); + assert_eq!(back.as_slice(), plain.as_slice()); + } + + #[test] + fn parse_aes_key_raw16_base64() { + let raw = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + let b64 = B64.encode(raw); + let k = parse_weixin_cdn_aes_key(&b64).unwrap(); + assert_eq!(k, raw); + } + + #[test] + fn parse_aes_key_hex_wrapped_base64() { + let raw = [0xabu8; 16]; + let hex_str = hex::encode(raw); + let b64 = B64.encode(hex_str.as_bytes()); + let k = parse_weixin_cdn_aes_key(&b64).unwrap(); + assert_eq!(k, raw); + } + + #[test] + fn body_from_message_plain_text() { + let msg = json!({ + "item_list": [{ "type": 1, "text_item": { "text": "hi" } }] + }); + assert_eq!(WeixinBot::body_from_message(&msg), "hi"); + } + + #[test] + fn body_from_message_quoted_text() { + let msg = json!({ + "item_list": [{ + "type": 1, + "text_item": { "text": "reply" }, + "ref_msg": { "title": " earlier ", "message_item": { "type": 1, "text_item": { "text": "orig" } } } + }] + }); + let b = WeixinBot::body_from_message(&msg); + assert!(b.contains("[引用:")); + assert!(b.contains("reply")); + } +} diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index 09083b22..2af6d168 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -3,7 +3,7 @@ //! Provides phone-to-desktop remote connection capabilities with E2E encryption. //! Supports multiple connection methods: LAN, ngrok, relay server, and bots. //! -//! Bot connections (Telegram / Feishu) run independently of relay connections +//! Bot connections (Telegram / Feishu / Weixin) run independently of relay connections //! (LAN / ngrok / BitFun Server / Custom Server). Calling `stop()` only //! tears down the relay side; bots keep running. Use `stop_bot()` or //! `stop_all()` to shut everything down. @@ -42,6 +42,7 @@ pub enum ConnectionMethod { CustomServer { url: String }, BotFeishu, BotTelegram, + BotWeixin, } /// Configuration for Remote Connect. @@ -53,6 +54,7 @@ pub struct RemoteConnectConfig { pub custom_server_url: Option, pub bot_feishu: Option, pub bot_telegram: Option, + pub bot_weixin: Option, pub mobile_web_dir: Option, } @@ -65,6 +67,7 @@ impl Default for RemoteConnectConfig { custom_server_url: None, bot_feishu: None, bot_telegram: None, + bot_weixin: None, mobile_web_dir: None, } } @@ -82,7 +85,7 @@ pub struct ConnectionResult { pub pairing_state: PairingState, } -/// Handle to a running bot (Telegram or Feishu). +/// Handle to a running bot (Telegram, Feishu, or Weixin). struct BotHandle { stop_tx: tokio::sync::watch::Sender, } @@ -112,9 +115,11 @@ pub struct RemoteConnectService { // Bot handles live independently of relay connections bot_telegram_handle: Arc>>, bot_feishu_handle: Arc>>, + bot_weixin_handle: Arc>>, // Keep Arc references to bots for send_message etc. telegram_bot: Arc>>>, feishu_bot: Arc>>>, + weixin_bot: Arc>>>, /// Independent bot connection state — not tied to PairingProtocol. /// Stores the peer description (e.g. "Telegram(7096812005)") when a bot is active. bot_connected_info: Arc>>, @@ -138,8 +143,10 @@ impl RemoteConnectService { embedded_relay: Arc::new(RwLock::new(None)), bot_telegram_handle: Arc::new(RwLock::new(None)), bot_feishu_handle: Arc::new(RwLock::new(None)), + bot_weixin_handle: Arc::new(RwLock::new(None)), telegram_bot: Arc::new(RwLock::new(None)), feishu_bot: Arc::new(RwLock::new(None)), + weixin_bot: Arc::new(RwLock::new(None)), bot_connected_info: Arc::new(RwLock::new(None)), trusted_mobile_identity: Arc::new(RwLock::new(None)), }) @@ -220,6 +227,17 @@ impl RemoteConnectService { bot::BotConfig::Telegram { bot_token } => { self.config.bot_telegram = Some(bot::BotConfig::Telegram { bot_token }); } + bot::BotConfig::Weixin { + ilink_token, + base_url, + bot_account_id, + } => { + self.config.bot_weixin = Some(bot::BotConfig::Weixin { + ilink_token, + base_url, + bot_account_id, + }); + } } } @@ -233,6 +251,7 @@ impl RemoteConnectService { }, ConnectionMethod::BotFeishu, ConnectionMethod::BotTelegram, + ConnectionMethod::BotWeixin, ] } @@ -246,7 +265,9 @@ impl RemoteConnectService { info!("Starting remote connect: {method:?}"); match &method { - ConnectionMethod::BotFeishu | ConnectionMethod::BotTelegram => { + ConnectionMethod::BotFeishu + | ConnectionMethod::BotTelegram + | ConnectionMethod::BotWeixin => { return self.start_bot_connection(&method).await; } _ => {} @@ -717,7 +738,74 @@ impl RemoteConnectService { } } } - _ => String::new(), + ConnectionMethod::BotWeixin => { + match &self.config.bot_weixin { + Some(bot::BotConfig::Weixin { + ilink_token, + base_url, + bot_account_id, + }) if !ilink_token.is_empty() && !bot_account_id.is_empty() => { + if let Some(handle) = self.bot_weixin_handle.write().await.take() { + handle.stop(); + } + + let wx_cfg = bot::weixin::WeixinConfig { + ilink_token: ilink_token.clone(), + base_url: if base_url.trim().is_empty() { + "https://ilinkai.weixin.qq.com".to_string() + } else { + base_url.clone() + }, + bot_account_id: bot_account_id.clone(), + }; + + let wx_bot = Arc::new(bot::weixin::WeixinBot::new(wx_cfg)); + wx_bot.register_pairing(&pairing_code).await?; + + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + + let bot_connected_info = self.bot_connected_info.clone(); + let bot_for_pair = wx_bot.clone(); + let bot_for_loop = wx_bot.clone(); + let wx_bot_ref = self.weixin_bot.clone(); + + *wx_bot_ref.write().await = Some(wx_bot.clone()); + + tokio::spawn(async move { + let mut stop_rx = stop_rx; + match bot_for_pair.wait_for_pairing(&mut stop_rx).await { + Ok(peer_id) => { + if !*stop_rx.borrow() { + *bot_connected_info.write().await = + Some(format!("Weixin({peer_id})")); + info!("Weixin bot paired, starting message loop"); + bot_for_loop.run_message_loop(stop_rx).await; + } else { + info!("Weixin pairing completed but bot was stopped; discarding"); + } + } + Err(e) => { + info!("Weixin pairing ended: {e}"); + } + } + }); + + *self.bot_weixin_handle.write().await = Some(BotHandle { stop_tx }); + + "https://www.wechat.com".to_string() + } + _ => { + return Err(anyhow::anyhow!( + "Weixin not linked. Complete WeChat QR login in Remote Connect first." + )); + } + } + } + _ => { + return Err(anyhow::anyhow!( + "start_bot_connection: unsupported method {method:?}" + )); + } }; Ok(ConnectionResult { @@ -798,6 +886,45 @@ impl RemoteConnectService { *self.bot_feishu_handle.write().await = Some(BotHandle { stop_tx }); info!("Feishu bot restored for chat_id={}", saved.chat_id); } + bot::BotConfig::Weixin { + ref ilink_token, + ref base_url, + ref bot_account_id, + } => { + if let Some(handle) = self.bot_weixin_handle.write().await.take() { + handle.stop(); + } + + let wx_cfg = bot::weixin::WeixinConfig { + ilink_token: ilink_token.clone(), + base_url: if base_url.trim().is_empty() { + "https://ilinkai.weixin.qq.com".to_string() + } else { + base_url.clone() + }, + bot_account_id: bot_account_id.clone(), + }; + + let wx_bot = Arc::new(bot::weixin::WeixinBot::new(wx_cfg)); + wx_bot + .restore_chat_state(&saved.chat_id, saved.chat_state.clone()) + .await; + + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + *self.weixin_bot.write().await = Some(wx_bot.clone()); + + let cid = saved.chat_id.clone(); + *self.bot_connected_info.write().await = Some(format!("Weixin({cid})")); + + let bot_for_loop = wx_bot.clone(); + tokio::spawn(async move { + info!("Weixin bot restored from persistence, starting message loop"); + bot_for_loop.run_message_loop(stop_rx).await; + }); + + *self.bot_weixin_handle.write().await = Some(BotHandle { stop_tx }); + info!("Weixin bot restored for chat_id={}", saved.chat_id); + } } Ok(()) } @@ -842,6 +969,11 @@ impl RemoteConnectService { handle.stop(); } *self.feishu_bot.write().await = None; + + if let Some(handle) = self.bot_weixin_handle.write().await.take() { + handle.stop(); + } + *self.weixin_bot.write().await = None; *self.bot_connected_info.write().await = None; info!("Bot connections stopped"); @@ -880,6 +1012,7 @@ impl RemoteConnectService { match bot_type { "telegram" => self.bot_telegram_handle.read().await.is_some(), "feishu" => self.bot_feishu_handle.read().await.is_some(), + "weixin" => self.bot_weixin_handle.read().await.is_some(), _ => false, } } diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index ec2a8e8e..a6e30153 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -125,6 +125,7 @@ $_section-header-height: 24px; &__mode-switch { width: 100%; + min-width: 0; min-height: 56px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--color-primary) 16%, transparent); @@ -234,17 +235,23 @@ $_section-header-height: 24px; display: inline-flex; align-items: center; gap: 6px; + min-width: 0; + max-width: 100%; font-size: 11px; font-weight: 600; line-height: 1.15; color: var(--color-text-primary); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &__mode-switch-sub { display: grid; grid-template-areas: 'sub'; align-items: start; + min-width: 0; + max-width: 100%; } &__mode-switch-desc { @@ -252,6 +259,8 @@ $_section-header-height: 24px; display: flex; flex-direction: column; gap: 2px; + min-width: 0; + max-width: 100%; opacity: 1; transition: opacity $motion-fast $easing-standard; @@ -261,22 +270,29 @@ $_section-header-height: 24px; } &__mode-switch-desc-main { + min-width: 0; + max-width: 100%; font-size: 10px; line-height: 1.35; color: var(--color-text-muted); opacity: 0.78; + overflow-wrap: break-word; } &__mode-switch-hint { grid-area: sub; align-self: center; justify-self: start; + min-width: 0; + max-width: 100%; font-size: 10px; line-height: 1.2; color: var(--color-text-muted); opacity: 0; font-weight: 400; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; transition: opacity $motion-fast $easing-standard; .bitfun-nav-panel__mode-switch:hover & { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 84a8bd99..91a8fcdb 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -496,3 +496,30 @@ } } +.bitfun-remote-connect__weixin-qr { + margin-top: 12px; + text-align: center; +} + +.bitfun-remote-connect__weixin-qr-img { + display: block; + max-width: 220px; + height: auto; + margin: 0 auto 8px; + border-radius: 8px; +} + +.bitfun-remote-connect__weixin-qr-svg-wrap { + display: inline-block; + margin: 0 auto 8px; + padding: 10px; + background: #fff; + border-radius: 8px; + box-sizing: border-box; + + svg { + display: block; + vertical-align: top; + } +} + diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 36323e0d..e3c1b53d 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -1,7 +1,7 @@ /** * Remote Connect dialog with two independent groups: * - Network (LAN / Ngrok / BitFun Server / Custom Server) – mutually exclusive - * - SMS Bot (Telegram / Feishu) – mutually exclusive + * - IM Bot (Telegram / Feishu / WeChat) – mutually exclusive * Both groups can be active simultaneously. */ @@ -26,7 +26,24 @@ import './RemoteConnectDialog.scss'; type ActiveGroup = 'network' | 'bot'; type NetworkTab = 'lan' | 'ngrok' | 'bitfun_server' | 'custom_server'; -type BotTab = 'telegram' | 'feishu'; +type BotTab = 'telegram' | 'feishu' | 'weixin'; + +/** + * iLink `qrcode_img_content` is the string to encode in a QR (OpenClaw passes it to + * `qrcode-terminal.generate`), not necessarily an `` raster URL. Only treat + * as raster when it is clearly a data-URL or direct image link. + */ +function isWeixinRasterQrSrc(raw: string): boolean { + const t = raw.trim(); + if (/^data:image\//i.test(t)) return true; + if ( + /^https?:\/\//i.test(t) + && /\.(png|jpe?g|gif|webp|svg)(\?|#|$)/i.test(t) + ) { + return true; + } + return false; +} const NETWORK_TABS: { id: NetworkTab; labelKey: string }[] = [ { id: 'lan', labelKey: 'remoteConnect.tabLan' }, @@ -38,6 +55,7 @@ const NETWORK_TABS: { id: NetworkTab; labelKey: string }[] = [ const BOT_TABS: { id: BotTab; label: string }[] = [ { id: 'telegram', label: 'Telegram' }, { id: 'feishu', label: '' }, // filled from i18n + { id: 'weixin', label: '' }, ]; const NGROK_SETUP_URL = 'https://dashboard.ngrok.com/get-started/setup'; @@ -60,6 +78,7 @@ const botInfoToBotTab = (info: string | null | undefined): BotTab | null => { if (!info) return null; if (info.startsWith('Telegram')) return 'telegram'; if (info.startsWith('Feishu')) return 'feishu'; + if (info.startsWith('Weixin')) return 'weixin'; return null; }; @@ -94,6 +113,19 @@ export const RemoteConnectDialog: React.FC = ({ const [tgToken, setTgToken] = useState(''); const [feishuAppId, setFeishuAppId] = useState(''); const [feishuAppSecret, setFeishuAppSecret] = useState(''); + const [weixinIlinkToken, setWeixinIlinkToken] = useState(''); + const [weixinBaseUrl, setWeixinBaseUrl] = useState(''); + const [weixinBotAccountId, setWeixinBotAccountId] = useState(''); + const [weixinQrSessionKey, setWeixinQrSessionKey] = useState(null); + const [weixinQrImageUrl, setWeixinQrImageUrl] = useState(null); + const [weixinAwaitingPhoneConfirm, setWeixinAwaitingPhoneConfirm] = useState(false); + + const formSnapshotRef = useRef({ + customUrl: '', + tgToken: '', + feishuAppId: '', + feishuAppSecret: '', + }); const pollRef = useRef | null>(null); const pollTargetRef = useRef<'relay' | 'bot'>('relay'); @@ -204,6 +236,9 @@ export const RemoteConnectDialog: React.FC = ({ setTgToken(formState.telegram_bot_token ?? ''); setFeishuAppId(formState.feishu_app_id ?? ''); setFeishuAppSecret(formState.feishu_app_secret ?? ''); + setWeixinIlinkToken(formState.weixin_ilink_token ?? ''); + setWeixinBaseUrl(formState.weixin_base_url ?? ''); + setWeixinBotAccountId(formState.weixin_bot_account_id ?? ''); } catch { // Ignore form-state restore failures and keep in-memory defaults. } @@ -214,6 +249,116 @@ export const RemoteConnectDialog: React.FC = ({ }; }, [isOpen]); + useEffect(() => { + formSnapshotRef.current = { + customUrl, + tgToken, + feishuAppId, + feishuAppSecret, + }; + }, [customUrl, tgToken, feishuAppId, feishuAppSecret]); + + const prepareAndStartWeixinBotFromQr = useCallback(async ( + ilinkToken: string, + baseUrl: string, + botAccountId: string, + ): Promise => { + const fs = formSnapshotRef.current; + await remoteConnectAPI.setFormState({ + custom_server_url: fs.customUrl, + telegram_bot_token: fs.tgToken, + feishu_app_id: fs.feishuAppId, + feishu_app_secret: fs.feishuAppSecret, + weixin_ilink_token: ilinkToken, + weixin_base_url: baseUrl || undefined, + weixin_bot_account_id: botAccountId, + }); + await remoteConnectAPI.configureBot({ + botType: 'weixin', + weixinIlinkToken: ilinkToken, + weixinBaseUrl: baseUrl || undefined, + weixinBotAccountId: botAccountId, + }); + return await remoteConnectAPI.startConnection('bot_weixin'); + }, []); + + // WeChat QR login: poll iLink until confirmed or error (session key cleared on completion). + useEffect(() => { + const key = weixinQrSessionKey; + if (!key) return; + let cancelled = false; + void (async () => { + while (!cancelled) { + try { + const p = await remoteConnectAPI.weixinQrPoll(key); + if (cancelled) return; + if (p.status === 'scanned') { + setWeixinQrImageUrl(null); + setWeixinAwaitingPhoneConfirm(true); + continue; + } + if (p.status === 'confirmed' && p.ilink_token && p.bot_account_id) { + const token = p.ilink_token; + const base = p.base_url ?? ''; + const bid = p.bot_account_id; + setWeixinAwaitingPhoneConfirm(false); + setWeixinIlinkToken(token); + setWeixinBaseUrl(base); + setWeixinBotAccountId(bid); + // Hide QR immediately, but keep `weixinQrSessionKey` until the pipeline finishes. + // Clearing the session key first re-runs this effect's cleanup and sets `cancelled`, + // so after `await` we would skip `setConnectionResult` and never `setLoading(false)`. + setWeixinQrImageUrl(null); + setConnectionResult(null); + setError(null); + setLoading(true); + try { + const result = await prepareAndStartWeixinBotFromQr(token, base, bid); + if (!cancelled) { + setConnectionResult(result); + startPolling('bot'); + } + } catch (e: unknown) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + if (!cancelled) { + setWeixinQrSessionKey(null); + } + return; + } + if (p.status === 'error') { + setError(p.message); + setWeixinQrSessionKey(null); + setWeixinQrImageUrl(null); + setWeixinAwaitingPhoneConfirm(false); + return; + } + if (p.status === 'expired' && p.qr_image_url) { + setWeixinQrImageUrl(p.qr_image_url); + setWeixinAwaitingPhoneConfirm(false); + } + } catch (e: unknown) { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + } + setWeixinQrSessionKey(null); + setWeixinQrImageUrl(null); + setWeixinAwaitingPhoneConfirm(false); + return; + } + } + })(); + return () => { + cancelled = true; + }; + }, [weixinQrSessionKey, prepareAndStartWeixinBotFromQr, startPolling]); + // ── Connection handlers ────────────────────────────────────────── const handleConnect = useCallback(async () => { @@ -227,19 +372,35 @@ export const RemoteConnectDialog: React.FC = ({ telegram_bot_token: tgToken, feishu_app_id: feishuAppId, feishu_app_secret: feishuAppSecret, + weixin_ilink_token: weixinIlinkToken, + weixin_base_url: weixinBaseUrl, + weixin_bot_account_id: weixinBotAccountId, }); let method: string; let serverUrl: string | undefined; if (activeGroup === 'bot') { - method = botTab === 'telegram' ? 'bot_telegram' : 'bot_feishu'; + if (botTab === 'telegram') { + method = 'bot_telegram'; + } else if (botTab === 'feishu') { + method = 'bot_feishu'; + } else { + method = 'bot_weixin'; + } if (botTab === 'telegram' && tgToken) { await remoteConnectAPI.configureBot({ botType: 'telegram', botToken: tgToken }); } else if (botTab === 'feishu' && feishuAppId) { await remoteConnectAPI.configureBot({ botType: 'feishu', appId: feishuAppId, appSecret: feishuAppSecret, }); + } else if (botTab === 'weixin' && weixinIlinkToken && weixinBotAccountId) { + await remoteConnectAPI.configureBot({ + botType: 'weixin', + weixinIlinkToken: weixinIlinkToken, + weixinBaseUrl: weixinBaseUrl || undefined, + weixinBotAccountId: weixinBotAccountId, + }); } } else { method = networkTab; @@ -253,7 +414,28 @@ export const RemoteConnectDialog: React.FC = ({ } finally { setLoading(false); } - }, [activeGroup, networkTab, botTab, customUrl, tgToken, feishuAppId, feishuAppSecret, startPolling]); + }, [activeGroup, networkTab, botTab, customUrl, tgToken, feishuAppId, feishuAppSecret, weixinIlinkToken, weixinBaseUrl, weixinBotAccountId, startPolling]); + + const handleStartWeixinQr = useCallback(async () => { + setError(null); + setWeixinAwaitingPhoneConfirm(false); + setLoading(true); + try { + const r = await remoteConnectAPI.weixinQrStart(null); + setWeixinQrSessionKey(r.session_key); + setWeixinQrImageUrl(r.qr_image_url); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + const handleCancelWeixinQr = useCallback(() => { + setWeixinQrSessionKey(null); + setWeixinQrImageUrl(null); + setWeixinAwaitingPhoneConfirm(false); + }, []); const handleDisconnectRelay = useCallback(async () => { try { @@ -569,7 +751,7 @@ export const RemoteConnectDialog: React.FC = ({ onChange={(e) => setTgToken(e.target.value)} /> - ) : ( + ) : botTab === 'feishu' ? (
{renderInfoCard( <> @@ -622,13 +804,85 @@ export const RemoteConnectDialog: React.FC = ({ onChange={(e) => setFeishuAppSecret(e.target.value)} />
+ ) : ( +
+ {renderInfoCard( +
+

{t('remoteConnect.botWeixinIntro')}

+

1. {t('remoteConnect.botWeixinStep1')}

+

2. {t('remoteConnect.botWeixinStep2')}

+
, + )} + {weixinQrImageUrl && ( +
+ {isWeixinRasterQrSrc(weixinQrImageUrl) ? ( + WeChat QR + ) : ( +
+ +
+ )} +

{t('remoteConnect.botWeixinPolling')}

+ +
+ )} + {weixinQrSessionKey && !weixinQrImageUrl && weixinAwaitingPhoneConfirm && ( +
+

{t('remoteConnect.botWeixinAwaitingPhoneConfirm')}

+ +
+ )} + {!weixinQrSessionKey && !weixinQrImageUrl && ( + + )} + {weixinIlinkToken && weixinBotAccountId && !weixinQrSessionKey && ( +

{t('remoteConnect.botWeixinLinked')}

+ )} +
)} {renderErrorBlock()} @@ -721,7 +975,7 @@ export const RemoteConnectDialog: React.FC = ({ onClick={() => { setBotTab(tab.id); setConnectionResult(null); setError(null); }} disabled={isBotSubDisabled(tab.id) || isBotConnecting} > - {tab.id === 'feishu' ? t('remoteConnect.feishu') : tab.label} + {tab.id === 'feishu' ? t('remoteConnect.feishu') : tab.id === 'weixin' ? t('remoteConnect.weixin') : tab.label} {isBotConnected && connectedBotTab === tab.id && botTab !== tab.id && ( )} diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index d20f0de6..b30d27a9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -50,6 +50,31 @@ export interface RemoteConnectFormState { telegram_bot_token: string; feishu_app_id: string; feishu_app_secret: string; + weixin_ilink_token?: string; + weixin_base_url?: string; + weixin_bot_account_id?: string; +} + +export interface WeixinQrStartResponse { + session_key: string; + qr_image_url: string; + message: string; +} + +export type WeixinQrPollStatus = + | 'wait' + | 'scanned' + | 'confirmed' + | 'expired' + | 'error'; + +export interface WeixinQrPollResponse { + status: WeixinQrPollStatus; + message: string; + qr_image_url: string | null; + ilink_token: string | null; + bot_account_id: string | null; + base_url: string | null; } class RemoteConnectAPIService { @@ -161,6 +186,9 @@ class RemoteConnectAPIService { appId?: string; appSecret?: string; botToken?: string; + weixinIlinkToken?: string; + weixinBaseUrl?: string; + weixinBotAccountId?: string; }): Promise { try { await this.adapter.request('remote_connect_configure_bot', { @@ -169,6 +197,9 @@ class RemoteConnectAPIService { app_id: params.appId ?? null, app_secret: params.appSecret ?? null, bot_token: params.botToken ?? null, + weixin_ilink_token: params.weixinIlinkToken ?? null, + weixin_base_url: params.weixinBaseUrl ?? null, + weixin_bot_account_id: params.weixinBotAccountId ?? null, }, }); } catch (e) { @@ -177,6 +208,18 @@ class RemoteConnectAPIService { } } + async weixinQrStart(baseUrl?: string | null): Promise { + return await this.adapter.request('remote_connect_weixin_qr_start', { + request: { base_url: baseUrl ?? null }, + }); + } + + async weixinQrPoll(sessionKey: string, baseUrl?: string | null): Promise { + return await this.adapter.request('remote_connect_weixin_qr_poll', { + request: { session_key: sessionKey, base_url: baseUrl ?? null }, + }); + } + async getBotVerboseMode(): Promise { try { return await this.adapter.request('remote_connect_get_bot_verbose_mode'); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 9c9e9de3..6873b948 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -349,9 +349,9 @@ "tabBitfunServer": "BitFun Server", "tabNgrok": "NAT Traversal", "tabCustomServer": "Self-Hosted", - "tabBot": "SMS Bot", + "tabBot": "IM Bot", "groupNetwork": "Network Relay", - "groupBot": "SMS Bot", + "groupBot": "IM Bot", "connect": "Connect", "connecting": "Connecting...", "disconnect": "Disconnect", @@ -384,7 +384,7 @@ "desc_custom_server_prefix": "Connect via your own ", "desc_custom_server_link": "self-hosted relay server", "desc_custom_server_suffix": ".", - "desc_bot": "Connect via Feishu or Telegram bot. Configure your bot credentials below.", + "desc_bot": "Connect via Feishu, Telegram, or WeChat (iLink) bot. Complete the setup for your chosen channel below.", "botTgStep1": "Open Telegram and search for @BotFather", "botTgStep2": "Send /newbot and follow the prompts to create a bot", "botTgStep3": "Copy the Bot Token and paste it below", @@ -396,6 +396,15 @@ "botFeishuStep1Suffix": " and create a Custom App", "botFeishuStep2": "Enable Bot capability and configure Permissions & Scopes and Events & Callbacks", "botFeishuStep3": "Copy App ID and App Secret below", + "weixin": "WeChat", + "botWeixinIntro": "Uses Tencent WeChat iLink. Sign in with WeChat QR first, then send the 6-digit pairing code like other IM bots.", + "botWeixinStep1": "Tap the button below to show a QR code, scan it with WeChat, and confirm on your phone.", + "botWeixinStep2": "After you confirm login in WeChat, BitFun will show the 6-digit pairing code automatically; open the bot chat in WeChat and send that code.", + "botWeixinQrButton": "Show WeChat login QR", + "botWeixinQrCancel": "Cancel QR login", + "botWeixinAwaitingPhoneConfirm": "Scanned. Please confirm login in WeChat; the pairing code will appear here automatically.", + "botWeixinLinked": "WeChat is linked. If pairing does not start automatically, tap Connect to retry.", + "botWeixinPolling": "Waiting for scan…", "botVerboseMode": "Verbose Mode", "botConciseMode": "Concise Mode", "openNgrokSetup": "Open ngrok setup page", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 39cf5adb..d35141f5 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -349,9 +349,9 @@ "tabBitfunServer": "BitFun服务器", "tabNgrok": "内网穿透", "tabCustomServer": "自建服务器", - "tabBot": "SMS机器人", + "tabBot": "IM机器人", "groupNetwork": "网络中继", - "groupBot": "SMS机器人", + "groupBot": "IM机器人", "connect": "连接", "connecting": "连接中...", "disconnect": "断开连接", @@ -384,7 +384,7 @@ "desc_custom_server_prefix": "通过", "desc_custom_server_link": "自建中继服务器连接", "desc_custom_server_suffix": "。", - "desc_bot": "通过飞书或Telegram机器人连接,请先配置机器人凭证。", + "desc_bot": "通过飞书、Telegram 或微信机器人连接,请先完成对应配置。", "botTgStep1": "打开Telegram,搜索 @BotFather", "botTgStep2": "发送 /newbot,按提示创建机器人", "botTgStep3": "复制Bot Token,粘贴到下方", @@ -396,6 +396,15 @@ "botFeishuStep1Suffix": ",创建企业自建应用", "botFeishuStep2": "启用机器人能力,配置权限管理和事件与回调", "botFeishuStep3": "将App ID和App Secret填入下方", + "weixin": "微信", + "botWeixinIntro": "使用腾讯微信 iLink 通道连接。需先用微信扫码登录,再与飞书/Telegram 一样发送 6 位配对码。", + "botWeixinStep1": "点击下方按钮显示二维码,用微信扫码并在手机上确认登录。", + "botWeixinStep2": "在手机微信中确认登录后,桌面端会自动显示 6 位配对码;在微信中打开与机器人的对话并发送该配对码。", + "botWeixinQrButton": "获取微信登录二维码", + "botWeixinQrCancel": "取消扫码", + "botWeixinAwaitingPhoneConfirm": "已扫码,请在微信中确认登录;确认后将自动显示配对码。", + "botWeixinLinked": "微信已登录。若未自动进入配对步骤,请点击「连接」重试。", + "botWeixinPolling": "等待扫码确认…", "botVerboseMode": "详细模式", "botConciseMode": "简洁模式", "openNgrokSetup": "打开 ngrok 安装与配置页面", diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 50f5e9d6..805dc2b2 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -24,6 +24,7 @@ import { EditorConfig as EditorConfigType } from '@/infrastructure/config/types' import { CubeLoading } from '@/component-library'; import { getMonacoLanguage } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; +import { isSamePath } from '@/shared/utils/pathUtils'; import { useI18n } from '@/infrastructure/i18n'; import { EditorBreadcrumb } from './EditorBreadcrumb'; import { EditorStatusBar } from './EditorStatusBar'; @@ -286,6 +287,7 @@ const CodeEditor: React.FC = ({ useEffect(() => { filePathRef.current = filePath; pendingModelContentRef.current = null; + lastJumpPositionRef.current = null; }, [filePath]); useEffect(() => { @@ -921,66 +923,7 @@ const CodeEditor: React.FC = ({ } }, [readOnly]); - // Handle initial jump (after content load) - useEffect(() => { - const editor = editorRef.current; - const model = modelRef.current; - - const finalRange = jumpToRange || (jumpToLine ? { start: jumpToLine, end: jumpToColumn ? jumpToLine : undefined } : undefined); - - if (!finalRange) { - return; - } - - // Avoid repeated jumps during editing - const targetColumn = 1; - const lastJump = lastJumpPositionRef.current; - if (lastJump && - lastJump.filePath === filePath && - lastJump.line === finalRange.start && - lastJump.endLine === finalRange.end) { - return; - } - - if (!editor || !model || !monacoReady) { - return; - } - - if (loading) { - return; - } - - const lineCount = model.getLineCount(); - - if (lineCount < 1) { - const timer = setTimeout(() => { - const currentLineCount = model.getLineCount(); - if (currentLineCount >= 1) { - lastJumpPositionRef.current = { - filePath, - line: finalRange.start, - column: targetColumn, - endLine: finalRange.end - }; - performJump(editor, model, finalRange.start, targetColumn, finalRange.end); - } - }, 200); - return () => clearTimeout(timer); - } - - // Record jump position to prevent repeated jumps during subsequent editing - lastJumpPositionRef.current = { - filePath, - line: finalRange.start, - column: targetColumn, - endLine: finalRange.end - }; - - performJump(editor, model, finalRange.start, targetColumn, finalRange.end); - - }, [jumpToRange, jumpToLine, jumpToColumn, monacoReady, loading, content, filePath]); - - const performJump = (editor: any, model: any, line: number, column: number, endLine?: number) => { + const performJump = useCallback((editor: any, model: any, line: number, column: number, endLine?: number) => { const lineCount = model.getLineCount(); const targetLine = Math.min(line, Math.max(1, lineCount)); const targetEndLine = endLine ? Math.min(endLine, Math.max(1, lineCount)) : undefined; @@ -1025,7 +968,102 @@ const CodeEditor: React.FC = ({ } }); }); - }; + }, []); + + // Handle initial jump (after content load). If the model has fewer lines than requested, + // wait for content to sync into the model; otherwise we clamp to line 1, set lastJump, + // and dedupe blocks a correct jump after the real text arrives. + useEffect(() => { + const editor = editorRef.current; + const model = modelRef.current; + + const finalRange = + jumpToRange || + (jumpToLine ? { start: jumpToLine, end: jumpToColumn ? jumpToLine : undefined } : undefined); + + if (!finalRange) { + return; + } + + const targetColumn = 1; + const lastJump = lastJumpPositionRef.current; + if ( + lastJump && + lastJump.filePath === filePath && + lastJump.line === finalRange.start && + lastJump.endLine === finalRange.end + ) { + return; + } + + if (!editor || !model || !monacoReady) { + return; + } + + if (loading) { + return; + } + + const maxLineNeeded = Math.max(finalRange.start, finalRange.end ?? finalRange.start); + const lineCount = model.getLineCount(); + + const applyJumpForCurrentModel = () => { + const ed = editorRef.current; + const md = modelRef.current; + if (!ed || !md) { + return; + } + lastJumpPositionRef.current = { + filePath, + line: finalRange.start, + column: targetColumn, + endLine: finalRange.end, + }; + performJump(ed, md, finalRange.start, targetColumn, finalRange.end); + }; + + if (lineCount >= maxLineNeeded) { + applyJumpForCurrentModel(); + return; + } + + let finished = false; + let timeoutId: number | null = null; + let contentDisposable: { dispose: () => void } | null = null; + + const finishOnce = () => { + if (finished) { + return; + } + finished = true; + contentDisposable?.dispose(); + contentDisposable = null; + if (timeoutId != null) { + clearTimeout(timeoutId); + timeoutId = null; + } + applyJumpForCurrentModel(); + }; + + contentDisposable = model.onDidChangeContent(() => { + const md = modelRef.current; + if (md && md.getLineCount() >= maxLineNeeded) { + finishOnce(); + } + }); + + timeoutId = window.setTimeout(finishOnce, 600); + + return () => { + finished = true; + contentDisposable?.dispose(); + contentDisposable = null; + if (timeoutId != null) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + }, [jumpToRange, jumpToLine, jumpToColumn, monacoReady, loading, content, filePath, performJump]); // Status bar popover: open and confirm const openStatusBarPopover = useCallback((type: 'position' | 'indent' | 'encoding' | 'language', e: React.MouseEvent) => { @@ -1050,7 +1088,7 @@ const CodeEditor: React.FC = ({ const editor = editorRef.current; const model = modelRef.current; if (editor && model) performJump(editor, model, line, column); - }, []); + }, [performJump]); const handleIndentConfirm = useCallback((tabSize: number, insertSpaces: boolean) => { const merged = { tab_size: tabSize, insert_spaces: insertSpaces }; @@ -1110,6 +1148,19 @@ const CodeEditor: React.FC = ({ // If Model already has content, skip file loading to avoid overwriting unsaved changes (e.g. switching back to open tab) if (modelRef.current && modelRef.current.getValue()) { setLoading(false); + void (async () => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + if (typeof fileInfo?.modified === 'number') { + lastModifiedTimeRef.current = fileInfo.modified; + } + } catch (err) { + log.warn('Failed to sync file metadata when skipping load', err); + } + })(); return; } @@ -1281,7 +1332,7 @@ const CodeEditor: React.FC = ({ // Check file modifications const checkFileModification = useCallback(async () => { - if (!filePath || isCheckingFileRef.current || !monacoReady) return; + if (!filePath || isCheckingFileRef.current) return; isCheckingFileRef.current = true; @@ -1337,7 +1388,7 @@ const CodeEditor: React.FC = ({ } finally { isCheckingFileRef.current = false; } - }, [applyExternalContentToModel, filePath, hasChanges, monacoReady, onContentChange, t, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, hasChanges, onContentChange, t, updateLargeFileMode]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); @@ -1373,10 +1424,7 @@ const CodeEditor: React.FC = ({ const unsubscribers: Array<() => void> = []; const unsubGotoDef = globalEventBus.on('editor:goto-definition', async (data: any) => { - const normalizePath = (path: string) => path.replace(/\\/g, '/').toLowerCase(); - const eventPath = normalizePath(data.filePath || ''); - const currentPath = normalizePath(filePath || ''); - const isMatch = eventPath === currentPath; + const isMatch = isSamePath(data.filePath || '', filePath || ''); if (isMatch) { try { @@ -1535,47 +1583,77 @@ const CodeEditor: React.FC = ({ unsubscribers.push(unsubDocHighlight); const unsubFileChanged = globalEventBus.on('editor:file-changed', async (data: { filePath: string }) => { - const normalizePath = (path: string) => path.replace(/\\/g, '/').toLowerCase(); - const eventPath = normalizePath(data.filePath || ''); - const currentPath = normalizePath(filePath || ''); - - if (eventPath === currentPath) { - try { - const { workspaceAPI } = await import('@/infrastructure/api'); - const content = await workspaceAPI.readFile(filePath); - updateLargeFileMode(content); - - const currentPosition = editor?.getPosition(); - - setContent(content); - originalContentRef.current = content; - setHasChanges(false); - hasChangesRef.current = false; - applyExternalContentToModel(content); + if (!isSamePath(data.filePath || '', filePath || '')) { + return; + } - if (editor && currentPosition) { - editor.setPosition(currentPosition); + try { + if (hasChangesRef.current) { + const shouldReload = window.confirm( + t('editor.codeEditor.externalModifiedConfirm') + ); + if (!shouldReload) { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + if (typeof fileInfo?.modified === 'number') { + lastModifiedTimeRef.current = fileInfo.modified; + } + } catch (err) { + log.warn('Failed to sync mtime after declining external reload', err); + } + return; } + } - queueMicrotask(() => { - if (modelRef.current && !isUnmountedRef.current) { - savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); - monacoModelManager.markAsSaved(filePath); - } + const { workspaceAPI } = await import('@/infrastructure/api'); + const { invoke } = await import('@tauri-apps/api/core'); + const fileContent = await workspaceAPI.readFileContent(filePath); + updateLargeFileMode(fileContent); + + const currentPosition = editor?.getPosition(); + + isLoadingContentRef.current = true; + setContent(fileContent); + originalContentRef.current = fileContent; + setHasChanges(false); + hasChangesRef.current = false; + applyExternalContentToModel(fileContent); + + try { + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath } }); - } catch (error) { - log.error('Failed to reload file', error); + if (typeof fileInfo?.modified === 'number') { + lastModifiedTimeRef.current = fileInfo.modified; + } + } catch (err) { + log.warn('Failed to update file modification time after external reload', err); } + + if (editor && currentPosition) { + editor.setPosition(currentPosition); + } + + onContentChange?.(fileContent, false); + + queueMicrotask(() => { + isLoadingContentRef.current = false; + if (modelRef.current && !isUnmountedRef.current) { + savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); + monacoModelManager.markAsSaved(filePath); + } + }); + } catch (error) { + log.error('Failed to reload file', error); } }); unsubscribers.push(unsubFileChanged); const unsubSaveFile = globalEventBus.on('editor:save-file', (data: { filePath: string }) => { - const normalizePath = (path: string) => path.replace(/\\/g, '/').toLowerCase(); - const eventPath = normalizePath(data.filePath || ''); - const currentPath = normalizePath(filePath || ''); - - if (eventPath === currentPath) { + if (isSamePath(data.filePath || '', filePath || '')) { saveFileContentRef.current?.(); } }); @@ -1584,7 +1662,7 @@ const CodeEditor: React.FC = ({ return () => { unsubscribers.forEach(unsub => unsub()); }; - }, [applyExternalContentToModel, monacoReady, filePath, updateLargeFileMode]); + }, [applyExternalContentToModel, monacoReady, filePath, updateLargeFileMode, onContentChange, t]); useEffect(() => { userLanguageOverrideRef.current = false; diff --git a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts index c6740657..98b5fdee 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts @@ -4,6 +4,7 @@ import { fileSystemService } from '../services/FileSystemService'; import { directoryCache } from '../services/DirectoryCache'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; +import { globalEventBus } from '@/infrastructure/event-bus'; const log = createLogger('useFileSystem'); @@ -558,6 +559,14 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem const unwatch = fileSystemService.watchFileChanges(rootPath, (event) => { handleFileChange(event.path); + + if ( + event.type === 'modified' || + event.type === 'created' || + event.type === 'renamed' + ) { + globalEventBus.emit('editor:file-changed', { filePath: event.path }); + } }); return () => { diff --git a/src/web-ui/src/tools/file-system/services/FileSystemService.ts b/src/web-ui/src/tools/file-system/services/FileSystemService.ts index c39aedd0..31aaff71 100644 --- a/src/web-ui/src/tools/file-system/services/FileSystemService.ts +++ b/src/web-ui/src/tools/file-system/services/FileSystemService.ts @@ -88,7 +88,10 @@ class FileSystemService implements IFileSystemService { events.forEach((fileEvent) => { const normalizedEventPath = normalizeForCompare(fileEvent.path); - if (!normalizedEventPath.startsWith(normalizedRoot)) { + const underRoot = + normalizedEventPath === normalizedRoot || + normalizedEventPath.startsWith(`${normalizedRoot}/`); + if (!underRoot) { return; }