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 54e4a7d4..5b0c3478 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 @@ -52,6 +52,13 @@ pub struct BotChatState { pub paired: bool, pub current_workspace: Option, pub current_assistant: Option, + /// Human-readable name of the active assistant (e.g. "默认助理" / "Bob"). + /// Populated alongside `current_assistant` from `WorkspaceInfo.name` so + /// the assistant-mode menu body can show a meaningful label instead of + /// the workspace directory name (which is often a generic + /// "workspace" / "workspace-" folder). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assistant_name: Option, pub current_session_id: Option, #[serde(default)] pub display_mode: BotDisplayMode, @@ -83,6 +90,7 @@ impl BotChatState { paired: false, current_workspace: None, current_assistant: None, + current_assistant_name: None, current_session_id: None, display_mode: BotDisplayMode::Assistant, pending_action: None, @@ -92,6 +100,21 @@ impl BotChatState { } } + /// Returns the workspace root path that should be used to resolve relative + /// file references emitted by the agent (e.g. markdown links in replies). + /// + /// In Pro mode this is the explicitly switched workspace + /// (`current_workspace`); in Assistant mode the agent runs against the + /// per-user assistant workspace held in `current_assistant`. IM platform + /// adapters MUST consult both — looking only at `current_workspace` causes + /// auto-push to silently drop relative-path attachments produced by + /// assistant sessions (the most common case for end users). + pub fn active_workspace_path(&self) -> Option { + self.current_workspace + .clone() + .or_else(|| self.current_assistant.clone()) + } + fn set_pending(&mut self, action: PendingAction) { self.pending_action = Some(action); self.pending_expires_at = now_secs() + PENDING_TTL_SECS; @@ -425,13 +448,13 @@ fn welcome_view(s: &'static BotStrings) -> MenuView { } fn ready_to_chat_body(state: &BotChatState, s: &'static BotStrings) -> Option { - if state.current_session_id.is_some() { - Some(format!( - "{}: {}", - s.current_session_label, - short_session_label(state, s) - )) - } else if state.display_mode == BotDisplayMode::Pro { + // Always show the workspace / assistant name (a human-meaningful + // identifier) regardless of whether a session is active. We deliberately + // do NOT surface `current_session_id` — the random UUID tail (e.g. + // "5cff6a1") is opaque to the user and adds nothing useful. If the + // user wants to manage sessions they can use /resume which renders + // proper session names. + if state.display_mode == BotDisplayMode::Pro { match &state.current_workspace { Some(p) => Some(format!( "{}: {}", @@ -441,27 +464,49 @@ fn ready_to_chat_body(state: &BotChatState, s: &'static BotStrings) -> Option Some(s.no_workspace.to_string()), } } else { + // Assistant mode: prefer the cached assistant display name (set by + // pairing / switch / resume flows from `WorkspaceInfo.name`). The + // workspace path's directory name is meaningless here — the actual + // assistant folder is usually `workspace` or `workspace-`, + // both of which look like noise to the user. match &state.current_assistant { - Some(p) => Some(format!( - "{}: {}", - s.current_assistant_label, - short_path_name(p) - )), + Some(p) => { + let label = state + .current_assistant_name + .as_deref() + .filter(|n| !n.trim().is_empty()) + .map(|n| n.to_string()) + .unwrap_or_else(|| short_path_name(p)); + Some(format!("{}: {}", s.current_assistant_label, label)) + } None => Some(s.no_assistant.to_string()), } } } -fn short_session_label(state: &BotChatState, s: &'static BotStrings) -> String { - state - .current_session_id - .as_deref() - .map(|id| { - // Show only the short tail to avoid showing full UUIDs. - let tail = id.rsplit('-').next().unwrap_or(id); - tail.to_string() - }) - .unwrap_or_else(|| s.no_session.to_string()) +/// One-shot lookup that fills in `current_assistant_name` from the workspace +/// service when the chat state has an `current_assistant` path but no cached +/// display name (e.g. the state was persisted before the field was added). +/// Best-effort: silently no-ops if the workspace service is unavailable or +/// the path is not a known assistant workspace. +async fn refresh_assistant_name_if_missing(state: &mut BotChatState) { + use crate::service::workspace::get_global_workspace_service; + if state.current_assistant_name.is_some() { + return; + } + let Some(path) = state.current_assistant.clone() else { + return; + }; + let Some(svc) = get_global_workspace_service() else { + return; + }; + let workspaces = svc.get_assistant_workspaces().await; + if let Some(ws) = workspaces + .into_iter() + .find(|w| w.root_path.to_string_lossy() == path) + { + state.current_assistant_name = Some(ws.name); + } } fn short_path_name(path: &str) -> String { @@ -617,6 +662,7 @@ pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String } state.current_assistant = Some(ws_info.root_path.to_string_lossy().to_string()); + state.current_assistant_name = Some(ws_info.name.clone()); state.current_session_id = None; let create_res = create_session(state, "Claw").await; @@ -715,6 +761,12 @@ async fn dispatch( return result_from_menu(state, welcome_view(s)); } + // Lazily resolve `current_assistant_name` for chat states that were + // persisted before this field existed. Without this, already-paired + // users would keep seeing the workspace folder name (e.g. "workspace") + // until they manually re-switch assistants. + refresh_assistant_name_if_missing(state).await; + // Handle /cancel as task cancellation when an active session exists. if let BotCommand::CancelTask(turn_id) = &cmd { return handle_cancel_task(state, turn_id.as_deref(), s).await; @@ -1033,6 +1085,7 @@ async fn select_assistant( error!("Failed to init snapshot after bot assistant switch: {e}"); } state.current_assistant = Some(path.to_string()); + state.current_assistant_name = Some(name.to_string()); state.current_session_id = None; info!("Bot switched assistant to: {path}"); @@ -1387,13 +1440,19 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes } }; let workspaces = ws_service.get_assistant_workspaces().await; - let resolved = if let Some(default_ws) = + let resolved: Option<(String, String)> = if let Some(default_ws) = workspaces.into_iter().find(|w| w.assistant_id.is_none()) { - Some(default_ws.root_path.to_string_lossy().to_string()) + Some(( + default_ws.root_path.to_string_lossy().to_string(), + default_ws.name.clone(), + )) } else { match ws_service.create_assistant_workspace(None).await { - Ok(ws_info) => Some(ws_info.root_path.to_string_lossy().to_string()), + Ok(ws_info) => Some(( + ws_info.root_path.to_string_lossy().to_string(), + ws_info.name.clone(), + )), Err(e) => { return result_from_menu( state, @@ -1405,10 +1464,11 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes } } }; - if let Some(ref path) = resolved { + if let Some((ref path, ref name)) = resolved { state.current_assistant = Some(path.clone()); + state.current_assistant_name = Some(name.clone()); } - resolved + resolved.map(|(p, _)| p) } } else { state.current_workspace.clone() @@ -1967,6 +2027,21 @@ async fn submit_question_answers( // ── Free-form chat handling ─────────────────────────────────────── +/// Look up the agent type a session was created with (e.g. "Claw", "Cowork", +/// "agentic"). Returns `None` if the coordinator is unavailable or the +/// session is not currently hot in memory; in that case `send_message` will +/// lazily restore the session from disk and `resolve_agent_type` falls back +/// to the safe default ("agentic"), so chat keeps working. +async fn resolve_session_agent_type(session_id: &str) -> Option { + use crate::agentic::coordination::get_global_coordinator; + + let coordinator = get_global_coordinator()?; + coordinator + .get_session_manager() + .get_session(session_id) + .map(|s| s.agent_type.clone()) +} + async fn handle_chat( state: &mut BotChatState, message: &str, @@ -1995,32 +2070,35 @@ async fn handle_chat( let session_id = state.current_session_id.clone().unwrap(); let turn_id = format!("turn_{}", uuid::Uuid::new_v4()); - let session_busy = { - use crate::agentic::coordination::get_global_coordinator; - use crate::agentic::core::SessionState; - get_global_coordinator() - .and_then(|c| c.get_session_manager().get_session(&session_id)) - .is_some_and(|s| matches!(s.state, SessionState::Processing { .. })) - }; - - let view = if session_busy { - MenuView::plain(s.queued).with_items(vec![ - MenuItem::danger(s.item_cancel_task, format!("/cancel_task {turn_id}")), - MenuItem::default(s.item_back, "/menu"), - ]) - } else { - MenuView::plain(s.processing) - .with_items(vec![ - MenuItem::danger(s.item_cancel_task, format!("/cancel_task {turn_id}")), - MenuItem::default(s.item_back, "/menu"), - ]) - .with_footer(s.footer_processing_cancel_hint) - }; + // Pick the agent type from the actual session — NOT a hardcoded + // "agentic" — otherwise every chat message goes through the Code + // (`agentic`) agent regardless of what kind of session was created. + // Concretely: the IM pairing bootstrap creates a `Claw` session for + // assistant mode, but the old hardcoded value caused all subsequent + // messages to be re-routed to the Code agent and the assistant flow + // was effectively bypassed. We mirror the agent type the session was + // actually created with, falling back to "agentic" only if the session + // is missing in memory (e.g. needs lazy restore — `send_message` will + // also normalize via `resolve_agent_type`). + let agent_type = resolve_session_agent_type(&session_id) + .await + .unwrap_or_else(|| "agentic".to_string()); + + // Intentionally do NOT send a "Processing..." / "Queued" interstitial + // message with a Cancel-task menu. The session manager queues new user + // messages automatically: the user can simply send another message and + // it will be processed once the current atomic step finishes. Showing + // a cancel button adds noise (especially on WeChat where every reply + // costs a context_token slot) without giving the user anything they + // actually need. The empty `MenuView::default()` here is silently + // dropped by every adapter's `send_handle_result` (see the + // empty-text guards in weixin.rs / feishu.rs / telegram.rs). + let view = MenuView::default(); let forward = ForwardRequest { session_id, content: message.to_string(), - agent_type: "agentic".to_string(), + agent_type, turn_id, image_contexts, }; @@ -2071,8 +2149,6 @@ pub async fn execute_forwarded_turn( let result = tokio::time::timeout(std::time::Duration::from_secs(3600), async { let mut response = String::new(); let mut thinking_buf = String::new(); - let mut tool_params_cache: std::collections::HashMap> = - std::collections::HashMap::new(); let streams_our_turn = || { tracker @@ -2117,6 +2193,10 @@ pub async fn execute_forwarded_turn( if !streams_our_turn() { continue; } + // Only AskUserQuestion needs an IM-side prompt; every + // other tool call is internal and not surfaced to the + // user (verbose mode keeps thinking summaries only — + // see ToolCompleted handler below). if tool_name == "AskUserQuestion" { if let Some(questions_value) = params.and_then(|p| p.get("questions").cloned()) @@ -2145,46 +2225,16 @@ pub async fn execute_forwarded_turn( } } } - } else { - tool_params_cache.insert(tool_id, params); } } - TrackerEvent::ToolCompleted { - tool_id, - tool_name, - duration_ms, - success, - } => { - if !streams_our_turn() { - continue; - } - if verbose_mode { - if let Some(sender) = message_sender.as_ref() { - let params_str = tool_params_cache - .remove(&tool_id) - .flatten() - .and_then(|p| format_tool_params_slim(&p)) - .unwrap_or_default(); - let duration_str = duration_ms - .map(|ms| { - if ms >= 1000 { - format!("{:.1}s", ms as f64 / 1000.0) - } else { - format!("{ms}ms") - } - }) - .unwrap_or_default(); - let status = if success { "OK" } else { "FAILED" }; - let msg = if params_str.is_empty() { - format!("[{tool_name}] {status} {duration_str}") - } else { - format!( - "[{tool_name}] {params_str}\n=> {status} {duration_str}" - ) - }; - sender(msg).await; - } - } + TrackerEvent::ToolCompleted { .. } => { + // Verbose mode used to push a `[ToolName] params => OK 627ms` + // line for every tool call. That is noisy on IM channels + // (especially WeChat where each line costs a context_token + // slot) and provides little value to the end user — they + // only care about the thinking summary and the final + // answer. Drop the tool-call notifications entirely while + // keeping `ThinkingEnd` summaries for verbose mode. } TrackerEvent::TurnCompleted { turn_id } => { if turn_id == target_turn_id { @@ -2221,16 +2271,13 @@ pub async fn execute_forwarded_turn( let full_text = tracker.accumulated_text(); let full_text = if full_text.is_empty() { response } else { full_text }; - let mut display_text = full_text.clone(); - const MAX_BOT_MSG_LEN: usize = 4000; - if display_text.len() > MAX_BOT_MSG_LEN { - let mut end = MAX_BOT_MSG_LEN; - while !display_text.is_char_boundary(end) { - end -= 1; - } - display_text.truncate(end); - display_text.push_str("\n\n... (truncated)"); - } + // Do NOT truncate here. Each IM adapter knows its own per-message + // size limit and chunks accordingly (e.g. WeChat splits via + // `chunk_text_for_weixin`, Telegram chunks at 4096 chars). A global + // 4000-char hard cut here would silently drop the tail of long + // replies (e.g. PPT outlines, code reviews) and confuse users with + // a "(truncated)" suffix they cannot recover from. + let display_text = full_text.clone(); ForwardedTurnResult { display_text: if display_text.is_empty() { @@ -2260,45 +2307,6 @@ fn truncate_at_char_boundary(s: &str, max_len: usize) -> String { format!("{}...", &s[..end]) } -fn format_tool_params_slim(params: &serde_json::Value) -> Option { - const MAX_VAL_LEN: usize = 120; - match params { - serde_json::Value::Object(obj) => { - let parts: Vec = obj - .iter() - .filter_map(|(k, v)| { - let val_str = match v { - serde_json::Value::String(s) => { - if s.len() > MAX_VAL_LEN { - return None; - } - s.clone() - } - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Null => "null".to_string(), - _ => { - let json = serde_json::to_string(v).unwrap_or_default(); - if json.len() > MAX_VAL_LEN { - return None; - } - json - } - }; - Some(format!("{k}: {val_str}")) - }) - .collect(); - if parts.is_empty() { - None - } else { - Some(parts.join(", ")) - } - } - serde_json::Value::String(s) => Some(truncate_at_char_boundary(s, MAX_VAL_LEN)), - _ => None, - } -} - // ── Tests ───────────────────────────────────────────────────────── #[cfg(test)] @@ -2423,6 +2431,26 @@ mod state_tests { assert!(state.pending_expired()); } + #[test] + fn active_workspace_path_prefers_pro_workspace_then_assistant() { + let mut state = BotChatState::new("c".into()); + assert_eq!(state.active_workspace_path(), None); + + state.current_assistant = Some("/tmp/assistant-ws".to_string()); + assert_eq!( + state.active_workspace_path().as_deref(), + Some("/tmp/assistant-ws"), + "assistant path is the fallback when no Pro workspace is set" + ); + + state.current_workspace = Some("/tmp/pro-ws".to_string()); + assert_eq!( + state.active_workspace_path().as_deref(), + Some("/tmp/pro-ws"), + "Pro workspace wins over the assistant path when both are set" + ); + } + #[test] fn clear_pending_resets_counters() { let mut state = BotChatState::new("c".into()); @@ -2458,4 +2486,136 @@ mod menu_tests { assert_eq!(view.items.len(), 5); assert!(view.items.iter().any(|i| i.command == "/new_code_session")); } + + /// Main menu must NOT surface the random session UUID tail. The user + /// only cares about the workspace / assistant name; the session ID is + /// noise (see /resume for proper session management). + #[test] + fn main_menu_body_omits_session_id() { + let mut state = BotChatState::new("c".into()); + state.current_assistant = Some("/tmp/my-assistant".to_string()); + state.current_assistant_name = Some("我的助理".to_string()); + state.current_session_id = + Some("abcdef12-3456-7890-abcd-ef1234567890".to_string()); + let s = strings_for(BotLanguage::ZhCN); + let view = main_menu_view(&state, s); + let body = view.body.as_deref().unwrap_or(""); + assert!( + !body.contains("567890") && !body.contains("ef1234567890"), + "session UUID tail leaked into body: {body}" + ); + assert!(body.contains("我的助理"), "assistant name missing: {body}"); + } + + /// Assistant mode must show the assistant's display name rather than + /// the workspace directory's `file_name`. The directory is usually a + /// generic "workspace" / "workspace-" folder which is meaningless + /// to the user. + #[test] + fn assistant_mode_body_uses_display_name_not_dir_name() { + let mut state = BotChatState::new("c".into()); + state.current_assistant = + Some("/tmp/bitfun_assistants/workspace-abc123".to_string()); + state.current_assistant_name = Some("默认助理".to_string()); + let s = strings_for(BotLanguage::ZhCN); + let view = main_menu_view(&state, s); + let body = view.body.as_deref().unwrap_or(""); + assert!( + body.contains("默认助理"), + "expected assistant display name in body, got: {body}" + ); + assert!( + !body.contains("workspace-abc123"), + "workspace directory name leaked into body: {body}" + ); + } + + /// Expert mode keeps showing the workspace directory name (it IS the + /// project name, which is what the user expects to see). + #[test] + fn expert_mode_body_still_uses_workspace_dir_name() { + let mut state = BotChatState::new("c".into()); + state.display_mode = BotDisplayMode::Pro; + state.current_workspace = Some("/tmp/projects/MyApp".to_string()); + // `current_assistant_name` should not affect Pro mode at all. + state.current_assistant_name = Some("ignored".to_string()); + let s = strings_for(BotLanguage::ZhCN); + let view = main_menu_view(&state, s); + let body = view.body.as_deref().unwrap_or(""); + assert!(body.contains("MyApp"), "workspace name missing: {body}"); + assert!(!body.contains("ignored"), "assistant name leaked into Pro mode: {body}"); + } + + /// When the cached assistant display name is missing (e.g. legacy + /// persisted state), fall back to the path's last segment instead of + /// rendering an empty label or panicking. + #[test] + fn assistant_mode_body_falls_back_to_path_when_name_missing() { + let mut state = BotChatState::new("c".into()); + state.current_assistant = Some("/tmp/my-assistant-folder".to_string()); + state.current_assistant_name = None; + let s = strings_for(BotLanguage::ZhCN); + let view = main_menu_view(&state, s); + let body = view.body.as_deref().unwrap_or(""); + assert!( + body.contains("my-assistant-folder"), + "expected fallback to path tail, got: {body}" + ); + } + + #[test] + fn main_menu_body_omits_session_label_text() { + let mut state = BotChatState::new("c".into()); + state.current_assistant = Some("/tmp/my-assistant".to_string()); + state.current_session_id = Some("session-xyz".to_string()); + let s = strings_for(BotLanguage::ZhCN); + let view = main_menu_view(&state, s); + let body = view.body.as_deref().unwrap_or(""); + assert!( + !body.contains(s.current_session_label), + "current_session_label leaked into body: {body}" + ); + } +} + +#[cfg(test)] +mod handle_chat_tests { + use super::*; + + /// `handle_chat` must NOT push a "Processing… [Cancel Task]" interstitial + /// to the user. The session manager queues new messages automatically; + /// showing a cancel button just adds noise (and on WeChat costs a + /// context_token slot per send). + #[tokio::test] + async fn chat_message_forwards_silently_without_processing_menu() { + let mut state = BotChatState::new("peer".into()); + state.paired = true; + state.current_assistant = Some("/tmp/a".into()); + state.current_session_id = Some("s1".into()); + let s = strings_for(BotLanguage::ZhCN); + let result = handle_chat(&mut state, "hello bitfun", vec![], s).await; + + assert!( + result.forward_to_session.is_some(), + "chat message must still be forwarded to the session" + ); + assert!( + result.menu.title.is_empty() + && result.menu.items.is_empty() + && result.menu.body.is_none() + && result.menu.footer_hint.is_none(), + "handle_chat must return an empty MenuView so adapters skip the send: {:?}", + result.menu + ); + assert!( + !result.reply.contains(s.processing) && !result.reply.contains(s.queued), + "processing/queued text must not be sent: {}", + result.reply + ); + assert!( + !result.reply.contains(s.item_cancel_task), + "cancel-task button must not be sent: {}", + result.reply + ); + } } 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 2eb39e26..edf577e3 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -547,6 +547,12 @@ impl FeishuBot { } else { result.menu.render_text_block() }; + // Empty replies (e.g. the silent "forward only" result returned by + // `handle_chat`) must not be sent — they would surface as a blank + // message in the user's Feishu chat. + if text.trim().is_empty() { + return Ok(()); + } if result.actions.is_empty() { self.send_message(chat_id, &text).await } else { @@ -641,9 +647,7 @@ impl FeishuBot { let language = current_bot_language().await; let workspace_root = { let states = self.chat_states.read().await; - states - .get(chat_id) - .and_then(|s| s.current_workspace.clone()) + states.get(chat_id).and_then(|s| s.active_workspace_path()) }; let files = super::collect_auto_push_files( text, @@ -653,11 +657,9 @@ impl FeishuBot { return; } - let intro = super::auto_push_intro(language, files.len()); - if let Err(e) = self.send_message(chat_id, &intro).await { - warn!("Feishu auto-push intro failed for chat {chat_id}: {e}"); - } - + // Skip the "正在为你发送 N 个文件……" intro: the file card itself is + // visible in the chat; only error / size-skip notices below need to + // surface to the user. for file in files { if file.size > MAX_FEISHU_FILE_BYTES { let notice = super::auto_push_skip_too_large_message( 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 6c652812..61559455 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -78,7 +78,13 @@ pub struct BotPersistenceData { #[serde(default)] pub form_state: RemoteConnectFormState, /// Global verbose mode setting for all bot connections. - /// When true, intermediate tool execution progress is sent to the user. + /// When true, the agent's intermediate thinking summaries (one short + /// `[Thinking] …` line per `ThinkingEnd`) are forwarded to the user. + /// Tool-call notifications are intentionally NOT sent even in verbose + /// mode — they were too noisy for IM channels (especially WeChat where + /// each line costs a `context_token` slot) without giving the user + /// information they could act on. + /// Defaults to `false` (concise mode). #[serde(default)] pub verbose_mode: bool, } @@ -606,7 +612,7 @@ pub fn save_bot_persistence(data: &BotPersistenceData) { #[cfg(test)] mod tests { - use super::{extract_downloadable_file_paths, resolve_workspace_path}; + use super::{collect_auto_push_files, extract_downloadable_file_paths, resolve_workspace_path}; fn make_temp_workspace() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { let base = std::env::temp_dir().join(format!( @@ -647,6 +653,28 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// Regression: `[name.pptx](name.pptx)` style relative markdown links + /// emitted by the agent must be auto-pushed when the active workspace + /// (Pro mode `current_workspace` OR Assistant mode `current_assistant`) + /// is known. Previously only `current_workspace` was consulted, so + /// assistant-mode replies silently dropped attachments — see + /// `BotChatState::active_workspace_path` and the per-platform + /// `notify_files_ready` callers. + #[test] + fn collects_relative_pptx_link_against_assistant_workspace_root() { + let (base, workspace, _report) = make_temp_workspace(); + let pptx = workspace.join("apple-vision-pro-keynote-style.pptx"); + std::fs::write(&pptx, b"pptx-bytes").unwrap(); + + let text = "[apple-vision-pro-keynote-style.pptx](apple-vision-pro-keynote-style.pptx)"; + let files = collect_auto_push_files(text, Some(&workspace)); + + assert_eq!(files.len(), 1, "relative pptx link must be auto-pushed"); + assert_eq!(files[0].name, "apple-vision-pro-keynote-style.pptx"); + assert_eq!(files[0].size, b"pptx-bytes".len() as u64); + let _ = std::fs::remove_dir_all(base); + } + #[test] fn extracts_relative_computer_links_when_workspace_root_is_known() { let (base, workspace, _report) = make_temp_workspace(); 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 7343e735..4618f653 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -40,6 +40,35 @@ struct PendingPairing { /// across all IM platforms by capping at 30 MB to match Feishu / WeChat. const MAX_TELEGRAM_FILE_BYTES: u64 = 30 * 1024 * 1024; +/// Telegram caps `sendMessage.text` at 4096 UTF-16 code units. We chunk on +/// char boundaries and stay slightly under the limit to leave headroom for +/// any client-side counting differences. +const MAX_TELEGRAM_TEXT_CHUNK: usize = 4000; + +fn chunk_text_for_telegram(text: &str) -> Vec { + if text.len() <= MAX_TELEGRAM_TEXT_CHUNK { + return vec![text.to_string()]; + } + let mut out = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + if rest.len() <= MAX_TELEGRAM_TEXT_CHUNK { + out.push(rest.to_string()); + break; + } + let mut cut = MAX_TELEGRAM_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 +} + impl TelegramBot { fn invalid_pairing_code_message(language: BotLanguage) -> &'static str { if language.is_chinese() { @@ -80,18 +109,24 @@ impl TelegramBot { pub async fn send_message(&self, chat_id: i64, text: &str) -> Result<()> { let client = reqwest::Client::new(); - let resp = client - .post(self.api_url("sendMessage")) - .json(&serde_json::json!({ - "chat_id": chat_id, - "text": text, - })) - .send() - .await?; - - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("telegram sendMessage failed: {body}")); + // Telegram caps a single sendMessage at 4096 UTF-16 code units. We + // conservatively chunk on byte/char boundaries so long agent + // replies are delivered as multiple messages instead of being + // rejected or silently dropped. + for chunk in chunk_text_for_telegram(text) { + let resp = client + .post(self.api_url("sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": chunk, + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("telegram sendMessage failed: {body}")); + } } debug!("Telegram message sent to chat {chat_id}"); Ok(()) @@ -180,9 +215,7 @@ impl TelegramBot { let language = current_bot_language().await; let workspace_root = { let states = self.chat_states.read().await; - states - .get(&chat_id) - .and_then(|s| s.current_workspace.clone()) + states.get(&chat_id).and_then(|s| s.active_workspace_path()) }; let files = super::collect_auto_push_files( text, @@ -192,11 +225,9 @@ impl TelegramBot { return; } - let intro = super::auto_push_intro(language, files.len()); - if let Err(e) = self.send_message(chat_id, &intro).await { - warn!("Telegram auto-push intro failed for chat {chat_id}: {e}"); - } - + // Skip the "正在为你发送 N 个文件……" intro: the document message + // itself is visible in the chat; only error / size-skip notices + // below need to surface to the user. for file in files { if file.size > MAX_TELEGRAM_FILE_BYTES { let notice = super::auto_push_skip_too_large_message( @@ -247,14 +278,24 @@ impl TelegramBot { } else { result.menu.render_text_block() }; + // Empty replies (e.g. the silent "forward only" result returned by + // `handle_chat`) must not be sent — Telegram rejects empty bodies + // and a lone whitespace message is just noise to the user. + if text.trim().is_empty() { + return; + } if result.actions.is_empty() { - self.send_message(chat_id, &text).await.ok(); + if let Err(e) = self.send_message(chat_id, &text).await { + warn!("Failed to send Telegram message to {chat_id}: {e}"); + } } else if let Err(e) = self .send_message_with_keyboard(chat_id, &text, &result.actions) .await { warn!("Failed to send Telegram keyboard message: {e}; falling back to plain text"); - self.send_message(chat_id, &result.reply).await.ok(); + if let Err(e2) = self.send_message(chat_id, &result.reply).await { + warn!("Telegram fallback plain send to {chat_id} also failed: {e2}"); + } } } diff --git a/src/crates/core/src/service/remote_connect/bot/weixin.rs b/src/crates/core/src/service/remote_connect/bot/weixin.rs index d65b5039..0e6d60f4 100644 --- a/src/crates/core/src/service/remote_connect/bot/weixin.rs +++ b/src/crates/core/src/service/remote_connect/bot/weixin.rs @@ -626,9 +626,72 @@ pub struct WeixinBot { pending_pairings: Arc>>, chat_states: Arc>>, context_tokens: Arc>>, + /// Per-peer typing ticket cache (returned by `ilink/bot/getconfig`, + /// required by `ilink/bot/sendtyping`). Refreshed lazily and dropped + /// whenever a typing API call signals an invalid/expired ticket. + typing_tickets: Arc>>, session_pause_until_ms: Arc>>, } +/// RAII guard returned by [`WeixinBot::start_typing`]. Dropping or calling +/// [`TypingHandle::stop`] cancels the periodic refresher and best-effort +/// emits a `sendtyping(status=2)` to clear the "正在输入" UI on the peer side. +/// +/// Cancellation uses an [`AtomicBool`] (not `tokio::sync::Notify`) on purpose: +/// `Notify::notify_waiters` only wakes tasks that are *currently* parked on +/// `.notified()`, so signalling while the loop is mid-`send_typing` HTTP call +/// silently drops the wake-up and the task would refresh "正在输入" forever. +/// An atomic flag plus short-grained polling makes the cancel deterministic. +pub struct TypingHandle { + cancel: Arc, + handle: Option>, + bot: Arc, + peer_id: String, + stopped: bool, +} + +impl TypingHandle { + /// Stop the typing loop and explicitly send a cancel event. Awaiting this + /// gives callers visibility into the cancel attempt; not awaiting (i.e. + /// just dropping) still cancels the loop and fires a best-effort cancel + /// from the Drop impl. + pub async fn stop(mut self) { + self.stopped = true; + self.cancel.store(true, std::sync::atomic::Ordering::Release); + if let Some(h) = self.handle.take() { + let _ = h.await; + } + if let Err(e) = self.bot.send_typing(&self.peer_id, 2).await { + debug!( + "weixin: send typing(cancel) failed for peer {peer}: {e}", + peer = self.peer_id + ); + } + } +} + +impl Drop for TypingHandle { + fn drop(&mut self) { + if self.stopped { + return; + } + self.cancel.store(true, std::sync::atomic::Ordering::Release); + if let Some(h) = self.handle.take() { + h.abort(); + } + // Fire-and-forget cancel: we can't await in Drop, but we still want + // the peer's "正在输入" indicator to clear in case the future was + // dropped without `stop().await`. + let bot = self.bot.clone(); + let peer = self.peer_id.clone(); + tokio::spawn(async move { + if let Err(e) = bot.send_typing(&peer, 2).await { + debug!("weixin: drop-cancel typing failed for peer {peer}: {e}"); + } + }); + } +} + impl WeixinBot { pub fn new(config: WeixinConfig) -> Self { Self { @@ -636,6 +699,7 @@ impl WeixinBot { pending_pairings: Arc::new(RwLock::new(HashMap::new())), chat_states: Arc::new(RwLock::new(HashMap::new())), context_tokens: Arc::new(RwLock::new(HashMap::new())), + typing_tickets: Arc::new(RwLock::new(HashMap::new())), session_pause_until_ms: Arc::new(RwLock::new(HashMap::new())), } } @@ -719,6 +783,32 @@ impl WeixinBot { if !status.is_success() { return Err(anyhow!("ilink {endpoint} HTTP {status}: {text}")); } + // WeChat iLink returns HTTP 200 even on application errors. The actual + // status lives in the JSON body's `ret` / `errcode` fields. We MUST + // surface those as errors here so callers (e.g. `send_message_raw`) + // notice failures like expired `context_token` instead of silently + // dropping messages. `getupdates` callers parse the body themselves + // and tolerate `ret == -14`, so we only enforce this for the + // `sendmessage` endpoint where the body is well-defined. + if endpoint.contains("sendmessage") + || endpoint.contains("sendtyping") + || endpoint.contains("getconfig") + { + if let Ok(v) = serde_json::from_str::(&text) { + let ret = v["ret"].as_i64().unwrap_or(0); + let errcode = v["errcode"].as_i64().unwrap_or(0); + if ret != 0 || errcode != 0 { + let errmsg = v["errmsg"] + .as_str() + .or_else(|| v["msg"].as_str()) + .unwrap_or("") + .to_string(); + return Err(anyhow!( + "ilink {endpoint} application error ret={ret} errcode={errcode} errmsg={errmsg}" + )); + } + } + } Ok(text) } @@ -1004,13 +1094,24 @@ impl WeixinBot { }) } - /// `aes_key` in JSON: base64 of raw 16-byte key (standard); matches typical iLink clients. + /// `aes_key` in JSON for outbound media items. + /// + /// Quirk match with the official `@tencent-weixin/openclaw-weixin@2.x` + /// reference plugin: it does `Buffer.from(aeskey.toString("hex")).toString("base64")`, + /// which treats the 32-char hex *string* as UTF-8 bytes and base64-encodes + /// **those ASCII bytes** — NOT the raw 16 binary bytes. The downstream + /// WeChat client decodes the value, sees 32 ASCII hex chars, and hex- + /// decodes back to the original 16-byte AES key. We were previously + /// shipping `base64(raw 16 bytes)` (the "obvious" interpretation), which + /// the WeChat client cannot decrypt — the file appeared in the chat but + /// every download attempt failed with "下载失败". Stay bug-compatible + /// with the reference so the client can decrypt the CDN payload. 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")); + let trimmed = aeskey_hex.trim(); + if trimmed.len() != 32 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow!("aeskey must be 32 ascii hex chars")); } - Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) + Ok(base64::engine::general_purpose::STANDARD.encode(trimmed.as_bytes())) } async fn send_message_with_items( @@ -1181,19 +1282,201 @@ impl WeixinBot { } /// Send text to peer; uses last known `context_token` for that peer. + /// + /// If the WeChat iLink API rejects the message (typically because the + /// `context_token` has expired or exceeded its usage budget), we drop + /// the cached token so subsequent sends fail fast with a clear error + /// instead of silently retrying a known-bad token. The token will be + /// refreshed automatically the next time the user sends an inbound + /// message (see `run_message_loop` / `wait_for_pairing`). 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}"))? + .ok_or_else(|| anyhow!("context_token unavailable for peer {peer_id} (waiting for next inbound message)"))? }; for chunk in chunk_text_for_weixin(text) { - self.send_message_raw(peer_id, &token, &chunk).await?; + if let Err(e) = self.send_message_raw(peer_id, &token, &chunk).await { + if Self::is_context_token_error(&e) { + let mut m = self.context_tokens.write().await; + if m.get(peer_id).map(|t| t == &token).unwrap_or(false) { + m.remove(peer_id); + warn!( + "weixin: dropped stale context_token for peer {peer_id} after send error: {e}" + ); + } + } + return Err(e); + } } Ok(()) } + /// Heuristic: treat any send error mentioning an iLink application error + /// (or a ret/errcode payload) as a context_token-expiration signal. + /// We invalidate aggressively because the only thing we can do with a + /// bad token is stop using it. + fn is_context_token_error(err: &anyhow::Error) -> bool { + let s = err.to_string(); + s.contains("application error") + || s.contains("context_token") + || s.contains("errcode=") + } + + /// Best-effort send that logs a warning on failure instead of silently + /// swallowing the error. Use this for non-critical replies (welcome, + /// pairing-error hints, etc.) where we don't want to abort the caller + /// but we DO want a log record if the send actually failed. + async fn try_send_text(&self, peer_id: &str, text: &str, ctx: &str) { + if let Err(e) = self.send_text(peer_id, text).await { + warn!("weixin: {ctx} send to peer {peer_id} failed: {e}"); + } + } + + // ── Typing indicator (ilink/bot/getconfig + ilink/bot/sendtyping) ────── + // + // Per `@tencent-weixin/openclaw-weixin` (`src/api/api.ts`), driving the + // "对方正在输入" hint above the WeChat chat input requires two calls: + // 1. `POST ilink/bot/getconfig` → returns a base64 `typing_ticket` + // bound to the `(bot, ilink_user_id, context_token)` triple. + // 2. `POST ilink/bot/sendtyping` → with `status=1` to start typing and + // `status=2` to cancel (also auto-times out server-side after a few + // seconds, hence the 5-second refresh cadence used below). + + /// Fetch a fresh typing_ticket for `peer_id`. Always invokes + /// `ilink/bot/getconfig` (does NOT consult the cache) so the caller can + /// recover from a stale ticket by clearing it and calling here again. + async fn fetch_typing_ticket(&self, peer_id: &str) -> Result { + let context_token = { + let m = self.context_tokens.read().await; + m.get(peer_id).cloned() + }; + let mut body = json!({ + "ilink_user_id": peer_id, + "base_info": { "channel_version": CHANNEL_VERSION } + }); + if let Some(ct) = context_token { + body["context_token"] = json!(ct); + } + let raw = self + .post_ilink( + "ilink/bot/getconfig", + body, + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await?; + let v: Value = serde_json::from_str(&raw)?; + let ticket = v["typing_ticket"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("ilink/bot/getconfig returned empty typing_ticket"))?; + let mut m = self.typing_tickets.write().await; + m.insert(peer_id.to_string(), ticket.clone()); + Ok(ticket) + } + + /// Send one typing event (`status`: 1 = start, 2 = cancel). Lazily fetches + /// a typing_ticket on the first call per peer and refreshes once on + /// ticket-related errors before giving up. + async fn send_typing(&self, peer_id: &str, status: i64) -> Result<()> { + let cached = { + let m = self.typing_tickets.read().await; + m.get(peer_id).cloned() + }; + let ticket = match cached { + Some(t) => t, + None => self.fetch_typing_ticket(peer_id).await?, + }; + + let send_with = |t: String| async move { + let body = json!({ + "ilink_user_id": peer_id, + "typing_ticket": t, + "status": status, + "base_info": { "channel_version": CHANNEL_VERSION } + }); + self.post_ilink( + "ilink/bot/sendtyping", + body, + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await + }; + + match send_with(ticket.clone()).await { + Ok(_) => Ok(()), + Err(e) => { + // Drop the stale ticket and retry once with a fresh one. We + // can't reliably distinguish ticket errors from transient + // failures, so we always try to recover at most once. + { + let mut m = self.typing_tickets.write().await; + if m.get(peer_id).map(|t| t == &ticket).unwrap_or(false) { + m.remove(peer_id); + } + } + debug!("weixin: typing ticket retry for peer {peer_id} (prev err: {e})"); + let fresh = self.fetch_typing_ticket(peer_id).await?; + send_with(fresh).await?; + Ok(()) + } + } + } + + /// Spawn a background task that emits `sendtyping(status=1)` immediately + /// and refreshes it every 5 seconds. The returned [`TypingHandle`] cancels + /// the loop and emits `sendtyping(status=2)` when stopped or dropped, so + /// the "正在输入" hint disappears on the user's side as soon as the bot + /// finishes responding. + fn start_typing(self: &Arc, peer_id: String) -> TypingHandle { + use std::sync::atomic::{AtomicBool, Ordering}; + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_task = cancel.clone(); + let bot = self.clone(); + let peer_for_task = peer_id.clone(); + let handle = tokio::spawn(async move { + // Refresh interval matches OpenClaw's 6s default cadence; we use + // 5s to leave a small safety margin against server-side timeout. + // Each "wait" between refreshes is broken into 100ms ticks so a + // stop signal from the main task is observed within ≤100ms even + // mid-wait, which keeps the indicator from lingering after the + // bot has actually finished responding. + const TICK: Duration = Duration::from_millis(100); + const TICKS_PER_REFRESH: u32 = 50; // 50 * 100ms = 5s + const TICKS_AFTER_FAILURE: u32 = 100; // 100 * 100ms = 10s + + loop { + if cancel_task.load(Ordering::Acquire) { + return; + } + let next_wait = match bot.send_typing(&peer_for_task, 1).await { + Ok(()) => TICKS_PER_REFRESH, + Err(e) => { + debug!( + "weixin: send typing(start) failed for peer {peer_for_task}: {e}" + ); + TICKS_AFTER_FAILURE + } + }; + for _ in 0..next_wait { + if cancel_task.load(Ordering::Acquire) { + return; + } + tokio::time::sleep(TICK).await; + } + } + }); + TypingHandle { + cancel, + handle: Some(handle), + bot: self.clone(), + peer_id, + stopped: false, + } + } + fn is_weixin_media_item_type(type_id: i64) -> bool { matches!(type_id, 2..=5) } @@ -1327,9 +1610,7 @@ impl WeixinBot { let language = current_bot_language().await; let workspace_root = { let states = self.chat_states.read().await; - states - .get(peer_id) - .and_then(|s| s.current_workspace.clone()) + states.get(peer_id).and_then(|s| s.active_workspace_path()) }; let files = super::collect_auto_push_files( text, @@ -1339,11 +1620,11 @@ impl WeixinBot { return; } - let intro = super::auto_push_intro(language, files.len()); - if let Err(e) = self.send_text(peer_id, &intro).await { - warn!("Weixin auto-push intro failed for peer {peer_id}: {e}"); - } - + // Intentionally do NOT send a "正在为你发送 N 个文件……" intro: the + // file message itself already shows up in the chat, and the intro + // line just adds noise (and on WeChat costs a context_token slot + // per send). Errors / size-skips below still surface as their own + // notice messages so the user is informed when something is wrong. let root_path = workspace_root.as_deref().map(std::path::Path::new); for file in files { if file.size > MAX_WEIXIN_FILE_BYTES { @@ -1353,7 +1634,9 @@ impl WeixinBot { file.size, MAX_WEIXIN_FILE_BYTES, ); - let _ = self.send_text(peer_id, ¬ice).await; + if let Err(e) = self.send_text(peer_id, ¬ice).await { + warn!("Weixin auto-push skip notice failed for peer {peer_id}: {e}"); + } continue; } match self @@ -1371,7 +1654,11 @@ impl WeixinBot { ); let notice = super::auto_push_failed_message(language, &file.name, &e.to_string()); - let _ = self.send_text(peer_id, ¬ice).await; + if let Err(send_err) = self.send_text(peer_id, ¬ice).await { + warn!( + "Weixin auto-push failure notice failed for peer {peer_id}: {send_err}" + ); + } } } } @@ -1459,7 +1746,7 @@ impl WeixinBot { let language = current_bot_language().await; if text == "/start" { - let _ = self.send_text(&peer, welcome_message(language)).await; + self.try_send_text(&peer, welcome_message(language), "welcome").await; continue; } @@ -1482,7 +1769,7 @@ impl WeixinBot { } else { "Invalid or expired pairing code." }; - let _ = self.send_text(&peer, err).await; + self.try_send_text(&peer, err, "pairing-invalid").await; } } else if !text.is_empty() { let err = if language.is_chinese() { @@ -1490,14 +1777,14 @@ impl WeixinBot { } else { "Please send the 6-digit pairing code from BitFun Desktop Remote Connect." }; - let _ = self.send_text(&peer, err).await; + self.try_send_text(&peer, err, "pairing-prompt").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; + self.try_send_text(&peer, err, "pairing-image-hint").await; } } } @@ -1582,7 +1869,7 @@ impl WeixinBot { MAX_INBOUND_IMAGES, skipped_images ) }; - let _ = bot.send_text(&peer, ¬e).await; + bot.try_send_text(&peer, ¬e, "image-truncation-notice").await; } let body = WeixinBot::body_from_message(&msg_value); let text = if body.trim().is_empty() && !images.is_empty() { @@ -1619,7 +1906,7 @@ impl WeixinBot { let trimmed = text.trim(); if trimmed == "/start" { drop(states); - let _ = self.send_text(&peer_id, welcome_message(language)).await; + self.try_send_text(&peer_id, welcome_message(language), "welcome").await; return; } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { @@ -1636,7 +1923,7 @@ impl WeixinBot { "Invalid or expired pairing code." }; drop(states); - let _ = self.send_text(&peer_id, err).await; + self.try_send_text(&peer_id, err, "pairing-invalid").await; return; } } @@ -1646,7 +1933,7 @@ impl WeixinBot { } else { "Please send the 6-digit pairing code." }; - let _ = self.send_text(&peer_id, err).await; + self.try_send_text(&peer_id, err, "pairing-prompt").await; return; } @@ -1660,6 +1947,13 @@ impl WeixinBot { if let Some(forward) = result.forward_to_session { let bot = self.clone(); let peer = peer_id.clone(); + // Only show "正在输入" when there's an actual agentic turn to run. + // Local command/menu replies are already sent synchronously above, + // so a typing indicator there would either flash for a few ms or, + // worse, linger if the cancel call is delayed — both look broken + // to the user. Agentic turns are the long-running case where + // typing genuinely tells the user "the bot is still working". + let typing_for_turn = self.start_typing(peer_id.clone()); tokio::spawn(async move { let interaction_bot = bot.clone(); let peer_c = peer.clone(); @@ -1679,7 +1973,9 @@ impl WeixinBot { 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; + if let Err(e) = msg_bot.send_text(&peer_s, &t).await { + warn!("weixin: send intermediate message to peer {peer_s} failed: {e}"); + } }) }); let verbose_mode = load_bot_persistence().verbose_mode; @@ -1687,9 +1983,15 @@ impl WeixinBot { execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode) .await; if !turn_result.display_text.is_empty() { - let _ = bot.send_text(&peer, &turn_result.display_text).await; + if let Err(e) = bot.send_text(&peer, &turn_result.display_text).await { + warn!("weixin: send final reply to peer {peer} failed: {e}"); + } } bot.notify_files_ready(&peer, &turn_result.full_text).await; + // Stop typing AFTER both the final reply and any auto-pushed + // files have been dispatched, so the indicator does not flap + // off between the text answer and its attachments. + typing_for_turn.stop().await; }); } } @@ -1744,6 +2046,29 @@ mod weixin_inbound_tests { use super::*; use serde_json::json; + /// Sanity-check the heuristic used by `send_text` to decide whether a + /// failed `send_message_raw` indicates the cached `context_token` has + /// gone bad. Application errors and explicit `errcode=` strings must + /// trigger token invalidation; pure transport errors (network/HTTP) + /// must NOT, so we don't drop a perfectly good token after a transient + /// blip. + #[test] + fn context_token_error_heuristic() { + let app_err = anyhow!( + "ilink ilink/bot/sendmessage application error ret=0 errcode=12345 errmsg=context_token expired" + ); + assert!(WeixinBot::is_context_token_error(&app_err)); + + let app_err_short = anyhow!("upstream returned errcode=42 unauthorized"); + assert!(WeixinBot::is_context_token_error(&app_err_short)); + + let net_err = anyhow!("error sending request: connection refused"); + assert!(!WeixinBot::is_context_token_error(&net_err)); + + let http_err = anyhow!("ilink ilink/bot/sendmessage HTTP 500 Internal Server Error"); + assert!(!WeixinBot::is_context_token_error(&http_err)); + } + #[test] fn aes_ecb_roundtrip() { let key = [9u8; 16]; @@ -1770,6 +2095,46 @@ mod weixin_inbound_tests { assert_eq!(k, raw); } + /// Outbound `aes_key` MUST be base64 of the 32-char hex *string* (its + /// ASCII bytes), NOT base64 of the 16 raw key bytes. This matches the + /// official `@tencent-weixin/openclaw-weixin@2.x` reference plugin and + /// is what the WeChat client expects when it pulls the file from CDN — + /// otherwise every download fails with "下载失败" even though the bot + /// successfully delivers the message itself. + #[test] + fn media_aes_key_b64_matches_openclaw_hex_ascii_format() { + let raw = [ + 0x01u8, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, + ]; + let aeskey_hex = hex::encode(raw); + let produced = WeixinBot::media_aes_key_b64(&aeskey_hex).unwrap(); + let expected = B64.encode(aeskey_hex.as_bytes()); + assert_eq!( + produced, expected, + "media_aes_key_b64 must base64-encode the hex string ASCII bytes (OpenClaw quirk)" + ); + let decoded = B64.decode(&produced).unwrap(); + assert_eq!( + decoded.len(), + 32, + "decoded value must be 32 ASCII chars, not 16 raw bytes" + ); + assert!( + std::str::from_utf8(&decoded) + .map(|s| s.chars().all(|c| c.is_ascii_hexdigit())) + .unwrap_or(false), + "decoded payload must be the original hex string" + ); + } + + #[test] + fn media_aes_key_b64_rejects_non_hex_input() { + assert!(WeixinBot::media_aes_key_b64("not_hex_at_all").is_err()); + assert!(WeixinBot::media_aes_key_b64("zz".repeat(16).as_str()).is_err()); + assert!(WeixinBot::media_aes_key_b64("ab").is_err()); + } + #[test] fn body_from_message_plain_text() { let msg = json!({