diff --git a/packages/agent-server-rust/src/db/mod.rs b/packages/agent-server-rust/src/db/mod.rs index 69e5b77..de8c13c 100644 --- a/packages/agent-server-rust/src/db/mod.rs +++ b/packages/agent-server-rust/src/db/mod.rs @@ -62,8 +62,7 @@ fn migrate_to_encrypted(path: &str, key: &str) -> Result { std::fs::remove_file(path).map_err(|e| format!("Failed to remove old db: {e}"))?; std::fs::remove_file(format!("{path}-wal")).ok(); std::fs::remove_file(format!("{path}-shm")).ok(); - std::fs::rename(&tmp_path, path) - .map_err(|e| format!("Failed to rename encrypted db: {e}"))?; + std::fs::rename(&tmp_path, path).map_err(|e| format!("Failed to rename encrypted db: {e}"))?; tracing::info!("[DB] Migrated unencrypted database to encrypted"); @@ -72,8 +71,7 @@ fn migrate_to_encrypted(path: &str, key: &str) -> Result { /// Initialize the database: set encryption key, run migrations, set pragmas. pub fn init_db() -> Result<(), String> { - let db_path = - std::env::var("AGENT_DB_PATH").unwrap_or_else(|_| "/data/agent.db".to_string()); + let db_path = std::env::var("AGENT_DB_PATH").unwrap_or_else(|_| "/data/agent.db".to_string()); // Ensure directory exists if let Some(parent) = Path::new(&db_path).parent() { @@ -106,8 +104,7 @@ pub fn init_db() -> Result<(), String> { } } else { // New DB — just open encrypted - open_encrypted(&db_path, key) - .map_err(|e| format!("Failed to create encrypted db: {e}"))? + open_encrypted(&db_path, key).map_err(|e| format!("Failed to create encrypted db: {e}"))? }; // Run migrations (refinery) diff --git a/packages/agent-server-rust/src/db/queries.rs b/packages/agent-server-rust/src/db/queries.rs index 4c32e93..158d48a 100644 --- a/packages/agent-server-rust/src/db/queries.rs +++ b/packages/agent-server-rust/src/db/queries.rs @@ -1,4 +1,5 @@ use rusqlite::{params, Connection}; +use std::collections::HashMap; // ============================================ // SYNC STATE QUERIES @@ -37,6 +38,91 @@ pub fn set_sync_state(conn: &Connection, key: &str, value: &str, session_id: Opt .ok(); } +const PAYMENT_RECEIPTS_PREFIX: &str = "payment_receipts:"; + +fn payment_receipts_key(chat_id: &str) -> String { + format!("{PAYMENT_RECEIPTS_PREFIX}{chat_id}") +} + +fn read_payment_receipts( + conn: &Connection, + session_id: &str, + chat_id: &str, +) -> HashMap { + get_sync_state(conn, &payment_receipts_key(chat_id), Some(session_id)) + .and_then(|json| serde_json::from_str::>(&json).ok()) + .unwrap_or_default() +} + +fn write_payment_receipts( + conn: &Connection, + session_id: &str, + chat_id: &str, + receipts: &HashMap, +) { + if let Ok(json) = serde_json::to_string(receipts) { + set_sync_state( + conn, + &payment_receipts_key(chat_id), + &json, + Some(session_id), + ); + } +} + +pub fn get_payment_receipts( + conn: &Connection, + session_id: &str, + chat_id: &str, +) -> HashMap { + read_payment_receipts(conn, session_id, chat_id) + .into_iter() + .filter_map(|(local_id, received_at)| { + local_id.parse::().ok().map(|id| (id, received_at)) + }) + .collect() +} + +pub fn mark_payment_received( + conn: &Connection, + session_id: &str, + chat_id: &str, + local_id: i64, +) -> String { + let mut receipts = read_payment_receipts(conn, session_id, chat_id); + let now = chrono::Utc::now().to_rfc3339(); + let received_at = receipts + .entry(local_id.to_string()) + .or_insert_with(|| now.clone()) + .clone(); + + write_payment_receipts(conn, session_id, chat_id, &receipts); + + received_at +} + +pub fn remove_payment_receipts( + conn: &Connection, + session_id: &str, + chat_id: &str, + local_ids: &[i64], +) { + if local_ids.is_empty() { + return; + } + + let mut receipts = read_payment_receipts(conn, session_id, chat_id); + let mut changed = false; + + for local_id in local_ids { + changed |= receipts.remove(&local_id.to_string()).is_some(); + } + + if changed { + write_payment_receipts(conn, session_id, chat_id, &receipts); + } +} + // ============================================ // SESSION QUERIES // ============================================ @@ -57,7 +143,11 @@ pub fn update_session_logged_in_user( logged_in_user: Option<&str>, ) { let now = chrono::Utc::now().to_rfc3339(); - let login_state = if logged_in_user.is_some() { "logged_in" } else { "logged_out" }; + let login_state = if logged_in_user.is_some() { + "logged_in" + } else { + "logged_out" + }; conn.execute( "UPDATE sessions SET logged_in_user = ?1, login_state = ?2, updated_at = ?3 WHERE id = ?4", params![logged_in_user, login_state, now, session_id], diff --git a/packages/agent-server-rust/src/execution/actions.rs b/packages/agent-server-rust/src/execution/actions.rs index 2f6ba15..eaf7a47 100644 --- a/packages/agent-server-rust/src/execution/actions.rs +++ b/packages/agent-server-rust/src/execution/actions.rs @@ -50,7 +50,9 @@ pub fn execute_action<'a>( let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); exec_command("click", &args_ref, options).await; } else { - tracing::warn!("[action] click selector '{selector}' matched but no bounds"); + tracing::warn!( + "[action] click selector '{selector}' matched but no bounds" + ); } } else { tracing::warn!("[action] click selector '{selector}' — no match"); diff --git a/packages/agent-server-rust/src/execution/mod.rs b/packages/agent-server-rust/src/execution/mod.rs index cb17784..8e27316 100644 --- a/packages/agent-server-rust/src/execution/mod.rs +++ b/packages/agent-server-rust/src/execution/mod.rs @@ -1,14 +1,14 @@ pub mod actions; -use base64::Engine; use crate::context::Context; -use crate::ia::{find_state_by_id, identify_states}; +use crate::db::get_db; +use crate::effects::collect_effects; use crate::ia::types::*; +use crate::ia::{find_state_by_id, identify_states}; use crate::tools::a11y::get_a11y_desktop; use crate::tools::exec::ExecOptions; use crate::tools::screenshot::capture_screenshot; -use crate::effects::collect_effects; -use crate::db::get_db; +use base64::Engine; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -73,20 +73,26 @@ where for step in 0..MAX_STEPS { // Check execution timeout if execution_start.elapsed().as_millis() as u64 > EXECUTION_TIMEOUT_MS { - return (ExecutionResult { - success: false, - error: Some(format!( - "Execution timeout after {}s", - execution_start.elapsed().as_secs() - )), - }, plan_state); + return ( + ExecutionResult { + success: false, + error: Some(format!( + "Execution timeout after {}s", + execution_start.elapsed().as_secs() + )), + }, + plan_state, + ); } if cancel.is_cancelled() { - return (ExecutionResult { - success: false, - error: Some("Aborted".to_string()), - }, plan_state); + return ( + ExecutionResult { + success: false, + error: Some("Aborted".to_string()), + }, + plan_state, + ); } // 1. OBSERVE: get a11y tree + screenshot @@ -112,13 +118,16 @@ where let elapsed = unknown_state_since.unwrap().elapsed(); if elapsed.as_millis() as u64 > UNKNOWN_STATE_TIMEOUT_MS { tracing::error!("[exec] Unknown state timeout after {}s", elapsed.as_secs()); - return (ExecutionResult { - success: false, - error: Some(format!( - "Unknown state for {}s - no matching IAState found", - elapsed.as_secs() - )), - }, plan_state); + return ( + ExecutionResult { + success: false, + error: Some(format!( + "Unknown state for {}s - no matching IAState found", + elapsed.as_secs() + )), + }, + plan_state, + ); } tracing::warn!("[exec] Unknown state ({}s), waiting...", elapsed.as_secs()); tokio::time::sleep(std::time::Duration::from_millis(1000)).await; @@ -243,28 +252,38 @@ where // 7. EXECUTE: run the action (emits fire inline via callback) if let Some(sel) = &selected { - actions::execute_action(&sel.action, sel.frame.as_ref(), &exec_options, &a11y, emit).await; + actions::execute_action(&sel.action, sel.frame.as_ref(), &exec_options, &a11y, emit) + .await; } // 8. GOAL CHECK (after action) if plan.is_goal_reached(&context.state, &plan_state) { - return (ExecutionResult { - success: true, - error: None, - }, plan_state); + return ( + ExecutionResult { + success: true, + error: None, + }, + plan_state, + ); } // No action = stuck (only if plan returns None) if selected.is_none() { - return (ExecutionResult { - success: false, - error: Some("No action selected".to_string()), - }, plan_state); + return ( + ExecutionResult { + success: false, + error: Some("No action selected".to_string()), + }, + plan_state, + ); } } - (ExecutionResult { - success: false, - error: Some("Max steps reached".to_string()), - }, plan_state) + ( + ExecutionResult { + success: false, + error: Some("Max steps reached".to_string()), + }, + plan_state, + ) } diff --git a/packages/agent-server-rust/src/ia/helpers.rs b/packages/agent-server-rust/src/ia/helpers.rs index 6fb7fa7..aaac7ab 100644 --- a/packages/agent-server-rust/src/ia/helpers.rs +++ b/packages/agent-server-rust/src/ia/helpers.rs @@ -40,7 +40,11 @@ pub fn get_bounds_center(bounds: &Bounds) -> (f64, f64) { pub fn frame_hint_from_node(node: &A11yNode) -> Option { let bounds = node.bounds.clone()?; Some(FrameHint { - name: if node.name.is_empty() { None } else { Some(node.name.clone()) }, + name: if node.name.is_empty() { + None + } else { + Some(node.name.clone()) + }, bounds, pid: node.window.as_ref().map(|w| w.pid), }) @@ -50,8 +54,16 @@ pub fn frame_hint_from_node(node: &A11yNode) -> Option { /// Walks the tree top-down, preferring deeper frames so we get the tightest /// enclosing frame (e.g. "Settings" frame, not the root desktop-frame). pub fn find_frame_for(a11y: &A11yNode, selector: &str) -> Option { - fn walk<'a>(node: &'a A11yNode, selector: &str, current_frame: Option<&'a A11yNode>) -> Option<&'a A11yNode> { - let frame = if node.role == "frame" { Some(node) } else { current_frame }; + fn walk<'a>( + node: &'a A11yNode, + selector: &str, + current_frame: Option<&'a A11yNode>, + ) -> Option<&'a A11yNode> { + let frame = if node.role == "frame" { + Some(node) + } else { + current_frame + }; // If this subtree contains the target, the deepest frame wins if query_selector(node, selector).is_some() { @@ -70,4 +82,3 @@ pub fn find_frame_for(a11y: &A11yNode, selector: &str) -> Option { } walk(a11y, selector, None).and_then(frame_hint_from_node) } - diff --git a/packages/agent-server-rust/src/ia/mod.rs b/packages/agent-server-rust/src/ia/mod.rs index eb8585d..e2a87c4 100644 --- a/packages/agent-server-rust/src/ia/mod.rs +++ b/packages/agent-server-rust/src/ia/mod.rs @@ -105,7 +105,8 @@ pub fn identify_states(a11y_tree: &A11yNode, screenshot: &str) -> IdentifiedStat } // Stop if we found all slots - if main_window.is_some() && popup.is_some() && contact_card.is_some() && settings.is_some() { + if main_window.is_some() && popup.is_some() && contact_card.is_some() && settings.is_some() + { break; } } diff --git a/packages/agent-server-rust/src/ia/selectors.rs b/packages/agent-server-rust/src/ia/selectors.rs index 230d2f5..7e2a3c4 100644 --- a/packages/agent-server-rust/src/ia/selectors.rs +++ b/packages/agent-server-rust/src/ia/selectors.rs @@ -170,8 +170,12 @@ fn parse_node(token: &str) -> SelectorNode { let value = if let Some(regex_body) = cap.get(6) { let flags = cap.get(7).map(|m| m.as_str()).unwrap_or(""); let mut prefix = String::new(); - if flags.contains('i') { prefix.push_str("(?i)"); } - if flags.contains('s') { prefix.push_str("(?s)"); } + if flags.contains('i') { + prefix.push_str("(?i)"); + } + if flags.contains('s') { + prefix.push_str("(?s)"); + } let pattern = format!("{}{}", prefix, regex_body.as_str()); AttrValue::Regex(Regex::new(&pattern).unwrap_or_else(|_| Regex::new("$^").unwrap())) } else { @@ -192,7 +196,11 @@ fn parse_node(token: &str) -> SelectorNode { index: cap[1].parse().unwrap_or(1), }); - SelectorNode { role, attrs, pseudo } + SelectorNode { + role, + attrs, + pseudo, + } } fn build_ast(tokens: &[String]) -> SelectorAST { @@ -290,10 +298,7 @@ fn matches_node(node: &A11yNode, target: &SelectorNode, sibling_index: Option( - node: &'a A11yNode, - target: &SelectorNode, -) -> Option<&'a A11yNode> { +fn walk_tree_match<'a>(node: &'a A11yNode, target: &SelectorNode) -> Option<&'a A11yNode> { if matches_node(node, target, None) { return Some(node); } @@ -312,10 +317,7 @@ fn walk_tree_match<'a>( } /// Find the first direct child matching the target selector node. -fn walk_children_match<'a>( - node: &'a A11yNode, - target: &SelectorNode, -) -> Option<&'a A11yNode> { +fn walk_children_match<'a>(node: &'a A11yNode, target: &SelectorNode) -> Option<&'a A11yNode> { if let Some(children) = &node.children { for (i, child) in children.iter().enumerate() { if matches_node(child, target, Some(i)) { @@ -458,13 +460,19 @@ mod tests { } fn messages_tree() -> A11yNode { - node("desktop-frame", "main", Some(vec![ - node("list", "Messages", Some(vec![ - node("list-item", "08:35", None), - node("list-item", "Audio2\u{201d}sec\n", None), - node("list-item", "Audio2\u{201d}secUnplay\n", None), - ])), - ])) + node( + "desktop-frame", + "main", + Some(vec![node( + "list", + "Messages", + Some(vec![ + node("list-item", "08:35", None), + node("list-item", "Audio2\u{201d}sec\n", None), + node("list-item", "Audio2\u{201d}secUnplay\n", None), + ]), + )]), + ) } #[test] @@ -480,9 +488,7 @@ mod tests { #[test] fn test_dot_does_not_match_newline_without_s_flag() { - let tree = node("root", "", Some(vec![ - node("item", "Hello\nWorld", None), - ])); + let tree = node("root", "", Some(vec![node("item", "Hello\nWorld", None)])); // Without s flag, . doesn't match \n let results = query_selector_all(&tree, r#"item[name=/Hello.*World/]"#); assert_eq!(results.len(), 0); @@ -494,11 +500,15 @@ mod tests { #[test] fn test_audio_unplay_with_plain_quote() { // Test with ASCII double quote instead of unicode right quote - let tree = node("desktop-frame", "main", Some(vec![ - node("list", "Messages", Some(vec![ - node("list-item", "Audio2\"secUnplay\n", None), - ])), - ])); + let tree = node( + "desktop-frame", + "main", + Some(vec![node( + "list", + "Messages", + Some(vec![node("list-item", "Audio2\"secUnplay\n", None)]), + )]), + ); let results = query_selector_all( &tree, r#"list[name="Messages"] > list-item[name=/^Audio.*Unplay/s]"#, @@ -508,10 +518,14 @@ mod tests { #[test] fn test_regex_case_insensitive_flag() { - let tree = node("root", "", Some(vec![ - node("button", "Submit", None), - node("button", "cancel", None), - ])); + let tree = node( + "root", + "", + Some(vec![ + node("button", "Submit", None), + node("button", "cancel", None), + ]), + ); let results = query_selector_all(&tree, r#"button[name=/submit/i]"#); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Submit"); @@ -520,10 +534,7 @@ mod tests { #[test] fn test_child_combinator_query_selector() { let tree = messages_tree(); - let result = query_selector( - &tree, - r#"list[name="Messages"] > list-item"#, - ); + let result = query_selector(&tree, r#"list[name="Messages"] > list-item"#); assert!(result.is_some(), "should find first list-item child"); assert_eq!(result.unwrap().name, "08:35"); } @@ -531,10 +542,7 @@ mod tests { #[test] fn test_child_combinator_query_selector_all() { let tree = messages_tree(); - let results = query_selector_all( - &tree, - r#"list[name="Messages"] > list-item"#, - ); + let results = query_selector_all(&tree, r#"list[name="Messages"] > list-item"#); assert_eq!(results.len(), 3, "should find all 3 list-item children"); } @@ -548,9 +556,7 @@ mod tests { #[test] fn test_regex_combined_flags() { - let tree = node("root", "", Some(vec![ - node("item", "Hello\nWorld", None), - ])); + let tree = node("root", "", Some(vec![node("item", "Hello\nWorld", None)])); let results = query_selector_all(&tree, r#"item[name=/hello.*world/is]"#); assert_eq!(results.len(), 1); } diff --git a/packages/agent-server-rust/src/ia/states/chat.rs b/packages/agent-server-rust/src/ia/states/chat.rs index 4d831c7..8435ef5 100644 --- a/packages/agent-server-rust/src/ia/states/chat.rs +++ b/packages/agent-server-rust/src/ia/states/chat.rs @@ -1,7 +1,7 @@ +use super::base::extract_window_control_bounds; use crate::ia::helpers::{extract_active_chat_id, find_frame_for}; use crate::ia::selectors::query_selector; use crate::ia::types::*; -use super::base::extract_window_control_bounds; /// Find the More menu dropdown filler (filler containing Settings + Lock buttons). /// Searches recursively since the filler may be nested under application > ... @@ -28,9 +28,11 @@ fn find_more_menu_filler<'a>(node: &'a A11yNode) -> Option<&'a A11yNode> { /// Extract More button bounds and Settings menu item bounds (when More dropdown is open). fn extract_more_menu_bounds(a11y: &A11yNode, state: &mut AppState) { // More button in Navigation toolbar - state.main_window.more_button_bounds = - query_selector(a11y, r#"tool-bar[name="Navigation"] push-button[name="More"]"#) - .and_then(|n| n.bounds.clone()); + state.main_window.more_button_bounds = query_selector( + a11y, + r#"tool-bar[name="Navigation"] push-button[name="More"]"#, + ) + .and_then(|n| n.bounds.clone()); // Settings menu item from More dropdown (only when dropdown is open) state.main_window.settings_menu_item_bounds = find_more_menu_filler(a11y) @@ -51,28 +53,42 @@ fn is_chat_view(a11y: &A11yNode) -> bool { fn find_selected_chat_item(a11y: &A11yNode) -> Option<&A11yNode> { let chat_list = query_selector(a11y, r#"list[name="Chats"]"#)?; - chat_list - .children - .as_ref()? - .iter() - .find(|item| item.states.as_ref().map(|s| s.iter().any(|st| st == "SELECTED")).unwrap_or(false)) + chat_list.children.as_ref()?.iter().find(|item| { + item.states + .as_ref() + .map(|s| s.iter().any(|st| st == "SELECTED")) + .unwrap_or(false) + }) } /// Chat state — no chat selected. struct ChatState; impl IAState for ChatState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "chat" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "chat" + } fn identify(&self, args: &IdentifyArgs) -> Result { if !is_chat_view(args.a11y) { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } if find_selected_chat_item(args.a11y).is_some() { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"list[name="Chats"]"#) }) + Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"list[name="Chats"]"#), + }) } fn reduce(&self, args: &ReduceArgs) -> AppState { @@ -95,17 +111,30 @@ impl IAState for ChatState { struct ChatOpenState; impl IAState for ChatOpenState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "chat_open" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "chat_open" + } fn identify(&self, args: &IdentifyArgs) -> Result { if !is_chat_view(args.a11y) { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } if find_selected_chat_item(args.a11y).is_none() { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"list[name="Chats"]"#) }) + Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"list[name="Chats"]"#), + }) } fn reduce(&self, args: &ReduceArgs) -> AppState { @@ -138,9 +167,14 @@ impl IAState for ChatOpenState { // Detect group via "(n)" suffix let member_count_re = regex::Regex::new(r"\((\d+)\)$").unwrap(); - let is_group = raw_name.as_ref().map(|n| member_count_re.is_match(n)).unwrap_or(false); + let is_group = raw_name + .as_ref() + .map(|n| member_count_re.is_match(n)) + .unwrap_or(false); let opened_chat_name = if is_group { - raw_name.as_ref().map(|n| member_count_re.replace(n, "").trim().to_string()) + raw_name + .as_ref() + .map(|n| member_count_re.replace(n, "").trim().to_string()) } else { raw_name.clone() }; @@ -150,8 +184,8 @@ impl IAState for ChatOpenState { let selected_chat_bounds = selected_item.and_then(|item| item.bounds.clone()); // Active chat ID - let selected_chat_id = extract_active_chat_id(a11y) - .or_else(|| args.prev.main_window.selected_chat_id.clone()); + let selected_chat_id = + extract_active_chat_id(a11y).or_else(|| args.prev.main_window.selected_chat_id.clone()); let mut state = args.prev.clone(); state.main_window.view = MainWindowView::ChatOpen; @@ -179,6 +213,5 @@ fn collect_labels<'a>(node: &'a A11yNode, out: &mut Vec<&'a A11yNode>) { } } -pub static CHAT_STATES: std::sync::LazyLock>> = std::sync::LazyLock::new(|| { - vec![Box::new(ChatState), Box::new(ChatOpenState)] -}); +pub static CHAT_STATES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| vec![Box::new(ChatState), Box::new(ChatOpenState)]); diff --git a/packages/agent-server-rust/src/ia/states/contact_card.rs b/packages/agent-server-rust/src/ia/states/contact_card.rs index 5d13e6b..6e76aad 100644 --- a/packages/agent-server-rust/src/ia/states/contact_card.rs +++ b/packages/agent-server-rust/src/ia/states/contact_card.rs @@ -5,8 +5,12 @@ use crate::ia::types::*; struct ContactCardStateImpl; impl IAState for ContactCardStateImpl { - fn fsm(&self) -> &str { "contactCard" } - fn id(&self) -> &str { "contact_card" } + fn fsm(&self) -> &str { + "contactCard" + } + fn id(&self) -> &str { + "contact_card" + } fn identify(&self, args: &IdentifyArgs) -> Result { let wechat_id_label = query_selector(args.a11y, r#"label[name=/^(WeChat ID:|微信号:)$/]"#); diff --git a/packages/agent-server-rust/src/ia/states/login.rs b/packages/agent-server-rust/src/ia/states/login.rs index 90f1f42..2da542b 100644 --- a/packages/agent-server-rust/src/ia/states/login.rs +++ b/packages/agent-server-rust/src/ia/states/login.rs @@ -1,34 +1,54 @@ +use super::base::extract_window_control_bounds; use crate::ia::helpers::find_frame_for; -use crate::ia::selectors::{query_selector}; +use crate::ia::selectors::query_selector; use crate::ia::types::*; use crate::tools::qr::decode_qr_from_base64; -use super::base::extract_window_control_bounds; /// login_qr: WeChat shows a QR code to scan. struct LoginQrState; impl IAState for LoginQrState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "login_qr" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "login_qr" + } fn identify(&self, args: &IdentifyArgs) -> Result { let scan_label = query_selector(args.a11y, r#"label[name*="Scan to log in"]"#); if scan_label.is_none() { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - let has_transfer = query_selector(args.a11y, r#"push-button[name*="Transfer files only"]"#).is_some(); + let has_transfer = + query_selector(args.a11y, r#"push-button[name*="Transfer files only"]"#).is_some(); if !has_transfer { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } let qr = decode_qr_from_base64(args.screenshot); - let has_wechat_qr = qr.as_ref().map(|r| r.data.starts_with("http://weixin.qq.com/x/")).unwrap_or(false); + let has_wechat_qr = qr + .as_ref() + .map(|r| r.data.starts_with("http://weixin.qq.com/x/")) + .unwrap_or(false); if !has_wechat_qr { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"label[name*="Scan to log in"]"#) }) + Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"label[name*="Scan to log in"]"#), + }) } fn reduce(&self, args: &ReduceArgs) -> AppState { @@ -54,22 +74,36 @@ impl IAState for LoginQrState { struct LoginAccountState; impl IAState for LoginAccountState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "login_account" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "login_account" + } fn identify(&self, args: &IdentifyArgs) -> Result { let log_in_btn = query_selector(args.a11y, r#"push-button[name="Log In"]"#) .or_else(|| query_selector(args.a11y, r#"push-button[name="Open WeChat"]"#)); if log_in_btn.is_none() { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - let has_switch = query_selector(args.a11y, r#"push-button[name="Switch Account"]"#).is_some(); + let has_switch = + query_selector(args.a11y, r#"push-button[name="Switch Account"]"#).is_some(); if !has_switch { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"push-button[name="Switch Account"]"#) }) + Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"push-button[name="Switch Account"]"#), + }) } fn reduce(&self, args: &ReduceArgs) -> AppState { @@ -90,14 +124,28 @@ impl IAState for LoginAccountState { struct LoginPhoneConfirmState; impl IAState for LoginPhoneConfirmState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "login_phone_confirm" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "login_phone_confirm" + } fn identify(&self, args: &IdentifyArgs) -> Result { - let confirm = query_selector(args.a11y, r#"label[name=/Comfirm on phone|Confirm.*phone|手机确认/i]"#); + let confirm = query_selector( + args.a11y, + r#"label[name=/Comfirm on phone|Confirm.*phone|手机确认/i]"#, + ); Ok(IdentifyResult { identified: confirm.is_some(), - frame: if confirm.is_some() { find_frame_for(args.a11y, r#"label[name=/Comfirm on phone|Confirm.*phone|手机确认/i]"#) } else { None }, + frame: if confirm.is_some() { + find_frame_for( + args.a11y, + r#"label[name=/Comfirm on phone|Confirm.*phone|手机确认/i]"#, + ) + } else { + None + }, }) } @@ -113,16 +161,26 @@ impl IAState for LoginPhoneConfirmState { struct LoginLoadingState; impl IAState for LoginLoadingState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "login_loading" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "login_loading" + } fn identify(&self, args: &IdentifyArgs) -> Result { // Case 1: "Entering" or "Loading X%" labels if query_selector(args.a11y, r#"label[name="Entering"]"#).is_some() { - return Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"label[name="Entering"]"#) }); + return Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"label[name="Entering"]"#), + }); } if query_selector(args.a11y, r#"label[name*="Loading"]"#).is_some() { - return Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"label[name*="Loading"]"#) }); + return Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"label[name*="Loading"]"#), + }); } // Case 2: Nav buttons but no Chats list @@ -132,10 +190,16 @@ impl IAState for LoginLoadingState { let has_chats = query_selector(args.a11y, r#"list[name="Chats"]"#).is_some(); if main_btn.is_some() && has_contacts && !has_chats { - return Ok(IdentifyResult { identified: true, frame: find_frame_for(args.a11y, r#"push-button[name="Contacts"]"#) }); + return Ok(IdentifyResult { + identified: true, + frame: find_frame_for(args.a11y, r#"push-button[name="Contacts"]"#), + }); } - Ok(IdentifyResult { identified: false, frame: None }) + Ok(IdentifyResult { + identified: false, + frame: None, + }) } fn reduce(&self, args: &ReduceArgs) -> AppState { @@ -150,8 +214,12 @@ impl IAState for LoginLoadingState { struct NetworkProxySettingsState; impl IAState for NetworkProxySettingsState { - fn fsm(&self) -> &str { "mainWindow" } - fn id(&self) -> &str { "network_proxy_settings" } + fn fsm(&self) -> &str { + "mainWindow" + } + fn id(&self) -> &str { + "network_proxy_settings" + } fn identify(&self, args: &IdentifyArgs) -> Result { let title = query_selector(args.a11y, r#"label[name="Network proxy settings"]"#); @@ -159,7 +227,11 @@ impl IAState for NetworkProxySettingsState { let identified = title.is_some() && checkbox.is_some(); Ok(IdentifyResult { identified, - frame: if identified { find_frame_for(args.a11y, r#"label[name="Network proxy settings"]"#) } else { None }, + frame: if identified { + find_frame_for(args.a11y, r#"label[name="Network proxy settings"]"#) + } else { + None + }, }) } @@ -183,12 +255,13 @@ impl IAState for NetworkProxySettingsState { use base64::Engine; /// All login states (order matters — first match wins). -pub static LOGIN_STATES: std::sync::LazyLock>> = std::sync::LazyLock::new(|| { - vec![ - Box::new(NetworkProxySettingsState), - Box::new(LoginQrState), - Box::new(LoginAccountState), - Box::new(LoginPhoneConfirmState), - Box::new(LoginLoadingState), - ] -}); +pub static LOGIN_STATES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| { + vec![ + Box::new(NetworkProxySettingsState), + Box::new(LoginQrState), + Box::new(LoginAccountState), + Box::new(LoginPhoneConfirmState), + Box::new(LoginLoadingState), + ] + }); diff --git a/packages/agent-server-rust/src/ia/states/popup.rs b/packages/agent-server-rust/src/ia/states/popup.rs index 928a507..d7b06e8 100644 --- a/packages/agent-server-rust/src/ia/states/popup.rs +++ b/packages/agent-server-rust/src/ia/states/popup.rs @@ -10,18 +10,33 @@ fn has_settings_frame(a11y: &A11yNode) -> bool { } impl IAState for PopupErrorState { - fn fsm(&self) -> &str { "popup" } - fn id(&self) -> &str { "popup_error" } + fn fsm(&self) -> &str { + "popup" + } + fn id(&self) -> &str { + "popup_error" + } fn identify(&self, args: &IdentifyArgs) -> Result { // Exclude matches when Settings frame is open (settings_modal handles those) if has_settings_frame(args.a11y) { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } let ok_btn = query_selector(args.a11y, r#"push-button[name="OK"]"#); - let error_text = query_selector(args.a11y, r#"static[name=/error|failed|timeout|失败|错误/i]"#) - .or_else(|| query_selector(args.a11y, r#"label[name=/error|failed|timeout|失败|错误/i]"#)); + let error_text = query_selector( + args.a11y, + r#"static[name=/error|failed|timeout|失败|错误/i]"#, + ) + .or_else(|| { + query_selector( + args.a11y, + r#"label[name=/error|failed|timeout|失败|错误/i]"#, + ) + }); Ok(IdentifyResult { identified: ok_btn.is_some() && error_text.is_some(), @@ -30,8 +45,16 @@ impl IAState for PopupErrorState { } fn reduce(&self, args: &ReduceArgs) -> AppState { - let error_text = query_selector(args.a11y, r#"static[name=/error|failed|timeout|失败|错误/i]"#) - .or_else(|| query_selector(args.a11y, r#"label[name=/error|failed|timeout|失败|错误/i]"#)); + let error_text = query_selector( + args.a11y, + r#"static[name=/error|failed|timeout|失败|错误/i]"#, + ) + .or_else(|| { + query_selector( + args.a11y, + r#"label[name=/error|failed|timeout|失败|错误/i]"#, + ) + }); let mut state = args.prev.clone(); state.popup = Some(PopupState { @@ -46,27 +69,51 @@ impl IAState for PopupErrorState { struct PopupConfirmState; impl IAState for PopupConfirmState { - fn fsm(&self) -> &str { "popup" } - fn id(&self) -> &str { "popup_confirm" } + fn fsm(&self) -> &str { + "popup" + } + fn id(&self) -> &str { + "popup_confirm" + } fn identify(&self, args: &IdentifyArgs) -> Result { // Exclude matches when Settings frame is open (settings_modal handles those) if has_settings_frame(args.a11y) { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } let ok_btn = query_selector(args.a11y, r#"push-button[name=/OK|Confirm|确定|确认/i]"#); if ok_btn.is_none() { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - let error_in_static = query_selector(args.a11y, r#"static[name=/error|failed|timeout|失败|错误/i]"#).is_some(); - let error_in_label = query_selector(args.a11y, r#"label[name=/error|failed|timeout|失败|错误/i]"#).is_some(); + let error_in_static = query_selector( + args.a11y, + r#"static[name=/error|failed|timeout|失败|错误/i]"#, + ) + .is_some(); + let error_in_label = query_selector( + args.a11y, + r#"label[name=/error|failed|timeout|失败|错误/i]"#, + ) + .is_some(); if error_in_static || error_in_label { - return Ok(IdentifyResult { identified: false, frame: None }); + return Ok(IdentifyResult { + identified: false, + frame: None, + }); } - Ok(IdentifyResult { identified: true, frame: None }) + Ok(IdentifyResult { + identified: true, + frame: None, + }) } fn reduce(&self, args: &ReduceArgs) -> AppState { @@ -82,6 +129,5 @@ impl IAState for PopupConfirmState { } } -pub static POPUP_STATES: std::sync::LazyLock>> = std::sync::LazyLock::new(|| { - vec![Box::new(PopupErrorState), Box::new(PopupConfirmState)] -}); +pub static POPUP_STATES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| vec![Box::new(PopupErrorState), Box::new(PopupConfirmState)]); diff --git a/packages/agent-server-rust/src/ia/states/settings.rs b/packages/agent-server-rust/src/ia/states/settings.rs index 45a44b7..04d279e 100644 --- a/packages/agent-server-rust/src/ia/states/settings.rs +++ b/packages/agent-server-rust/src/ia/states/settings.rs @@ -11,13 +11,22 @@ fn find_settings_frame<'a>(a11y: &'a A11yNode) -> Option<&'a A11yNode> { struct SettingsModalState; impl IAState for SettingsModalState { - fn fsm(&self) -> &str { "settings" } - fn id(&self) -> &str { "settings_modal" } + fn fsm(&self) -> &str { + "settings" + } + fn id(&self) -> &str { + "settings_modal" + } fn identify(&self, args: &IdentifyArgs) -> Result { let frame = match find_settings_frame(args.a11y) { Some(f) => f, - None => return Ok(IdentifyResult { identified: false, frame: None }), + None => { + return Ok(IdentifyResult { + identified: false, + frame: None, + }) + } }; let has_ok = query_selector(frame, r#"push-button[name="OK"]"#).is_some(); @@ -51,13 +60,22 @@ impl IAState for SettingsModalState { struct SettingsStateImpl; impl IAState for SettingsStateImpl { - fn fsm(&self) -> &str { "settings" } - fn id(&self) -> &str { "settings" } + fn fsm(&self) -> &str { + "settings" + } + fn id(&self) -> &str { + "settings" + } fn identify(&self, args: &IdentifyArgs) -> Result { let frame = match find_settings_frame(args.a11y) { Some(f) => f, - None => return Ok(IdentifyResult { identified: false, frame: None }), + None => { + return Ok(IdentifyResult { + identified: false, + frame: None, + }) + } }; let has_ok = query_selector(frame, r#"push-button[name="OK"]"#).is_some(); @@ -87,9 +105,7 @@ impl IAState for SettingsStateImpl { /// Settings states — settings_modal first so it takes priority when modal is present. pub static SETTINGS_STATES: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| { - vec![Box::new(SettingsModalState), Box::new(SettingsStateImpl)] - }); + std::sync::LazyLock::new(|| vec![Box::new(SettingsModalState), Box::new(SettingsStateImpl)]); #[cfg(test)] mod tests { @@ -111,10 +127,7 @@ mod tests { fn test_chat_view_no_settings() { let a11y = load_fixture("chat_view.json"); let states = identify_states(&a11y, ""); - assert_eq!( - states.main_window.as_ref().unwrap().state_id, - "chat" - ); + assert_eq!(states.main_window.as_ref().unwrap().state_id, "chat"); assert!(states.settings.is_none()); assert!(states.popup.is_none()); } @@ -123,10 +136,7 @@ mod tests { fn test_more_menu_not_a_popup() { let a11y = load_fixture("more_menu_open.json"); let states = identify_states(&a11y, ""); - assert_eq!( - states.main_window.as_ref().unwrap().state_id, - "chat" - ); + assert_eq!(states.main_window.as_ref().unwrap().state_id, "chat"); // More menu is NOT identified as a popup assert!(states.popup.is_none()); assert!(states.settings.is_none()); @@ -136,14 +146,8 @@ mod tests { fn test_settings_identified() { let a11y = load_fixture("settings_open.json"); let states = identify_states(&a11y, ""); - assert_eq!( - states.main_window.as_ref().unwrap().state_id, - "chat" - ); - assert_eq!( - states.settings.as_ref().unwrap().state_id, - "settings" - ); + assert_eq!(states.main_window.as_ref().unwrap().state_id, "chat"); + assert_eq!(states.settings.as_ref().unwrap().state_id, "settings"); // popup_confirm must NOT false-match inside Settings frame assert!(states.popup.is_none()); } @@ -152,14 +156,8 @@ mod tests { fn test_settings_confirm_modal() { let a11y = load_fixture("settings_confirm.json"); let states = identify_states(&a11y, ""); - assert_eq!( - states.main_window.as_ref().unwrap().state_id, - "chat" - ); - assert_eq!( - states.settings.as_ref().unwrap().state_id, - "settings_modal" - ); + assert_eq!(states.main_window.as_ref().unwrap().state_id, "chat"); + assert_eq!(states.settings.as_ref().unwrap().state_id, "settings_modal"); // popup must NOT false-match assert!(states.popup.is_none()); } diff --git a/packages/agent-server-rust/src/ia/types.rs b/packages/agent-server-rust/src/ia/types.rs index 1168ece..eb047c9 100644 --- a/packages/agent-server-rust/src/ia/types.rs +++ b/packages/agent-server-rust/src/ia/types.rs @@ -406,6 +406,43 @@ pub struct ReplyInfo { pub content: String, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct PaymentInfo { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub app_msg_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub amount_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub amount_cents: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transaction_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transfer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub send_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub pay_msg_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub receiver_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub native_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] @@ -424,6 +461,10 @@ pub struct Message { #[serde(rename = "type")] #[ts(rename = "type")] pub msg_type: i32, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub app_msg_type: Option, pub content: String, pub timestamp: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -434,7 +475,16 @@ pub struct Message { pub is_self: Option, #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub is_received: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub received_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub reply: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub payment: Option, } // ============================================ @@ -545,6 +595,47 @@ pub struct SendResult { pub error: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ReceivePaymentResult { + pub success: bool, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub local_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub is_received: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub received_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub amount_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub amount_cents: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transaction_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transfer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub send_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub pay_msg_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] diff --git a/packages/agent-server-rust/src/main.rs b/packages/agent-server-rust/src/main.rs index 2c4ddcc..28f82bb 100644 --- a/packages/agent-server-rust/src/main.rs +++ b/packages/agent-server-rust/src/main.rs @@ -5,10 +5,10 @@ mod db; mod effects; mod execution; mod ia; -mod tools; mod plans; mod router; mod sessions; +mod tools; use std::net::SocketAddr; use tracing_subscriber::EnvFilter; diff --git a/packages/agent-server-rust/src/plans/auth_status.rs b/packages/agent-server-rust/src/plans/auth_status.rs index 6cfbd24..b2c06b3 100644 --- a/packages/agent-server-rust/src/plans/auth_status.rs +++ b/packages/agent-server-rust/src/plans/auth_status.rs @@ -10,9 +10,13 @@ impl Plan for AuthStatusPlan { type PlanState = (); type Params = AuthStatusParams; - fn id(&self) -> &str { "auth_status" } + fn id(&self) -> &str { + "auth_status" + } - fn initial_plan_state(&self) -> () { () } + fn initial_plan_state(&self) -> () { + () + } /// Goal reached immediately — we just want one observation. fn is_goal_reached(&self, _state: &AppState, _plan_state: &()) -> bool { diff --git a/packages/agent-server-rust/src/plans/chat_open.rs b/packages/agent-server-rust/src/plans/chat_open.rs index df7970e..ed0fc8c 100644 --- a/packages/agent-server-rust/src/plans/chat_open.rs +++ b/packages/agent-server-rust/src/plans/chat_open.rs @@ -29,9 +29,9 @@ fn find_edit_area(a11y: &A11yNode) -> Option<&A11yNode> { fn find_edit_near_send(node: &A11yNode) -> Option<&A11yNode> { if let Some(children) = &node.children { - let has_send = children.iter().any(|c| { - c.role == "push-button" && c.name == "Send(S)" - }); + let has_send = children + .iter() + .any(|c| c.role == "push-button" && c.name == "Send(S)"); let edit_node = children.iter().find(|c| { c.role == "text" && c.states @@ -61,7 +61,9 @@ impl Plan for ChatOpenPlan { type PlanState = ChatOpenPlanState; type Params = ChatOpenParams; - fn id(&self) -> &str { "chat_open" } + fn id(&self) -> &str { + "chat_open" + } fn initial_plan_state(&self) -> ChatOpenPlanState { ChatOpenPlanState { @@ -87,7 +89,10 @@ impl Plan for ChatOpenPlan { if state.popup.is_some() && identified.popup.is_some() { return Some(SelectedAction { action: actions::dismiss_popup(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -128,7 +133,10 @@ impl Plan for ChatOpenPlan { if !skipped { return Some(SelectedAction { action: actions::wait_short(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } continue; @@ -139,7 +147,10 @@ impl Plan for ChatOpenPlan { tracing::info!("[chat_open] Opening → Done (no clear_unreads)"); return Some(SelectedAction { action: actions::wait_short(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -158,12 +169,18 @@ impl Plan for ChatOpenPlan { }; plan_state.phase = ChatOpenPhase::ClickingAudio; - tracing::info!("[chat_open] Focusing → ClickingAudio, edit_bounds={:?}", edit_node.bounds); + tracing::info!( + "[chat_open] Focusing → ClickingAudio, edit_bounds={:?}", + edit_node.bounds + ); if let Some(bounds) = &edit_node.bounds { return Some(SelectedAction { action: actions::click_bounds(bounds), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } continue; @@ -171,7 +188,10 @@ impl Plan for ChatOpenPlan { ChatOpenPhase::ClickingAudio => { if main_state_id != Some("chat_open") { - tracing::info!("[chat_open] ClickingAudio: wrong state {:?}", main_state_id); + tracing::info!( + "[chat_open] ClickingAudio: wrong state {:?}", + main_state_id + ); return None; } @@ -190,20 +210,30 @@ impl Plan for ChatOpenPlan { seq.push(Action::Wait { ms: 500 }); } } - tracing::info!("[chat_open] ClickingAudio: found {} unplayed, sequence of {} actions", unplayed.len(), seq.len()); + tracing::info!( + "[chat_open] ClickingAudio: found {} unplayed, sequence of {} actions", + unplayed.len(), + seq.len() + ); plan_state.phase = ChatOpenPhase::Done; if seq.is_empty() { return Some(SelectedAction { action: actions::wait_short(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } return Some(SelectedAction { action: Action::Sequence { actions: seq }, - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } diff --git a/packages/agent-server-rust/src/plans/login.rs b/packages/agent-server-rust/src/plans/login.rs index 31cad1f..87b8da3 100644 --- a/packages/agent-server-rust/src/plans/login.rs +++ b/packages/agent-server-rust/src/plans/login.rs @@ -35,7 +35,9 @@ impl Plan for LoginPlan { type PlanState = LoginPlanState; type Params = LoginParams; - fn id(&self) -> &str { "login" } + fn id(&self) -> &str { + "login" + } fn initial_plan_state(&self) -> LoginPlanState { LoginPlanState { @@ -64,7 +66,12 @@ impl Plan for LoginPlan { _a11y: &A11yNode, session_id: &str, ) -> Option { - let frame = || identified.main_window.as_ref().and_then(|m| m.frame.clone()); + let frame = || { + identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()) + }; // Dismiss popups first if state.popup.is_some() && identified.popup.is_some() { @@ -75,15 +82,14 @@ impl Plan for LoginPlan { } match plan_state.phase.clone() { - LoginPhase::Initializing => { - handle_initializing(state, params, plan_state, &frame) - } - LoginPhase::Authenticating => { - handle_authenticating(state, params, plan_state, &frame) - } + LoginPhase::Initializing => handle_initializing(state, params, plan_state, &frame), + LoginPhase::Authenticating => handle_authenticating(state, params, plan_state, &frame), LoginPhase::Maximizing => { plan_state.phase = LoginPhase::DetectingUser; - Some(SelectedAction { action: actions::wait(500), frame: None }) + Some(SelectedAction { + action: actions::wait(500), + frame: None, + }) } LoginPhase::DetectingUser => { handle_detecting_user(state, plan_state, session_id, frame()).await @@ -91,9 +97,10 @@ impl Plan for LoginPlan { LoginPhase::ExtractingKeys => { handle_extracting_keys(plan_state, session_id, frame()).await } - LoginPhase::Done => { - Some(SelectedAction { action: actions::wait_short(), frame: None }) - } + LoginPhase::Done => Some(SelectedAction { + action: actions::wait_short(), + frame: None, + }), } } } @@ -116,7 +123,10 @@ fn handle_initializing( } else { actions::click_back() }; - Some(SelectedAction { action, frame: frame() }) + Some(SelectedAction { + action, + frame: frame(), + }) } _ => { plan_state.phase = LoginPhase::Authenticating; @@ -142,9 +152,12 @@ fn handle_authenticating( Action::Emit { event: SubscriptionEvent { event_type: "qr".to_string(), - data: [ - ("qrData".to_string(), serde_json::Value::String(qr.clone())), - ].into_iter().collect(), + data: [( + "qrData".to_string(), + serde_json::Value::String(qr.clone()), + )] + .into_iter() + .collect(), }, }, actions::wait(500), @@ -153,7 +166,10 @@ fn handle_authenticating( }); } } - Some(SelectedAction { action: actions::wait(500), frame: None }) + Some(SelectedAction { + action: actions::wait(500), + frame: None, + }) } MainWindowView::LoginAccount => { @@ -162,7 +178,10 @@ fn handle_authenticating( } else { actions::click_login() }; - Some(SelectedAction { action, frame: frame() }) + Some(SelectedAction { + action, + frame: frame(), + }) } MainWindowView::LoginPhoneConfirm => { @@ -175,8 +194,12 @@ fn handle_authenticating( event_type: "phone_confirm".to_string(), data: [( "message".to_string(), - serde_json::Value::String("Please confirm login on your phone".to_string()), - )].into_iter().collect(), + serde_json::Value::String( + "Please confirm login on your phone".to_string(), + ), + )] + .into_iter() + .collect(), }, }, actions::wait(500), @@ -184,12 +207,16 @@ fn handle_authenticating( frame: frame(), }); } - Some(SelectedAction { action: actions::wait(500), frame: None }) + Some(SelectedAction { + action: actions::wait(500), + frame: None, + }) } - MainWindowView::LoginLoading => { - Some(SelectedAction { action: actions::wait(500), frame: None }) - } + MainWindowView::LoginLoading => Some(SelectedAction { + action: actions::wait(500), + frame: None, + }), MainWindowView::Chat | MainWindowView::ChatOpen => { plan_state.phase = LoginPhase::Maximizing; @@ -210,7 +237,10 @@ fn handle_authenticating( } else { actions::click_back() }; - Some(SelectedAction { action, frame: frame() }) + Some(SelectedAction { + action, + frame: frame(), + }) } } } @@ -221,8 +251,14 @@ async fn handle_detecting_user( session_id: &str, frame: Option, ) -> Option { - if !matches!(state.main_window.view, MainWindowView::Chat | MainWindowView::ChatOpen) { - return Some(SelectedAction { action: actions::wait(500), frame: None }); + if !matches!( + state.main_window.view, + MainWindowView::Chat | MainWindowView::ChatOpen + ) { + return Some(SelectedAction { + action: actions::wait(500), + frame: None, + }); } // All DB access is scoped in blocks so MutexGuard is dropped before any await @@ -250,7 +286,8 @@ async fn handle_detecting_user( db.execute( "UPDATE sessions SET wechat_pid = ?1, updated_at = ?2 WHERE id = ?3", params![pid, now, session_id], - ).ok(); + ) + .ok(); } } @@ -272,10 +309,9 @@ async fn handle_detecting_user( Action::Emit { event: SubscriptionEvent { event_type: "login_success".to_string(), - data: [( - "userId".to_string(), - serde_json::Value::String(acct), - )].into_iter().collect(), + data: [("userId".to_string(), serde_json::Value::String(acct))] + .into_iter() + .collect(), }, }, actions::wait_short(), @@ -293,8 +329,12 @@ async fn handle_detecting_user( event_type: "status".to_string(), data: [( "message".to_string(), - serde_json::Value::String("Getting your WeChat messages...".to_string()), - )].into_iter().collect(), + serde_json::Value::String( + "Getting your WeChat messages...".to_string(), + ), + )] + .into_iter() + .collect(), }, }, actions::wait_short(), @@ -320,7 +360,10 @@ async fn handle_detecting_user( }); } - Some(SelectedAction { action: actions::wait(2000), frame: None }) + Some(SelectedAction { + action: actions::wait(2000), + frame: None, + }) } async fn handle_extracting_keys( @@ -358,11 +401,14 @@ async fn handle_extracting_keys( Action::Emit { event: SubscriptionEvent { event_type: "login_success".to_string(), - data: plan_state.account_dir.as_ref() - .map(|a| [( - "userId".to_string(), - serde_json::Value::String(a.clone()), - )].into_iter().collect()) + data: plan_state + .account_dir + .as_ref() + .map(|a| { + [("userId".to_string(), serde_json::Value::String(a.clone()))] + .into_iter() + .collect() + }) .unwrap_or_default(), }, }, diff --git a/packages/agent-server-rust/src/plans/logout.rs b/packages/agent-server-rust/src/plans/logout.rs index 603a6d1..70342b9 100644 --- a/packages/agent-server-rust/src/plans/logout.rs +++ b/packages/agent-server-rust/src/plans/logout.rs @@ -11,9 +11,13 @@ impl Plan for LogoutPlan { type PlanState = (); type Params = LogoutParams; - fn id(&self) -> &str { "logout" } + fn id(&self) -> &str { + "logout" + } - fn initial_plan_state(&self) -> () { () } + fn initial_plan_state(&self) -> () { + () + } fn is_goal_reached(&self, state: &AppState, _plan_state: &()) -> bool { !state.main_window.is_logged_in @@ -29,7 +33,10 @@ impl Plan for LogoutPlan { _session_id: &str, ) -> Option { let settings_frame = identified.settings.as_ref().and_then(|s| s.frame.clone()); - let main_frame = identified.main_window.as_ref().and_then(|m| m.frame.clone()); + let main_frame = identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()); // 1. Settings confirmation modal → click OK if let Some(ref settings) = state.settings { diff --git a/packages/agent-server-rust/src/plans/mod.rs b/packages/agent-server-rust/src/plans/mod.rs index 94feb86..fb8b298 100644 --- a/packages/agent-server-rust/src/plans/mod.rs +++ b/packages/agent-server-rust/src/plans/mod.rs @@ -2,9 +2,10 @@ pub mod auth_status; pub mod chat_open; pub mod login; pub mod logout; +pub mod receive_transfer; pub mod send_message; -use crate::ia::types::{AppState, IdentifiedStates, SelectedAction, A11yNode}; +use crate::ia::types::{A11yNode, AppState, IdentifiedStates, SelectedAction}; /// Plan trait — defines a goal-oriented sequence of actions. /// @@ -20,11 +21,7 @@ pub trait Plan: Send + Sync { fn initial_plan_state(&self) -> Self::PlanState; - fn is_goal_reached( - &self, - state: &AppState, - plan_state: &Self::PlanState, - ) -> bool; + fn is_goal_reached(&self, state: &AppState, plan_state: &Self::PlanState) -> bool; async fn select_action( &self, diff --git a/packages/agent-server-rust/src/plans/receive_transfer.rs b/packages/agent-server-rust/src/plans/receive_transfer.rs new file mode 100644 index 0000000..6bd3a15 --- /dev/null +++ b/packages/agent-server-rust/src/plans/receive_transfer.rs @@ -0,0 +1,687 @@ +use super::Plan; +use crate::ia::actions; +use crate::ia::helpers::{find_frame_for, frame_hint_from_node}; +use crate::ia::selectors::query_selector; +use crate::ia::types::*; +use crate::tools::chat_select::{open_chat, OpenChatResult}; + +const ACCEPT_SELECTOR: &str = r#"push-button[name=/^(确认收钱|Accept|Receive)$/]"#; +const SUCCESS_SELECTOR: &str = + r#"*[name=/已(收钱|接收|领取)|收款成功|接收成功|领取成功|已存入|Accepted|Received|Success/i]"#; +const DIALOG_CLOSE_SELECTOR: &str = + r#"push-button[name=/^(Disable|Close|关闭|完成|Done|OK|确定|确认|好|好的|知道了)$/]"#; +const WINDOW_CLOSE_SELECTOR: &str = r#"tool-bar push-button[name="Disable"]"#; +const MAIN_CHAT_SELECTOR: &str = r#"list[name="Chats"]"#; + +pub struct ReceiveTransferPlan; + +pub struct ReceiveTransferParams { + pub chat_id: String, + pub transaction_id: Option, + pub amount_text: Option, + pub is_self: bool, + pub explicit_target: bool, +} + +pub enum ReceiveTransferPhase { + OpeningChat, + ClickingTransfer, + ClickingReceive, + WaitingSuccess, + ClosingSuccess, + Done, +} + +pub struct ReceiveTransferPlanState { + pub phase: ReceiveTransferPhase, + pub open_result: Option, + pub find_attempts: u32, + pub receive_attempts: u32, + pub success_attempts: u32, + pub close_attempts: u32, + pub received: bool, +} + +fn normalize_amount_text(text: &str) -> String { + text.chars() + .filter(|c| c.is_ascii_digit() || *c == '.') + .collect() +} + +fn is_receivable_transfer_item_name(name: &str, expected_amount: Option<&str>) -> bool { + let lower = name.to_lowercase(); + let transfer_like = name.contains("微信转账") + || lower.contains("wechat transfer") + || lower.contains("confirm receipt") + || name.contains("确认收钱") + || name.contains("确认收款") + || name.contains("待收钱") + || name.contains("待接收") + || name.contains("收钱"); + + if !transfer_like { + return false; + } + + let received_like = lower.contains("accepted") + || lower.contains("received") + || name.contains("已收") + || name.contains("已接收") + || name.contains("已领取") + || name.contains("已存入"); + let receivable_like = lower.contains("confirm receipt") + || (lower.contains("receive") && !received_like) + || name.contains("确认收钱") + || name.contains("确认收款") + || name.contains("待收钱") + || name.contains("待接收") + || (name.contains("收钱") && !received_like); + + if !receivable_like || received_like { + return false; + } + + if let Some(expected_amount) = expected_amount { + let hint = normalize_amount_text(expected_amount); + if !hint.is_empty() { + return normalize_amount_text(name).contains(&hint); + } + } + + true +} + +fn find_transfer_message<'a>( + a11y: &'a A11yNode, + expected_amount: Option<&str>, +) -> Option<&'a A11yNode> { + let list = query_selector(a11y, r#"list[name="Messages"]"#)?; + let children = list.children.as_ref()?; + children.iter().rev().find(|node| { + node.role == "list-item" && is_receivable_transfer_item_name(&node.name, expected_amount) + }) +} + +fn message_list_bounds(a11y: &A11yNode) -> Option<&Bounds> { + query_selector(a11y, r#"list[name="Messages"]"#)? + .bounds + .as_ref() +} + +fn click_transfer_card(bounds: &Bounds, is_self: bool) -> Action { + let max_offset = (bounds.width - 24.0).max(bounds.width / 2.0); + let min_offset = 160.0_f64.min(max_offset); + let x_offset = (bounds.width * 0.24).clamp(min_offset, max_offset); + let x = if is_self { + bounds.x + bounds.width - x_offset + } else { + bounds.x + x_offset + }; + actions::click_at(x.round(), (bounds.y + bounds.height / 2.0).round()) +} + +fn is_transfer_dialog_frame(frame: &A11yNode) -> bool { + query_selector(frame, MAIN_CHAT_SELECTOR).is_none() + && query_selector(frame, r#"list[name="Messages"]"#).is_none() +} + +fn find_transfer_dialog_frame_by<'a, F>(a11y: &'a A11yNode, predicate: &F) -> Option<&'a A11yNode> +where + F: Fn(&A11yNode) -> bool, +{ + fn walk<'a, F>(node: &'a A11yNode, predicate: &F) -> Option<&'a A11yNode> + where + F: Fn(&A11yNode) -> bool, + { + let mut best: Option<&'a A11yNode> = None; + + if let Some(children) = &node.children { + for child in children { + if let Some(frame) = walk(child, predicate) { + best = Some(frame); + } + } + } + + if best.is_some() { + return best; + } + + if node.role == "frame" && is_transfer_dialog_frame(node) && predicate(node) { + return Some(node); + } + + None + } + + walk(a11y, predicate) +} + +fn find_transfer_dialog_frame<'a>(a11y: &'a A11yNode, selector: &str) -> Option<&'a A11yNode> { + find_transfer_dialog_frame_by(a11y, &|frame| query_selector(frame, selector).is_some()) +} + +fn find_accept_button(a11y: &A11yNode) -> Option<(&A11yNode, Option)> { + if let Some(frame) = find_transfer_dialog_frame(a11y, ACCEPT_SELECTOR) { + return query_selector(frame, ACCEPT_SELECTOR) + .map(|btn| (btn, frame_hint_from_node(frame))); + } + + query_selector(a11y, ACCEPT_SELECTOR).map(|btn| (btn, find_frame_for(a11y, ACCEPT_SELECTOR))) +} + +fn find_success_dialog_frame(a11y: &A11yNode) -> Option<&A11yNode> { + find_transfer_dialog_frame_by(a11y, &|frame| { + query_selector(frame, SUCCESS_SELECTOR).is_some() + && query_selector(frame, ACCEPT_SELECTOR).is_none() + }) +} + +fn has_receive_success(a11y: &A11yNode) -> bool { + find_success_dialog_frame(a11y).is_some() +} + +fn find_close_button_in_frame(frame: &A11yNode) -> Option<(&A11yNode, Option)> { + query_selector(frame, DIALOG_CLOSE_SELECTOR) + .or_else(|| query_selector(frame, WINDOW_CLOSE_SELECTOR)) + .map(|btn| (btn, frame_hint_from_node(frame))) +} + +fn find_any_transfer_dialog_close_button( + a11y: &A11yNode, +) -> Option<(&A11yNode, Option)> { + if let Some(frame) = find_transfer_dialog_frame(a11y, DIALOG_CLOSE_SELECTOR) { + if let Some(button) = find_close_button_in_frame(frame) { + return Some(button); + } + } + + let frame = find_transfer_dialog_frame(a11y, WINDOW_CLOSE_SELECTOR)?; + find_close_button_in_frame(frame) +} + +fn find_success_close_button(a11y: &A11yNode) -> Option<(&A11yNode, Option)> { + let frame = find_success_dialog_frame(a11y)?; + find_close_button_in_frame(frame) +} + +fn receipt_completed_in_chat( + a11y: &A11yNode, + main_state_id: Option<&str>, + expected_amount: Option<&str>, +) -> bool { + main_state_id == Some("chat_open") + && query_selector(a11y, r#"list[name="Messages"]"#).is_some() + && find_accept_button(a11y).is_none() + && find_transfer_message(a11y, expected_amount).is_none() +} + +#[async_trait::async_trait] +impl Plan for ReceiveTransferPlan { + type PlanState = ReceiveTransferPlanState; + type Params = ReceiveTransferParams; + + fn id(&self) -> &str { + "receive_transfer" + } + + fn initial_plan_state(&self) -> ReceiveTransferPlanState { + ReceiveTransferPlanState { + phase: ReceiveTransferPhase::OpeningChat, + open_result: None, + find_attempts: 0, + receive_attempts: 0, + success_attempts: 0, + close_attempts: 0, + received: false, + } + } + + fn is_goal_reached(&self, _state: &AppState, plan_state: &ReceiveTransferPlanState) -> bool { + matches!(plan_state.phase, ReceiveTransferPhase::Done) && plan_state.received + } + + async fn select_action( + &self, + state: &AppState, + params: &ReceiveTransferParams, + identified: &IdentifiedStates, + plan_state: &mut ReceiveTransferPlanState, + a11y: &A11yNode, + _session_id: &str, + ) -> Option { + let main_state_id = identified.main_window.as_ref().map(|m| m.state_id.as_str()); + + // Dismiss other popups if unexpected + if state.popup.is_some() + && identified.popup.is_some() + && matches!( + plan_state.phase, + ReceiveTransferPhase::ClickingTransfer | ReceiveTransferPhase::OpeningChat + ) + { + return Some(SelectedAction { + action: actions::dismiss_popup(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + loop { + match plan_state.phase { + ReceiveTransferPhase::OpeningChat => { + if main_state_id != Some("chat") && main_state_id != Some("chat_open") { + return None; // Wait for app to be ready + } + + let chat_list_item = query_selector(a11y, r#"list[name="Chats"] > list-item"#); + let click_xy = chat_list_item.and_then(|item| { + item.bounds.as_ref().map(|b| { + ( + (b.x + b.width / 2.0).round(), + (b.y + b.height / 2.0).round(), + ) + }) + }); + + let force = main_state_id == Some("chat"); + let result = open_chat(¶ms.chat_id, force, click_xy).await; + + if !result.ok { + return None; // open_chat failed + } + + let skipped = result.skipped.unwrap_or(false); + plan_state.open_result = Some(result); + plan_state.phase = ReceiveTransferPhase::ClickingTransfer; + + if !skipped { + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + continue; + } + + ReceiveTransferPhase::ClickingTransfer => { + if main_state_id != Some("chat_open") { + return None; + } + + let transfer_node = find_transfer_message(a11y, params.amount_text.as_deref()); + if let Some(node) = transfer_node { + if let Some(bounds) = &node.bounds { + plan_state.phase = ReceiveTransferPhase::ClickingReceive; + return Some(SelectedAction { + action: actions::sequence(vec![ + click_transfer_card(bounds, params.is_self), + actions::wait_short(), + ]), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + } + + plan_state.find_attempts += 1; + if plan_state.find_attempts > 12 { + return None; + } + + if let Some(bounds) = message_list_bounds(a11y) { + return Some(SelectedAction { + action: actions::sequence(vec![ + actions::click_bounds(bounds), + Action::Scroll { + direction: ScrollDirection::Up, + x: None, + y: None, + amount: Some(if params.explicit_target { 5 } else { 3 }), + }, + actions::wait_short(), + ]), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + ReceiveTransferPhase::ClickingReceive => { + if has_receive_success(a11y) { + plan_state.phase = ReceiveTransferPhase::ClosingSuccess; + continue; + } + + if receipt_completed_in_chat(a11y, main_state_id, params.amount_text.as_deref()) + { + plan_state.received = true; + plan_state.phase = ReceiveTransferPhase::Done; + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + if let Some((btn, frame)) = find_accept_button(a11y) { + if let Some(bounds) = &btn.bounds { + if plan_state.receive_attempts >= 5 { + return None; + } + plan_state.receive_attempts += 1; + plan_state.phase = ReceiveTransferPhase::WaitingSuccess; + return Some(SelectedAction { + action: actions::sequence(vec![ + actions::click_bounds(bounds), + actions::wait_short(), + ]), + frame: frame.or_else(|| { + identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()) + }), + }); + } + } + + plan_state.receive_attempts += 1; + if plan_state.receive_attempts > 20 { + // Timeout waiting for popup + return None; + } + + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + ReceiveTransferPhase::WaitingSuccess => { + if has_receive_success(a11y) { + plan_state.phase = ReceiveTransferPhase::ClosingSuccess; + continue; + } + + if receipt_completed_in_chat(a11y, main_state_id, params.amount_text.as_deref()) + { + plan_state.received = true; + plan_state.phase = ReceiveTransferPhase::Done; + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + plan_state.success_attempts += 1; + if plan_state.success_attempts > 20 { + return None; + } + + return Some(SelectedAction { + action: actions::wait_short(), + frame: find_frame_for(a11y, ACCEPT_SELECTOR).or_else(|| { + identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()) + }), + }); + } + + ReceiveTransferPhase::ClosingSuccess => { + if let Some((btn, frame)) = find_success_close_button(a11y) { + if let Some(bounds) = &btn.bounds { + plan_state.received = true; + plan_state.phase = ReceiveTransferPhase::Done; + return Some(SelectedAction { + action: actions::sequence(vec![ + actions::click_bounds(bounds), + actions::wait_short(), + ]), + frame: frame.or_else(|| { + identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()) + }), + }); + } + } + + if !has_receive_success(a11y) { + if receipt_completed_in_chat( + a11y, + main_state_id, + params.amount_text.as_deref(), + ) { + plan_state.received = true; + plan_state.phase = ReceiveTransferPhase::Done; + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + } + + if receipt_completed_in_chat(a11y, main_state_id, params.amount_text.as_deref()) + { + plan_state.received = true; + plan_state.phase = ReceiveTransferPhase::Done; + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + plan_state.close_attempts += 1; + if plan_state.close_attempts > 10 { + return None; + } + + return Some(SelectedAction { + action: actions::wait_short(), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), + }); + } + + ReceiveTransferPhase::Done => return None, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + click_transfer_card, find_success_dialog_frame, find_transfer_dialog_frame, + is_receivable_transfer_item_name, SUCCESS_SELECTOR, WINDOW_CLOSE_SELECTOR, + }; + use crate::ia::selectors::query_selector; + use crate::ia::types::{A11yNode, Action, Bounds}; + + #[test] + fn transfer_item_matcher_only_accepts_receivable_cards() { + assert!(is_receivable_transfer_item_name( + "¥0.10 Confirm receipt WeChat Transfer", + Some("¥0.10") + )); + assert!(!is_receivable_transfer_item_name( + "¥0.10 Accepted WeChat Transfer", + Some("¥0.10") + )); + assert!(!is_receivable_transfer_item_name( + "¥0.20 Confirm receipt WeChat Transfer", + Some("¥0.10") + )); + } + + #[test] + fn transfer_card_click_uses_message_bubble_side() { + let bounds = Bounds { + x: 273.0, + y: 505.0, + width: 1004.0, + height: 111.0, + }; + + let incoming = click_transfer_card(&bounds, false); + let outgoing = click_transfer_card(&bounds, true); + + match incoming { + Action::ClickCoords { x, y } => { + assert!(x > 430.0); + assert!(x < 620.0); + assert_eq!(y, 561.0); + } + _ => panic!("expected incoming click coords"), + } + + match outgoing { + Action::ClickCoords { x, y } => { + assert!(x > 930.0); + assert!(x < 1120.0); + assert_eq!(y, 561.0); + } + _ => panic!("expected outgoing click coords"), + } + } + + fn node(role: &str, name: &str, children: Vec) -> A11yNode { + A11yNode { + role: role.to_string(), + name: name.to_string(), + bounds: None, + children: if children.is_empty() { + None + } else { + Some(children) + }, + parent_index: None, + window: None, + states: None, + } + } + + #[test] + fn dialog_frame_search_skips_main_chat_frame() { + let main_frame = node( + "frame", + "Weixin", + vec![ + node("tool-bar", "", vec![node("push-button", "Disable", vec![])]), + node("list", "Chats", vec![]), + node( + "list", + "Messages", + vec![node("list-item", "¥1.00 Accepted WeChat Transfer", vec![])], + ), + ], + ); + let dialog_frame = node( + "frame", + "Weixin", + vec![ + node("tool-bar", "", vec![node("push-button", "Disable", vec![])]), + node( + "label", + "You've accepted the transfer. The money has been deposited to your Balance.", + vec![], + ), + ], + ); + let desktop = node( + "desktop-frame", + "main", + vec![node( + "application", + "wechat", + vec![main_frame, dialog_frame.clone()], + )], + ); + + let success_frame = find_transfer_dialog_frame(&desktop, SUCCESS_SELECTOR); + let close_frame = find_transfer_dialog_frame(&desktop, WINDOW_CLOSE_SELECTOR); + + assert!(success_frame.is_some()); + assert!(close_frame.is_some()); + assert!(success_frame.is_some_and(|frame| frame.name == dialog_frame.name)); + assert!(close_frame.is_some_and(|frame| frame.name == dialog_frame.name)); + } + + #[test] + fn success_dialog_requires_success_text_without_accept_button() { + let pending_dialog = node( + "frame", + "Weixin", + vec![ + node("label", "Transfer", vec![]), + node("push-button", "Accept", vec![]), + node("tool-bar", "", vec![node("push-button", "Disable", vec![])]), + ], + ); + let success_dialog = node( + "frame", + "Weixin", + vec![ + node( + "label", + "You've accepted the transfer. The money has been deposited to your Balance.", + vec![], + ), + node("tool-bar", "", vec![node("push-button", "Disable", vec![])]), + ], + ); + let desktop = node( + "desktop-frame", + "main", + vec![node( + "application", + "wechat", + vec![pending_dialog, success_dialog.clone()], + )], + ); + + let frame = find_success_dialog_frame(&desktop); + + assert!(frame.is_some_and(|candidate| { + query_selector(candidate, r#"push-button[name="Accept"]"#).is_none() + && candidate.name == success_dialog.name + })); + } +} diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index 815f8d6..b8024b9 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -47,9 +47,9 @@ fn find_edit_near_send<'a>( fn find_edit_send_pair(node: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { if let Some(children) = &node.children { - let send_btn = children.iter().find(|c| { - c.role == "push-button" && c.name == "Send(S)" - }); + let send_btn = children + .iter() + .find(|c| c.role == "push-button" && c.name == "Send(S)"); let edit_node = children.iter().find(|c| { c.role == "text" && c.states @@ -77,7 +77,9 @@ impl Plan for SendMessagePlan { type PlanState = SendMessagePlanState; type Params = SendMessageParams; - fn id(&self) -> &str { "send_message" } + fn id(&self) -> &str { + "send_message" + } fn initial_plan_state(&self) -> SendMessagePlanState { SendMessagePlanState { @@ -106,7 +108,10 @@ impl Plan for SendMessagePlan { if state.popup.is_some() && identified.popup.is_some() { return Some(SelectedAction { action: actions::dismiss_popup(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -119,10 +124,12 @@ impl Plan for SendMessagePlan { let chat_list_item = query_selector(a11y, r#"list[name="Chats"] > list-item"#); let click_xy = chat_list_item.and_then(|item| { - item.bounds.as_ref().map(|b| ( - (b.x + b.width / 2.0).round(), - (b.y + b.height / 2.0).round(), - )) + item.bounds.as_ref().map(|b| { + ( + (b.x + b.width / 2.0).round(), + (b.y + b.height / 2.0).round(), + ) + }) }); let force = main_state_id == Some("chat"); @@ -139,7 +146,10 @@ impl Plan for SendMessagePlan { if !skipped { return Some(SelectedAction { action: actions::wait_short(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } continue; @@ -171,7 +181,10 @@ impl Plan for SendMessagePlan { if let Some(bounds) = &edit_node.bounds { return Some(SelectedAction { action: actions::click_bounds(bounds), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } return None; @@ -191,9 +204,14 @@ impl Plan for SendMessagePlan { return Some(SelectedAction { action: actions::sequence(vec![ Action::Wait { ms: 100 }, - Action::Key { combo: "Return".to_string() }, + Action::Key { + combo: "Return".to_string(), + }, ]), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -207,9 +225,14 @@ impl Plan for SendMessagePlan { return Some(SelectedAction { action: actions::sequence(vec![ Action::Wait { ms: 100 }, - Action::Key { combo: "Return".to_string() }, + Action::Key { + combo: "Return".to_string(), + }, ]), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -217,12 +240,22 @@ impl Plan for SendMessagePlan { if let Some(msg) = ¶ms.message { return Some(SelectedAction { action: actions::sequence(vec![ - Action::Key { combo: "ctrl+a".to_string() }, - Action::Type { text: msg.clone(), selector: None }, + Action::Key { + combo: "ctrl+a".to_string(), + }, + Action::Type { + text: msg.clone(), + selector: None, + }, Action::Wait { ms: 100 }, - Action::Key { combo: "Return".to_string() }, + Action::Key { + combo: "Return".to_string(), + }, ]), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -246,7 +279,10 @@ impl Plan for SendMessagePlan { plan_state.phase = SendMessagePhase::Done; return Some(SelectedAction { action: actions::wait_short(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } @@ -257,7 +293,10 @@ impl Plan for SendMessagePlan { return Some(SelectedAction { action: actions::wait_short(), - frame: identified.main_window.as_ref().and_then(|m| m.frame.clone()), + frame: identified + .main_window + .as_ref() + .and_then(|m| m.frame.clone()), }); } diff --git a/packages/agent-server-rust/src/router/chats.rs b/packages/agent-server-rust/src/router/chats.rs index 00c9296..401f5ae 100644 --- a/packages/agent-server-rust/src/router/chats.rs +++ b/packages/agent-server-rust/src/router/chats.rs @@ -1,4 +1,7 @@ -use axum::{extract::{Path, Query}, Json}; +use axum::{ + extract::{Path, Query}, + Json, +}; use serde::Deserialize; use tokio_util::sync::CancellationToken; @@ -102,7 +105,11 @@ pub async fn get_chat(Path(id): Path) -> Json> { } } - Json(wechat_chats::get_chat_by_username(&logged_in_user, &keys, &id)) + Json(wechat_chats::get_chat_by_username( + &logged_in_user, + &keys, + &id, + )) } #[derive(Deserialize)] @@ -191,7 +198,10 @@ pub async fn open_chat( }; let plan = ChatOpenPlan; - let params = ChatOpenParams { chat_id, clear_unreads }; + let params = ChatOpenParams { + chat_id, + clear_unreads, + }; let cancel = CancellationToken::new(); let noop_emit = |_: SubscriptionEvent| {}; @@ -200,7 +210,10 @@ pub async fn open_chat( if result.success { if let Some(open_result) = plan_state.result { - Json(serde_json::to_value(open_result).unwrap_or_else(|_| serde_json::json!({"ok": true}))) + Json( + serde_json::to_value(open_result) + .unwrap_or_else(|_| serde_json::json!({"ok": true})), + ) } else { Json(serde_json::json!({ "ok": true })) } diff --git a/packages/agent-server-rust/src/router/messages.rs b/packages/agent-server-rust/src/router/messages.rs index 1d74de7..7de1854 100644 --- a/packages/agent-server-rust/src/router/messages.rs +++ b/packages/agent-server-rust/src/router/messages.rs @@ -3,18 +3,23 @@ use axum::{ Json, }; use serde::Deserialize; +use std::collections::HashMap; use tokio_util::sync::CancellationToken; use crate::context::create_context; use crate::db::get_db; +use crate::db::queries::{get_payment_receipts, mark_payment_received, remove_payment_receipts}; use crate::execution::run_execution_loop; -use crate::ia::types::{MediaResult, Message, SendResult, SubscriptionEvent}; +use crate::ia::types::{ + MediaResult, Message, PaymentInfo, ReceivePaymentResult, SendResult, SubscriptionEvent, +}; +use crate::plans::receive_transfer::{ReceiveTransferParams, ReceiveTransferPlan}; use crate::plans::send_message::{SendMessageParams, SendMessagePlan}; +use crate::sessions::manager::get_session; use crate::tools::wechat_db::{find_wechat_pid, list_account_dbs}; -use crate::tools::wechat_keys::{extract_keys_async, get_stored_keys, get_image_keys, store_keys}; +use crate::tools::wechat_keys::{extract_keys_async, get_image_keys, get_stored_keys, store_keys}; use crate::tools::wechat_media::get_message_media; use crate::tools::wechat_messages; -use crate::sessions::manager::get_session; #[derive(Deserialize)] pub struct ListParams { @@ -66,17 +71,25 @@ pub async fn list_messages( } } - if !keys.keys().any(|k| k.starts_with("message_") && k.ends_with(".db") && !k.contains("fts") && !k.contains("resource")) { + if !keys.keys().any(|k| { + k.starts_with("message_") + && k.ends_with(".db") + && !k.contains("fts") + && !k.contains("resource") + }) { return Json(Vec::new()); } - Json(wechat_messages::list_messages( + let mut messages = wechat_messages::list_messages( &logged_in_user, &keys, &chat_id, params.limit, params.offset, - )) + ); + apply_payment_receipt_state(&session.id, &chat_id, &mut messages); + + Json(messages) } pub async fn get_media(Path((chat_id, local_id)): Path<(String, i64)>) -> Json { @@ -196,9 +209,17 @@ pub async fn send_message(Json(input): Json) -> Json { "image/gif" => ".gif", _ => ".png", }; - let path = format!("/tmp/send_image_{}{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(), ext); - if let Ok(bytes) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &img.data) { + let path = format!( + "/tmp/send_image_{}{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ext + ); + if let Ok(bytes) = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &img.data) + { if std::fs::write(&path, &bytes).is_ok() { image_mime = Some(img.mime_type.clone()); image_path = Some(path); @@ -214,18 +235,30 @@ pub async fn send_message(Json(input): Json) -> Json { // path stays portable across locales. The dot is preserved so that // file extensions survive (e.g. "遗憾.pdf" → "__.pdf"); the mangled // stem is acceptable since this is a transient temp path. - let safe_name: String = f.filename.chars().map(|c| { - if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { - c - } else { - '_' - } - }).collect(); - let path = format!("/tmp/send_file_{}_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(), safe_name); + let safe_name: String = f + .filename + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + let path = format!( + "/tmp/send_file_{}_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + safe_name + ); match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &f.data) { Ok(bytes) => match std::fs::write(&path, &bytes) { - Ok(_) => { file_path = Some(path); } + Ok(_) => { + file_path = Some(path); + } Err(e) => { return Json(SendResult { success: false, @@ -274,3 +307,552 @@ pub async fn send_message(Json(input): Json) -> Json { error: result.error, }) } + +#[derive(Deserialize)] +pub struct ReceiveTransferInput { + #[serde(rename = "transactionId")] + transaction_id: Option, + #[serde(rename = "localId")] + local_id: Option, +} + +#[derive(Deserialize)] +pub struct ReceiveRedPacketInput { + #[serde(rename = "localId")] + local_id: Option, + #[serde(rename = "sendId")] + send_id: Option, + #[serde(rename = "payMsgId")] + pay_msg_id: Option, +} + +fn payment_result( + kind: &str, + success: bool, + error: Option, + local_id: Option, + is_received: Option, + received_at: Option, + payment: Option<&PaymentInfo>, +) -> ReceivePaymentResult { + ReceivePaymentResult { + success, + kind: kind.to_string(), + error, + local_id, + is_received, + received_at, + amount_text: payment.and_then(|p| p.amount_text.clone()), + amount_cents: payment.and_then(|p| p.amount_cents), + currency: payment.and_then(|p| p.currency.clone()), + transaction_id: payment.and_then(|p| p.transaction_id.clone()), + transfer_id: payment.and_then(|p| p.transfer_id.clone()), + send_id: payment.and_then(|p| p.send_id.clone()), + pay_msg_id: payment.and_then(|p| p.pay_msg_id.clone()), + } +} + +fn apply_payment_receipt_state(session_id: &str, chat_id: &str, messages: &mut [Message]) { + let receipts = { + let db = get_db(); + get_payment_receipts(&db, session_id, chat_id) + }; + + let stale_receipts = reconcile_payment_receipt_state(messages, &receipts); + if !stale_receipts.is_empty() { + let db = get_db(); + remove_payment_receipts(&db, session_id, chat_id, &stale_receipts); + } +} + +fn reconcile_payment_receipt_state( + messages: &mut [Message], + receipts: &HashMap, +) -> Vec { + let mut stale_receipts = Vec::new(); + + for message in messages { + if message.payment.is_none() { + continue; + } + + let is_transfer = message + .payment + .as_ref() + .is_some_and(|payment| payment.kind == "transfer"); + + if is_transfer { + if message.is_received == Some(true) { + message.received_at = receipts.get(&message.local_id).cloned(); + } else { + if receipts.contains_key(&message.local_id) { + stale_receipts.push(message.local_id); + } + message.is_received = Some(false); + message.received_at = None; + } + continue; + } + + if let Some(received_at) = receipts.get(&message.local_id) { + message.is_received = Some(true); + message.received_at = Some(received_at.clone()); + } else if message.is_received != Some(true) { + message.is_received = Some(false); + message.received_at = None; + } + } + + stale_receipts +} + +async fn load_logged_in_session_and_keys() -> Result< + ( + crate::ia::types::Session, + String, + std::collections::HashMap, + ), + ReceivePaymentResult, +> { + let session = get_session("default").ok_or_else(|| { + payment_result( + "unknown", + false, + Some("No session available".to_string()), + None, + None, + None, + None, + ) + })?; + + let logged_in_user = session.logged_in_user.clone().ok_or_else(|| { + payment_result( + "unknown", + false, + Some("NOT_LOGGED_IN".to_string()), + None, + None, + None, + None, + ) + })?; + + let mut keys = { + let db = get_db(); + get_stored_keys(&db, &session.id, &logged_in_user) + }; + + let on_disk = list_account_dbs(&logged_in_user); + let has_missing_db = on_disk.iter().any(|name| { + (name.starts_with("message_") + && name.ends_with(".db") + && !name.contains("fts") + && !name.contains("resource")) + && !keys.contains_key(name.as_str()) + }); + + if has_missing_db { + if let Some(pid) = find_wechat_pid() { + let extracted = extract_keys_async(pid).await; + if !extracted.is_empty() { + let db = get_db(); + store_keys(&db, &session.id, &logged_in_user, &extracted); + keys = get_stored_keys(&db, &session.id, &logged_in_user); + } + } + } + + if !keys.keys().any(|k| { + k.starts_with("message_") + && k.ends_with(".db") + && !k.contains("fts") + && !k.contains("resource") + }) { + return Err(payment_result( + "unknown", + false, + Some("MESSAGE_DB_UNAVAILABLE".to_string()), + None, + None, + None, + None, + )); + } + + Ok((session, logged_in_user, keys)) +} + +fn find_payment_message<'a>( + messages: &'a [Message], + kind: &str, + local_id: Option, + transaction_id: Option<&str>, + send_id: Option<&str>, + pay_msg_id: Option<&str>, +) -> Option<&'a Message> { + messages.iter().find(|message| { + let payment = match &message.payment { + Some(payment) if payment.kind == kind => payment, + _ => return false, + }; + + if let Some(local_id) = local_id { + return message.local_id == local_id; + } + + if let Some(transaction_id) = transaction_id { + return payment.transaction_id.as_deref() == Some(transaction_id); + } + + if let Some(send_id) = send_id { + return payment.send_id.as_deref() == Some(send_id); + } + + if let Some(pay_msg_id) = pay_msg_id { + return payment.pay_msg_id.as_deref() == Some(pay_msg_id); + } + + true + }) +} + +fn is_receivable_transfer_message(message: &Message) -> bool { + match message.payment.as_ref() { + Some(payment) if payment.kind == "transfer" => { + message.is_self != Some(true) && message.is_received != Some(true) + } + _ => false, + } +} + +fn find_transfer_message_for_receive<'a>( + messages: &'a [Message], + local_id: Option, + transaction_id: Option<&str>, +) -> Option<&'a Message> { + if local_id.is_some() || transaction_id.is_some() { + return find_payment_message(messages, "transfer", local_id, transaction_id, None, None); + } + + messages + .iter() + .find(|message| is_receivable_transfer_message(message)) +} + +fn reload_transfer_message_state( + logged_in_user: &str, + keys: &std::collections::HashMap, + session_id: &str, + chat_id: &str, + local_id: i64, + transaction_id: Option<&str>, +) -> Option { + let mut messages = wechat_messages::list_messages(logged_in_user, keys, chat_id, 200, 0); + apply_payment_receipt_state(session_id, chat_id, &mut messages); + + find_payment_message( + &messages, + "transfer", + Some(local_id), + transaction_id, + None, + None, + ) + .cloned() + .or_else(|| { + transaction_id.and_then(|tx| { + find_payment_message(&messages, "transfer", None, Some(tx), None, None).cloned() + }) + }) +} + +async fn wait_for_transfer_received_state( + logged_in_user: &str, + keys: &std::collections::HashMap, + session_id: &str, + chat_id: &str, + local_id: i64, + transaction_id: Option<&str>, + max_attempts: usize, + poll_interval_ms: u64, +) -> Option { + let mut last_observed = None; + + for attempt in 0..max_attempts { + let observed = reload_transfer_message_state( + logged_in_user, + keys, + session_id, + chat_id, + local_id, + transaction_id, + ); + if observed.as_ref().and_then(|message| message.is_received) == Some(true) { + return observed; + } + last_observed = observed; + + if attempt + 1 < max_attempts { + tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await; + } + } + + last_observed +} + +pub async fn receive_transfer( + Path(chat_id): Path, + Json(input): Json, +) -> Json { + let (session, logged_in_user, keys) = match load_logged_in_session_and_keys().await { + Ok(value) => value, + Err(result) => return Json(result), + }; + + let mut messages = wechat_messages::list_messages(&logged_in_user, &keys, &chat_id, 200, 0); + apply_payment_receipt_state(&session.id, &chat_id, &mut messages); + let message = match find_transfer_message_for_receive( + &messages, + input.local_id, + input.transaction_id.as_deref(), + ) { + Some(message) => message.clone(), + None => { + return Json(payment_result( + "transfer", + false, + Some("TRANSFER_NOT_FOUND".to_string()), + input.local_id, + None, + None, + None, + )) + } + }; + + let payment = match message.payment.as_ref() { + Some(payment) => payment, + None => { + return Json(payment_result( + "transfer", + false, + Some("MESSAGE_IS_NOT_TRANSFER".to_string()), + Some(message.local_id), + None, + None, + None, + )) + } + }; + + if message.is_received == Some(true) { + return Json(payment_result( + "transfer", + true, + None, + Some(message.local_id), + Some(true), + message.received_at.clone(), + Some(payment), + )); + } + + if message.is_self == Some(true) { + return Json(payment_result( + "transfer", + false, + Some("TRANSFER_NOT_RECEIVABLE".to_string()), + Some(message.local_id), + Some(false), + None, + Some(payment), + )); + } + + let session_id = session.id.clone(); + let mut context = { + let db = get_db(); + create_context(session, &db) + }; + + let plan = ReceiveTransferPlan; + let params = ReceiveTransferParams { + chat_id: chat_id.clone(), + transaction_id: payment.transaction_id.clone(), + amount_text: payment.amount_text.clone(), + is_self: message.is_self == Some(true), + explicit_target: input.local_id.is_some() || input.transaction_id.is_some(), + }; + let cancel = CancellationToken::new(); + let noop_emit = |_: SubscriptionEvent| {}; + + let (result, plan_state) = + run_execution_loop(&plan, ¶ms, &mut context, &noop_emit, cancel).await; + + let execution_confirmed = result.success && plan_state.received; + let observed_message = if execution_confirmed { + wait_for_transfer_received_state( + &logged_in_user, + &keys, + &session_id, + &chat_id, + message.local_id, + payment.transaction_id.as_deref(), + 6, + 100, + ) + .await + } else { + wait_for_transfer_received_state( + &logged_in_user, + &keys, + &session_id, + &chat_id, + message.local_id, + payment.transaction_id.as_deref(), + 48, + 250, + ) + .await + }; + let observed_received = observed_message + .as_ref() + .and_then(|message| message.is_received) + == Some(true); + let effective_success = observed_received || execution_confirmed; + let effective_error = if effective_success { + None + } else { + result + .error + .or_else(|| Some("TRANSFER_NOT_RECEIVED".to_string())) + }; + + let (is_received, received_at) = if effective_success { + let received_at = { + let db = get_db(); + mark_payment_received(&db, &session_id, &chat_id, message.local_id) + }; + (Some(true), Some(received_at)) + } else { + (Some(false), None) + }; + + Json(payment_result( + "transfer", + effective_success, + effective_error, + Some(message.local_id), + is_received, + received_at, + Some(payment), + )) +} + +pub async fn receive_red_packet( + Path(chat_id): Path, + Json(input): Json, +) -> Json { + let (session, logged_in_user, keys) = match load_logged_in_session_and_keys().await { + Ok(value) => value, + Err(result) => return Json(result), + }; + + let mut messages = wechat_messages::list_messages(&logged_in_user, &keys, &chat_id, 200, 0); + apply_payment_receipt_state(&session.id, &chat_id, &mut messages); + let message = match find_payment_message( + &messages, + "red_packet", + input.local_id, + None, + input.send_id.as_deref(), + input.pay_msg_id.as_deref(), + ) { + Some(message) => message, + None => { + return Json(payment_result( + "red_packet", + false, + Some("RED_PACKET_NOT_FOUND".to_string()), + input.local_id, + None, + None, + None, + )) + } + }; + + Json(payment_result( + "red_packet", + false, + Some("UNSUPPORTED_ON_LINUX_WECHAT_CLIENT".to_string()), + Some(message.local_id), + message.is_received, + message.received_at.clone(), + message.payment.as_ref(), + )) +} + +#[cfg(test)] +mod tests { + use super::reconcile_payment_receipt_state; + use crate::ia::types::{Message, PaymentInfo}; + use std::collections::HashMap; + + fn transfer_message(local_id: i64, is_received: Option) -> Message { + Message { + local_id, + server_id: local_id, + chat_id: "chat".to_string(), + sender: Some("sender".to_string()), + sender_name: Some("sender".to_string()), + msg_type: 49, + kind: "transfer".to_string(), + app_msg_type: Some(2000), + content: "微信转账".to_string(), + timestamp: "2026-04-11T00:00:00+00:00".to_string(), + is_mentioned: None, + is_self: Some(false), + is_received, + received_at: None, + reply: None, + payment: Some(PaymentInfo { + kind: "transfer".to_string(), + app_msg_type: Some(2000), + amount_text: Some("¥0.30".to_string()), + amount_cents: Some(30), + currency: Some("CNY".to_string()), + transaction_id: Some(format!("tx-{local_id}")), + transfer_id: Some(format!("tr-{local_id}")), + send_id: None, + pay_msg_id: None, + receiver_title: None, + native_url: None, + }), + } + } + + #[test] + fn stale_transfer_receipts_are_cleared_without_forcing_received_state() { + let mut messages = vec![ + transfer_message(12, Some(false)), + transfer_message(16, Some(true)), + ]; + let receipts = HashMap::from([ + (12_i64, "2026-04-11T09:50:57Z".to_string()), + (16_i64, "2026-04-11T10:17:30Z".to_string()), + ]); + + let stale = reconcile_payment_receipt_state(&mut messages, &receipts); + + assert_eq!(stale, vec![12]); + assert_eq!(messages[0].is_received, Some(false)); + assert_eq!(messages[0].received_at, None); + assert_eq!(messages[1].is_received, Some(true)); + assert_eq!( + messages[1].received_at.as_deref(), + Some("2026-04-11T10:17:30Z") + ); + } +} diff --git a/packages/agent-server-rust/src/router/mod.rs b/packages/agent-server-rust/src/router/mod.rs index 3d727a0..72b1683 100644 --- a/packages/agent-server-rust/src/router/mod.rs +++ b/packages/agent-server-rust/src/router/mod.rs @@ -50,12 +50,26 @@ pub fn build_router() -> Router { get(messages::get_media), ) .route("/api/messages/send", post(messages::send_message)) + .route( + "/api/messages/{chat_id}/transfer/receive", + post(messages::receive_transfer), + ) + .route( + "/api/messages/{chat_id}/red-packet/receive", + post(messages::receive_red_packet), + ) // Debug .route("/api/debug/screenshot", get(debug::screenshot)) .route("/api/debug/a11y", get(debug::a11y)) // Sessions - .route("/api/sessions", get(sessions::list_sessions).post(sessions::create_session)) - .route("/api/sessions/{id}", get(sessions::get_session).delete(sessions::delete_session)) + .route( + "/api/sessions", + get(sessions::list_sessions).post(sessions::create_session), + ) + .route( + "/api/sessions/{id}", + get(sessions::get_session).delete(sessions::delete_session), + ) .route("/api/sessions/{id}/start", post(sessions::start_session)) .route("/api/sessions/{id}/stop", post(sessions::stop_session)) // WebSocket for login subscription diff --git a/packages/agent-server-rust/src/router/status.rs b/packages/agent-server-rust/src/router/status.rs index 7ce7246..43c7dd4 100644 --- a/packages/agent-server-rust/src/router/status.rs +++ b/packages/agent-server-rust/src/router/status.rs @@ -9,7 +9,6 @@ use axum::{ use serde::Deserialize; use tokio_util::sync::CancellationToken; -use base64::Engine; use crate::context::create_context; use crate::db::get_db; use crate::execution::run_execution_loop; @@ -22,6 +21,7 @@ use crate::tools::a11y::get_a11y_desktop; use crate::tools::exec::ExecOptions; use crate::tools::qr::{decode_qr_from_base64, to_data_url}; use crate::tools::screenshot::capture_screenshot; +use base64::Engine; pub async fn get_status() -> Json { Json(serde_json::json!({ @@ -70,9 +70,7 @@ pub async fn auth_status() -> Json { } }; - let screenshot = capture_screenshot(&exec_options) - .await - .unwrap_or_default(); + let screenshot = capture_screenshot(&exec_options).await.unwrap_or_default(); let identified = identify_states(&a11y, &screenshot); // Load persisted state and apply reduce @@ -279,7 +277,9 @@ async fn handle_login_ws(mut socket: WebSocket, params: LoginWsParams) { let emit = move |event: SubscriptionEvent| { let _ = tx.send(event); }; - run_execution_loop(&plan, &login_params, &mut context, &emit, cancel_for_exec).await.0 + run_execution_loop(&plan, &login_params, &mut context, &emit, cancel_for_exec) + .await + .0 }); // Main loop: bridge events to WebSocket, handle timeout + disconnect @@ -335,7 +335,9 @@ async fn handle_login_ws(mut socket: WebSocket, params: LoginWsParams) { let exec_result = exec_handle.await.ok(); if !client_disconnected && !sent_terminal { let fallback = match exec_result { - Some(result) if result.success => LoginSubscriptionEvent::LoginSuccess { user_id: None }, + Some(result) if result.success => { + LoginSubscriptionEvent::LoginSuccess { user_id: None } + } Some(result) => { let message = result.error.unwrap_or_else(|| "Login failed".to_string()); if message.starts_with("Unknown state for") diff --git a/packages/agent-server-rust/src/router/vnc.rs b/packages/agent-server-rust/src/router/vnc.rs index e7e2097..1422980 100644 --- a/packages/agent-server-rust/src/router/vnc.rs +++ b/packages/agent-server-rust/src/router/vnc.rs @@ -31,8 +31,12 @@ async fn handle_vnc_ws(ws: WebSocket, websockify_port: u16) { let client_to_upstream = async { while let Some(Ok(msg)) = ws_rx.next().await { let tung_msg = match msg { - Message::Binary(data) => tokio_tungstenite::tungstenite::Message::Binary(data.into()), - Message::Text(text) => tokio_tungstenite::tungstenite::Message::Text(text.as_str().into()), + Message::Binary(data) => { + tokio_tungstenite::tungstenite::Message::Binary(data.into()) + } + Message::Text(text) => { + tokio_tungstenite::tungstenite::Message::Text(text.as_str().into()) + } Message::Ping(data) => tokio_tungstenite::tungstenite::Message::Ping(data.into()), Message::Pong(data) => tokio_tungstenite::tungstenite::Message::Pong(data.into()), Message::Close(_) => break, @@ -47,8 +51,12 @@ async fn handle_vnc_ws(ws: WebSocket, websockify_port: u16) { let upstream_to_client = async { while let Some(Ok(msg)) = up_rx.next().await { let axum_msg = match msg { - tokio_tungstenite::tungstenite::Message::Binary(data) => Message::Binary(data.into()), - tokio_tungstenite::tungstenite::Message::Text(text) => Message::Text(text.as_str().into()), + tokio_tungstenite::tungstenite::Message::Binary(data) => { + Message::Binary(data.into()) + } + tokio_tungstenite::tungstenite::Message::Text(text) => { + Message::Text(text.as_str().into()) + } tokio_tungstenite::tungstenite::Message::Ping(data) => Message::Ping(data.into()), tokio_tungstenite::tungstenite::Message::Pong(data) => Message::Pong(data.into()), tokio_tungstenite::tungstenite::Message::Close(_) => break, diff --git a/packages/agent-server-rust/src/sessions/health_monitor.rs b/packages/agent-server-rust/src/sessions/health_monitor.rs index 8d65f9e..b46a993 100644 --- a/packages/agent-server-rust/src/sessions/health_monitor.rs +++ b/packages/agent-server-rust/src/sessions/health_monitor.rs @@ -151,9 +151,7 @@ pub fn spawn_health_monitor() { } }; - let screenshot = capture_screenshot(&exec_options) - .await - .unwrap_or_default(); + let screenshot = capture_screenshot(&exec_options).await.unwrap_or_default(); let identified = identify_states(&a11y, &screenshot); if identified.main_window.is_some() { diff --git a/packages/agent-server-rust/src/sessions/manager.rs b/packages/agent-server-rust/src/sessions/manager.rs index c1ad223..7dff2d4 100644 --- a/packages/agent-server-rust/src/sessions/manager.rs +++ b/packages/agent-server-rust/src/sessions/manager.rs @@ -18,9 +18,11 @@ fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result { xvfb_pid: row.get("xvfb_pid")?, dbus_pid: row.get("dbus_pid")?, error_message: row.get("error_message")?, - created_at: row.get::<_, Option>("created_at")? + created_at: row + .get::<_, Option>("created_at")? .unwrap_or_default(), - updated_at: row.get::<_, Option>("updated_at")? + updated_at: row + .get::<_, Option>("updated_at")? .unwrap_or_default(), }) } @@ -81,11 +83,7 @@ pub async fn create_session(name: &str) -> Result { // Get next VNC port let max_port: Option = db - .query_row( - "SELECT MAX(vnc_port) FROM sessions", - [], - |row| row.get(0), - ) + .query_row("SELECT MAX(vnc_port) FROM sessions", [], |row| row.get(0)) .ok(); let vnc_port = max_port.unwrap_or(5900) + 1; @@ -141,8 +139,8 @@ pub async fn get_or_create_default_session() -> Result { /// Start a session (launches Xvfb, D-Bus, AT-SPI, WeChat). pub async fn start_session(id_or_name: &str) -> Result { - let session = get_session(id_or_name) - .ok_or_else(|| format!("Session not found: {id_or_name}"))?; + let session = + get_session(id_or_name).ok_or_else(|| format!("Session not found: {id_or_name}"))?; if session.status == "running" { return Ok(session); @@ -173,7 +171,13 @@ pub async fn start_session(id_or_name: &str) -> Result { // 2. D-Bus let dbus_output = std::process::Command::new("su") - .args(["-s", "/bin/bash", "-c", "dbus-launch --sh-syntax", linux_user.as_str()]) + .args([ + "-s", + "/bin/bash", + "-c", + "dbus-launch --sh-syntax", + linux_user.as_str(), + ]) .output(); let dbus_address = dbus_output @@ -204,7 +208,18 @@ pub async fn start_session(id_or_name: &str) -> Result { // 5. VNC (localhost only — auth enforced by agent-server proxy) let vnc_port = session.vnc_port.to_string(); let _ = std::process::Command::new("x11vnc") - .args(["-display", display.as_str(), "-forever", "-nopw", "-shared", "-viewonly", "-xkb", "-rfbport", &vnc_port, "-listen", "127.0.0.1"]) + .args([ + "-display", + display.as_str(), + "-forever", + "-nopw", + "-shared", + "-xkb", + "-rfbport", + &vnc_port, + "-listen", + "127.0.0.1", + ]) .spawn(); // 5b. noVNC (websockify on localhost only — proxied via agent-server with auth) @@ -242,8 +257,8 @@ pub async fn start_session(id_or_name: &str) -> Result { /// Stop a session. pub async fn stop_session(id_or_name: &str) -> Result { - let session = get_session(id_or_name) - .ok_or_else(|| format!("Session not found: {id_or_name}"))?; + let session = + get_session(id_or_name).ok_or_else(|| format!("Session not found: {id_or_name}"))?; if session.status == "stopped" { return Ok(session); @@ -274,8 +289,8 @@ pub async fn stop_session(id_or_name: &str) -> Result { /// Delete a session. pub async fn delete_session(id_or_name: &str) -> Result<(), String> { - let session = get_session(id_or_name) - .ok_or_else(|| format!("Session not found: {id_or_name}"))?; + let session = + get_session(id_or_name).ok_or_else(|| format!("Session not found: {id_or_name}"))?; if session.status == "running" || session.status == "starting" { stop_session(&session.id).await?; @@ -283,10 +298,23 @@ pub async fn delete_session(id_or_name: &str) -> Result<(), String> { { let db = get_db(); - db.execute("DELETE FROM sync_state WHERE session_id = ?1", params![session.id]).ok(); - db.execute("DELETE FROM wechat_keys WHERE session_id = ?1", params![session.id]).ok(); - db.execute("DELETE FROM context WHERE session_id = ?1", params![session.id]).ok(); - db.execute("DELETE FROM sessions WHERE id = ?1", params![session.id]).ok(); + db.execute( + "DELETE FROM sync_state WHERE session_id = ?1", + params![session.id], + ) + .ok(); + db.execute( + "DELETE FROM wechat_keys WHERE session_id = ?1", + params![session.id], + ) + .ok(); + db.execute( + "DELETE FROM context WHERE session_id = ?1", + params![session.id], + ) + .ok(); + db.execute("DELETE FROM sessions WHERE id = ?1", params![session.id]) + .ok(); } let _ = std::process::Command::new("userdel") diff --git a/packages/agent-server-rust/src/tools/a11y.rs b/packages/agent-server-rust/src/tools/a11y.rs index af648a6..874fbda 100644 --- a/packages/agent-server-rust/src/tools/a11y.rs +++ b/packages/agent-server-rust/src/tools/a11y.rs @@ -18,15 +18,8 @@ fn add_parent_refs(node: &mut A11yNode, _parent_index: Option) { /// Get the desktop accessibility tree as a nested structure. /// Uses the Python a11y-dump script. -pub async fn get_a11y_desktop( - options: &ExecOptions, -) -> Result { - let result = exec_command( - "python3", - &[A11Y_SCRIPT_PATH, "--format", "json"], - options, - ) - .await; +pub async fn get_a11y_desktop(options: &ExecOptions) -> Result { + let result = exec_command("python3", &[A11Y_SCRIPT_PATH, "--format", "json"], options).await; if result.exit_code != 0 { return Err(result.stderr.clone().or_if_empty(&result.stdout)); @@ -41,12 +34,7 @@ pub async fn get_a11y_desktop( /// Get a11y tree as ARIA-style text. pub async fn get_a11y_aria(options: &ExecOptions) -> Result { - let result = exec_command( - "python3", - &[A11Y_SCRIPT_PATH, "--format", "aria"], - options, - ) - .await; + let result = exec_command("python3", &[A11Y_SCRIPT_PATH, "--format", "aria"], options).await; if result.exit_code != 0 { return Err(result.stderr.clone().or_if_empty(&result.stdout)); @@ -90,16 +78,23 @@ pub fn tree_to_aria(node: &A11yNode, depth: usize) -> String { .map(|s| format!("[{}]", s.join(","))) .unwrap_or_default(); - let parts: Vec<&str> = [node.role.as_str(), name.as_str(), states.as_str(), bounds.as_str()] - .into_iter() - .filter(|s| !s.is_empty()) - .collect(); + let parts: Vec<&str> = [ + node.role.as_str(), + name.as_str(), + states.as_str(), + bounds.as_str(), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect(); let line = format!("{indent}- {}", parts.join(" ")); if let Some(children) = &node.children { if !children.is_empty() { - let child_lines: Vec = - children.iter().map(|c| tree_to_aria(c, depth + 1)).collect(); + let child_lines: Vec = children + .iter() + .map(|c| tree_to_aria(c, depth + 1)) + .collect(); return format!("{line}\n{}", child_lines.join("\n")); } } diff --git a/packages/agent-server-rust/src/tools/chat_select.rs b/packages/agent-server-rust/src/tools/chat_select.rs index 2dddc87..3631f4f 100644 --- a/packages/agent-server-rust/src/tools/chat_select.rs +++ b/packages/agent-server-rust/src/tools/chat_select.rs @@ -17,11 +17,7 @@ pub struct OpenChatResult { /// Open a chat in the WeChat UI using the chat-select tool. /// /// Args format: chat-select [--force] [--click-xy X Y] -pub async fn open_chat( - chat_id: &str, - force: bool, - click_xy: Option<(f64, f64)>, -) -> OpenChatResult { +pub async fn open_chat(chat_id: &str, force: bool, click_xy: Option<(f64, f64)>) -> OpenChatResult { let mut args: Vec = Vec::new(); if force { @@ -39,12 +35,7 @@ pub async fn open_chat( let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let result = exec_command( - "chat-select", - &args_ref, - &ExecOptions::default(), - ) - .await; + let result = exec_command("chat-select", &args_ref, &ExecOptions::default()).await; // Result JSON is on stdout regardless of exit code if let Ok(parsed) = serde_json::from_str::(&result.stdout) { diff --git a/packages/agent-server-rust/src/tools/exec.rs b/packages/agent-server-rust/src/tools/exec.rs index 3912069..78398d6 100644 --- a/packages/agent-server-rust/src/tools/exec.rs +++ b/packages/agent-server-rust/src/tools/exec.rs @@ -27,11 +27,7 @@ impl Default for ExecOptions { /// /// If a session is provided, the command runs with that session's /// DISPLAY and DBUS_SESSION_BUS_ADDRESS environment. -pub async fn exec_command( - command: &str, - args: &[&str], - options: &ExecOptions, -) -> CommandResult { +pub async fn exec_command(command: &str, args: &[&str], options: &ExecOptions) -> CommandResult { let mut env: HashMap = std::env::vars().collect(); env.insert("QT_ACCESSIBILITY".into(), "1".into()); env.insert("QT_LINUX_ACCESSIBILITY_ALWAYS_ON".into(), "1".into()); @@ -44,18 +40,13 @@ pub async fn exec_command( ); env.insert("HOME".into(), format!("/home/{}", session.linux_user)); } else { - env.entry("DISPLAY".into()) - .or_insert_with(|| ":99".into()); + env.entry("DISPLAY".into()).or_insert_with(|| ":99".into()); } let timeout = std::time::Duration::from_millis(options.timeout_ms); let result = tokio::time::timeout(timeout, async { - let output = Command::new(command) - .args(args) - .envs(&env) - .output() - .await; + let output = Command::new(command).args(args).envs(&env).output().await; match output { Ok(out) => CommandResult { diff --git a/packages/agent-server-rust/src/tools/mod.rs b/packages/agent-server-rust/src/tools/mod.rs index e77ecd2..ab75205 100644 --- a/packages/agent-server-rust/src/tools/mod.rs +++ b/packages/agent-server-rust/src/tools/mod.rs @@ -7,5 +7,5 @@ pub mod wechat_chats; pub mod wechat_contacts; pub mod wechat_db; pub mod wechat_keys; -pub mod wechat_messages; pub mod wechat_media; +pub mod wechat_messages; diff --git a/packages/agent-server-rust/src/tools/screenshot.rs b/packages/agent-server-rust/src/tools/screenshot.rs index e69a36b..d9cc06d 100644 --- a/packages/agent-server-rust/src/tools/screenshot.rs +++ b/packages/agent-server-rust/src/tools/screenshot.rs @@ -11,10 +11,9 @@ pub async fn capture_screenshot(options: &ExecOptions) -> Result let filepath = result.stdout.trim().to_string(); - let buffer = - tokio::fs::read(&filepath) - .await - .map_err(|e| format!("Failed to read screenshot: {e}"))?; + let buffer = tokio::fs::read(&filepath) + .await + .map_err(|e| format!("Failed to read screenshot: {e}"))?; // Clean up temp file let _ = tokio::fs::remove_file(&filepath).await; diff --git a/packages/agent-server-rust/src/tools/wechat_chats.rs b/packages/agent-server-rust/src/tools/wechat_chats.rs index fb8b84f..1b2db96 100644 --- a/packages/agent-server-rust/src/tools/wechat_chats.rs +++ b/packages/agent-server-rust/src/tools/wechat_chats.rs @@ -78,7 +78,11 @@ pub fn list_chats( let is_group = username.contains("@chatroom"); let name = contact - .and_then(|c| c.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty())) + .and_then(|c| { + c.get("remark") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) .or_else(|| { contact.and_then(|c| { c.get("nick_name") @@ -90,7 +94,11 @@ pub fn list_chats( .to_string(); let remark = contact - .and_then(|c| c.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty())) + .and_then(|c| { + c.get("remark") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) .map(String::from); let unread_count = session @@ -126,9 +134,7 @@ pub fn list_chats( .unwrap_or_default() }); - let last_msg_local_id = session - .get("last_msg_locald_id") - .and_then(|v| v.as_i64()); + let last_msg_local_id = session.get("last_msg_locald_id").and_then(|v| v.as_i64()); Some(Chat { id: username.clone(), @@ -187,7 +193,11 @@ pub fn get_chat_by_username( let contact = contacts.first(); let name = contact - .and_then(|c| c.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty())) + .and_then(|c| { + c.get("remark") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) .or_else(|| { contact.and_then(|c| { c.get("nick_name") @@ -199,7 +209,11 @@ pub fn get_chat_by_username( .to_string(); let remark = contact - .and_then(|c| c.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty())) + .and_then(|c| { + c.get("remark") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) .map(String::from); let unread_count = session @@ -233,9 +247,7 @@ pub fn get_chat_by_username( .map(|dt| dt.to_rfc3339()) .unwrap_or_default() }), - last_msg_local_id: session - .get("last_msg_locald_id") - .and_then(|v| v.as_i64()), + last_msg_local_id: session.get("last_msg_locald_id").and_then(|v| v.as_i64()), }) } @@ -357,9 +369,7 @@ pub fn find_chats_by_name( .map(|dt| dt.to_rfc3339()) .unwrap_or_default() }), - last_msg_local_id: session - .get("last_msg_locald_id") - .and_then(|v| v.as_i64()), + last_msg_local_id: session.get("last_msg_locald_id").and_then(|v| v.as_i64()), }) }) .collect() diff --git a/packages/agent-server-rust/src/tools/wechat_contacts.rs b/packages/agent-server-rust/src/tools/wechat_contacts.rs index 1eab116..3a14981 100644 --- a/packages/agent-server-rust/src/tools/wechat_contacts.rs +++ b/packages/agent-server-rust/src/tools/wechat_contacts.rs @@ -75,10 +75,7 @@ pub fn list_contacts( return None; } - let local_type = row - .get("local_type") - .and_then(|v| v.as_i64()) - .unwrap_or(3); + let local_type = row.get("local_type").and_then(|v| v.as_i64()).unwrap_or(3); Some(Contact { username: username.to_string(), @@ -146,10 +143,7 @@ pub fn find_contacts( return None; } - let local_type = row - .get("local_type") - .and_then(|v| v.as_i64()) - .unwrap_or(3); + let local_type = row.get("local_type").and_then(|v| v.as_i64()).unwrap_or(3); Some(Contact { username: username.to_string(), diff --git a/packages/agent-server-rust/src/tools/wechat_db.rs b/packages/agent-server-rust/src/tools/wechat_db.rs index e32b739..cc4016e 100644 --- a/packages/agent-server-rust/src/tools/wechat_db.rs +++ b/packages/agent-server-rust/src/tools/wechat_db.rs @@ -8,11 +8,7 @@ use std::process::Command; /// that could interfere with WeChat's own writes. Since we open a fresh /// connection per query and drop it immediately, immutable mode is safe — /// we always see the latest committed state at open time. -pub fn query_wechat_db( - db_path: &str, - hex_key: &str, - sql: &str, -) -> Vec { +pub fn query_wechat_db(db_path: &str, hex_key: &str, sql: &str) -> Vec { let uri = format!("file:{}?immutable=1", db_path); let conn = match Connection::open_with_flags( &uri, @@ -42,11 +38,7 @@ pub fn query_wechat_db( } }; - let col_names: Vec = stmt - .column_names() - .iter() - .map(|s| s.to_string()) - .collect(); + let col_names: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); let rows = stmt.query_map([], |row| { let mut map = Map::new(); @@ -434,6 +426,9 @@ mod tests { let count_fresh: i64 = reader2 .query_row("SELECT count(*) FROM messages", [], |r| r.get(0)) .unwrap(); - assert_eq!(count_fresh, 3, "Fresh immutable connection should see committed writes"); + assert_eq!( + count_fresh, 3, + "Fresh immutable connection should see committed writes" + ); } } diff --git a/packages/agent-server-rust/src/tools/wechat_keys.rs b/packages/agent-server-rust/src/tools/wechat_keys.rs index b2e1346..ed71a75 100644 --- a/packages/agent-server-rust/src/tools/wechat_keys.rs +++ b/packages/agent-server-rust/src/tools/wechat_keys.rs @@ -66,10 +66,7 @@ pub fn get_stored_keys( let rows = stmt .query_map(params![session_id, account_dir], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - )) + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .unwrap(); @@ -150,11 +147,7 @@ pub fn verify_key(db_path: &str, hex_key: &str) -> bool { /// 1. Check that all required DB keys exist in stored_keys /// 2. Scan disk for additional required DBs (message_N, media_N) without keys /// 3. Spot-check one key for validity -pub fn needs_key_extraction( - conn: &Connection, - session_id: &str, - account_dir: &str, -) -> bool { +pub fn needs_key_extraction(conn: &Connection, session_id: &str, account_dir: &str) -> bool { let stored_keys = get_stored_keys(conn, session_id, account_dir); if stored_keys.is_empty() { @@ -205,7 +198,11 @@ pub fn needs_key_extraction( if !missing_on_disk.is_empty() { tracing::info!( "[wechat-keys] Missing keys for on-disk DBs: {}", - missing_on_disk.iter().map(|s| s.as_str()).collect::>().join(", ") + missing_on_disk + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") ); return true; } diff --git a/packages/agent-server-rust/src/tools/wechat_media.rs b/packages/agent-server-rust/src/tools/wechat_media.rs index e0eecd2..5047dff 100644 --- a/packages/agent-server-rust/src/tools/wechat_media.rs +++ b/packages/agent-server-rust/src/tools/wechat_media.rs @@ -1,6 +1,8 @@ use crate::ia::types::MediaResult; use crate::tools::wechat_db::{get_db_path, query_wechat_db}; -use crate::tools::wechat_messages::{decode_message_content, extract_xml_tag, find_message_db, get_msg_table_name}; +use crate::tools::wechat_messages::{ + decode_message_content, extract_xml_tag, find_message_db, get_msg_table_name, +}; use md5::{Digest, Md5}; use std::collections::HashMap; use std::fs; @@ -219,10 +221,7 @@ fn decrypt_dat_head(dat: &[u8], aes_key_hex: &str) -> Option<(Vec, u32)> { } fn hex_encode(bytes: &[u8]) -> String { - bytes - .iter() - .map(|b| format!("{b:02x}")) - .collect::() + bytes.iter().map(|b| format!("{b:02x}")).collect::() } fn derive_xor_byte(dat: &[u8], dec_head: &[u8]) -> Option { @@ -260,11 +259,7 @@ fn derive_xor_byte(dat: &[u8], dec_head: &[u8]) -> Option { None } -fn resolve_xor_byte( - dat_path: &str, - dat: &[u8], - image_keys: &ImageKeys, -) -> Option { +fn resolve_xor_byte(dat_path: &str, dat: &[u8], image_keys: &ImageKeys) -> Option { if let Some(xb) = image_keys.xor_byte { return Some(xb); } @@ -285,9 +280,7 @@ fn resolve_xor_byte( if sib.len() < 15 || sib[..6] != DAT_MAGIC { continue; } - if let Some((sib_head, _)) = - decrypt_dat_head(&sib, &image_keys.aes_key_hex) - { + if let Some((sib_head, _)) = decrypt_dat_head(&sib, &image_keys.aes_key_hex) { if let Some(xb) = derive_xor_byte(&sib, &sib_head) { return Some(xb); } @@ -378,7 +371,10 @@ fn find_dat_via_hardlink( let image_md5 = match xml_attr(content, "md5") { Some(m) => m, None => { - tracing::warn!("[media:hardlink] no md5 attr in content (len={})", content.len()); + tracing::warn!( + "[media:hardlink] no md5 attr in content (len={})", + content.len() + ); return None; } }; @@ -431,7 +427,10 @@ fn find_dat_via_hardlink( return Some(dat_path.to_string_lossy().to_string()); } } - tracing::warn!("[media:hardlink] .dat file not found on disk for md5={}", image_md5); + tracing::warn!( + "[media:hardlink] .dat file not found on disk for md5={}", + image_md5 + ); None } @@ -470,7 +469,11 @@ fn find_file_hash_via_resource_db( let hex_info = info_rows.first()?.get("hex_info")?.as_str()?.to_string(); let file_hash = extract_file_hash_from_packed_info(&hex_info)?; - tracing::info!("[media:resource-db] file_hash={} for local_id={}", file_hash, local_id); + tracing::info!( + "[media:resource-db] file_hash={} for local_id={}", + file_hash, + local_id + ); Some(file_hash) } @@ -505,7 +508,10 @@ fn find_dat_via_resource_db( } } - tracing::warn!("[media:resource-db] file not on disk yet for hash={}", file_hash); + tracing::warn!( + "[media:resource-db] file not on disk yet for hash={}", + file_hash + ); None } @@ -535,7 +541,11 @@ fn get_video_data( let mp4_path = video_dir.join(format!("{hash}.mp4")); if mp4_path.exists() { if let Ok(data) = fs::read(&mp4_path) { - tracing::info!("[media:video] found mp4 for local_id={}, size={}", local_id, data.len()); + tracing::info!( + "[media:video] found mp4 for local_id={}, size={}", + local_id, + data.len() + ); return MediaResult { media_type: "video".into(), data: Some(base64::Engine::encode( @@ -593,7 +603,10 @@ fn get_video_data( } // Video exists but no file found on disk yet - tracing::warn!("[media:video] no video file found for local_id={}", local_id); + tracing::warn!( + "[media:video] no video file found for local_id={}", + local_id + ); pending() } @@ -608,7 +621,10 @@ fn extract_file_hash_from_packed_info(hex_info: &str) -> Option { if window.iter().all(|&b| b.is_ascii_hexdigit()) { let candidate = std::str::from_utf8(window).ok()?; // Verify it's lowercase hex (not random ASCII digits) - if candidate.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) { + if candidate + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { return Some(candidate.to_string()); } } @@ -616,11 +632,7 @@ fn extract_file_hash_from_packed_info(hex_info: &str) -> Option { None } -fn decrypt_and_return( - dat_path: &str, - image_keys: &ImageKeys, - local_id: i64, -) -> MediaResult { +fn decrypt_and_return(dat_path: &str, image_keys: &ImageKeys, local_id: i64) -> MediaResult { let dat = match fs::read(dat_path) { Ok(d) => d, Err(_) => { @@ -686,9 +698,7 @@ fn decrypt_and_return( if Path::new(&thumb_path).exists() { if let Ok(thumb_dat) = fs::read(&thumb_path) { if let Some(xb2) = resolve_xor_byte(&thumb_path, &thumb_dat, image_keys) { - if let Some(dec) = - decrypt_dat(&thumb_dat, &image_keys.aes_key_hex, xb2) - { + if let Some(dec) = decrypt_dat(&thumb_dat, &image_keys.aes_key_hex, xb2) { let (tf, te) = detect_image_format(&dec); return MediaResult { media_type: "image".into(), @@ -737,9 +747,7 @@ fn get_emoji_media( let rows = query_wechat_db( &emoticon_db, emoticon_key, - &format!( - "SELECT cdn_url FROM kNonStoreEmoticonTable WHERE md5 = '{md5_val}' LIMIT 1;" - ), + &format!("SELECT cdn_url FROM kNonStoreEmoticonTable WHERE md5 = '{md5_val}' LIMIT 1;"), ); if let Some(row) = rows.first() { if let Some(url) = row.get("cdn_url").and_then(|v| v.as_str()) { @@ -819,10 +827,7 @@ fn get_voice_data( LIMIT 1;" ), ); - let hex_data = match voice_rows - .first() - .and_then(|r| r.get("hex_data")?.as_str()) - { + let hex_data = match voice_rows.first().and_then(|r| r.get("hex_data")?.as_str()) { Some(h) if !h.is_empty() => h.to_string(), _ => continue, }; @@ -875,7 +880,9 @@ fn get_file_attachment( // Files are stored at /msg/file/YYYY-MM/ let dt = chrono::DateTime::from_timestamp(create_time, 0); - let year_month = dt.map(|d| d.format("%Y-%m").to_string()).unwrap_or_default(); + let year_month = dt + .map(|d| d.format("%Y-%m").to_string()) + .unwrap_or_default(); for base in &account_base_paths(account_dir) { let file_path = Path::new(base) @@ -918,7 +925,8 @@ pub fn get_message_media( None => { tracing::warn!( "[media] lookup_message_raw returned None for chat_id={}, local_id={}", - chat_id, local_id + chat_id, + local_id ); return unsupported(); } @@ -936,19 +944,19 @@ pub fn get_message_media( // Image tracing::info!( "[media] image msg chat_id={}, local_id={}, create_time={}, content_len={}", - chat_id, local_id, create_time, content.len() + chat_id, + local_id, + create_time, + content.len() ); // Try cached thumbnail first - if let Some(thumb) = - get_image_thumbnail(account_dir, chat_id, local_id, create_time) - { + if let Some(thumb) = get_image_thumbnail(account_dir, chat_id, local_id, create_time) { tracing::info!("[media] found thumbnail for local_id={}", local_id); return thumb; } tracing::info!("[media] no thumbnail for local_id={}", local_id); - // Try .dat decryption if we have image keys if let Some((aes_hex, xor_byte)) = image_keys_raw { let image_keys = ImageKeys { @@ -957,22 +965,24 @@ pub fn get_message_media( }; // Primary: look up filename from message_resource.db - if let Some(dat_path) = find_dat_via_resource_db( - account_dir, keys, chat_id, local_id, create_time, - ) { + if let Some(dat_path) = + find_dat_via_resource_db(account_dir, keys, chat_id, local_id, create_time) + { tracing::info!("[media] found dat via resource-db: {}", dat_path); return decrypt_and_return(&dat_path, &image_keys, local_id); } // Fallback: try hardlink.db (older images may not be in resource db) - if let Some(dat_path) = find_dat_via_hardlink(account_dir, keys, chat_id, &content) { + if let Some(dat_path) = find_dat_via_hardlink(account_dir, keys, chat_id, &content) + { tracing::info!("[media] found dat via hardlink: {}", dat_path); return decrypt_and_return(&dat_path, &image_keys, local_id); } tracing::warn!( "[media] no dat found for local_id={}, md5={}", - local_id, xml_attr(&content, "md5").unwrap_or_default() + local_id, + xml_attr(&content, "md5").unwrap_or_default() ); } else { tracing::warn!("[media] no image keys available for local_id={}", local_id); @@ -1001,9 +1011,7 @@ pub fn get_message_media( } _ => { // Other types: check for cached thumbnail - if let Some(thumb) = - get_image_thumbnail(account_dir, chat_id, local_id, create_time) - { + if let Some(thumb) = get_image_thumbnail(account_dir, chat_id, local_id, create_time) { return thumb; } unsupported() diff --git a/packages/agent-server-rust/src/tools/wechat_messages.rs b/packages/agent-server-rust/src/tools/wechat_messages.rs index 250ec20..2887d53 100644 --- a/packages/agent-server-rust/src/tools/wechat_messages.rs +++ b/packages/agent-server-rust/src/tools/wechat_messages.rs @@ -1,7 +1,7 @@ use super::wechat_db::{get_db_path, query_wechat_db}; -use crate::ia::types::{Message, ReplyInfo}; +use crate::ia::types::{Message, PaymentInfo, ReplyInfo}; use md5::{Digest, Md5}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// ZSTD magic number (little-endian): 0xFD2FB528 const ZSTD_MAGIC: &str = "28b52ffd"; @@ -68,18 +68,24 @@ fn clean_content(content: &str, msg_type: i32) -> String { // Image (type 3): replace XML with empty string 3 if content.contains(" String::new(), // Emoji (type 47): show cdnurl or [emoji] - 47 if content.contains(" { - extract_xml_attr(content, "cdnurl") - .filter(|u| u.starts_with("http")) - .unwrap_or_else(|| "[emoji]".to_string()) - } + 47 if content.contains(" extract_xml_attr(content, "cdnurl") + .filter(|u| u.starts_with("http")) + .unwrap_or_else(|| "[emoji]".to_string()), // Appmsg (type 49): handle subtypes 49 if content.contains("") => { let title = extract_xml_tag(content, "title").unwrap_or_default(); - let appmsg_type = extract_xml_tag(content, "type") - .and_then(|t| t.parse::().ok()) - .unwrap_or(0); + let appmsg_type = extract_appmsg_type(content).unwrap_or(0); match appmsg_type { + 2000 => { + let amount = extract_xml_tag(content, "feedesc").unwrap_or_default(); + if title.is_empty() { + amount + } else if amount.is_empty() { + title + } else { + format!("{title} {amount}") + } + } // Link share (5), video link (4), music share (3) 3 | 4 | 5 => { let mut parts = Vec::new(); @@ -145,7 +151,11 @@ fn extract_xml_attr(xml: &str, attr: &str) -> Option { let start = xml.find(&pattern)? + pattern.len(); let end = xml[start..].find('"')? + start; let val = xml[start..end].trim().to_string(); - if val.is_empty() { None } else { Some(val) } + if val.is_empty() { + None + } else { + Some(val) + } } /// Extract text between XML tags: text @@ -160,7 +170,187 @@ pub(crate) fn extract_xml_tag(xml: &str, tag: &str) -> Option { if val.starts_with("") { val = val[9..val.len() - 3].to_string(); } - if val.is_empty() { None } else { Some(val) } + if val.is_empty() { + None + } else { + Some(val) + } +} + +fn extract_query_param(url: &str, key: &str) -> Option { + let query = url.split('?').nth(1)?; + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + let k = parts.next()?.trim(); + let v = parts.next()?.trim(); + if k == key && !v.is_empty() { + return Some(v.to_string()); + } + } + None +} + +fn extract_appmsg_type(content: &str) -> Option { + extract_xml_tag(content, "type").and_then(|t| t.parse::().ok()) +} + +fn parse_amount_cents(text: &str) -> Option { + let filtered: String = text + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-') + .collect(); + if filtered.is_empty() { + return None; + } + + let negative = filtered.starts_with('-'); + let unsigned = filtered.trim_start_matches('-'); + let mut parts = unsigned.splitn(2, '.'); + let whole = parts.next()?.parse::().ok()?; + let frac_raw = parts.next().unwrap_or(""); + let mut frac = frac_raw.chars().take(2).collect::(); + while frac.len() < 2 { + frac.push('0'); + } + let frac = frac.parse::().ok()?; + let cents = whole.checked_mul(100)?.checked_add(frac)?; + Some(if negative { -cents } else { cents }) +} + +fn extract_payment_info(content: &str, msg_type: i32) -> Option { + let base = msg_type & 0x7FFFFFFF; + if base != 49 || !content.contains("") { + return None; + } + + let app_msg_type = extract_appmsg_type(content)?; + match app_msg_type { + 2000 => { + let amount_text = extract_xml_tag(content, "feedesc"); + Some(PaymentInfo { + kind: "transfer".to_string(), + app_msg_type: Some(app_msg_type), + amount_cents: amount_text.as_deref().and_then(parse_amount_cents), + amount_text, + currency: Some("CNY".to_string()), + transaction_id: extract_xml_tag(content, "transcationid"), + transfer_id: extract_xml_tag(content, "transferid"), + send_id: None, + pay_msg_id: None, + receiver_title: None, + native_url: None, + }) + } + 2001 => { + let message_url = extract_xml_tag(content, "url"); + let native_url = extract_xml_tag(content, "nativeurl"); + let send_id = native_url + .as_deref() + .and_then(|u| extract_query_param(u, "sendid")) + .or_else(|| { + message_url + .as_deref() + .and_then(|u| extract_query_param(u, "sendid")) + }); + + Some(PaymentInfo { + kind: "red_packet".to_string(), + app_msg_type: Some(app_msg_type), + amount_text: None, + amount_cents: None, + currency: Some("CNY".to_string()), + transaction_id: None, + transfer_id: None, + send_id, + pay_msg_id: extract_xml_tag(content, "paymsgid"), + receiver_title: extract_xml_tag(content, "receivertitle"), + native_url, + }) + } + _ => None, + } +} + +fn message_kind(msg_type: i32, app_msg_type: Option, payment: Option<&PaymentInfo>) -> String { + if let Some(payment) = payment { + return payment.kind.clone(); + } + + match msg_type & 0x7FFFFFFF { + 1 => "text", + 3 => "image", + 34 => "voice", + 42 => "contact", + 43 | 62 => "video", + 47 => "emoji", + 48 => "location", + 49 => match app_msg_type { + Some(3) => "music", + Some(4) | Some(5) => "link", + Some(6) => "file", + Some(8) => "sticker", + Some(19) => "location", + Some(33) | Some(36) => "mini_program", + Some(57) => "reply", + Some(63) => "livestream", + _ => "app", + }, + 10000 => "system", + 10002 => "recalled", + _ => "unknown", + } + .to_string() +} + +fn payment_identity(payment: &PaymentInfo) -> Option<&str> { + payment + .transaction_id + .as_deref() + .filter(|id| !id.is_empty()) + .or_else(|| payment.transfer_id.as_deref().filter(|id| !id.is_empty())) +} + +fn infer_payment_receipt_state(messages: &mut [Message]) { + let self_transfer_ids: HashSet = messages + .iter() + .filter(|message| message.is_self == Some(true)) + .filter_map(|message| match message.payment.as_ref() { + Some(payment) if payment.kind == "transfer" => { + payment_identity(payment).map(str::to_string) + } + _ => None, + }) + .collect(); + let inbound_transfer_ids: HashSet = messages + .iter() + .filter(|message| message.is_self != Some(true)) + .filter_map(|message| match message.payment.as_ref() { + Some(payment) if payment.kind == "transfer" => { + payment_identity(payment).map(str::to_string) + } + _ => None, + }) + .collect(); + let received_transfer_ids: HashSet = self_transfer_ids + .intersection(&inbound_transfer_ids) + .cloned() + .collect(); + + if received_transfer_ids.is_empty() { + return; + } + + for message in messages { + let Some(payment) = message.payment.as_ref() else { + continue; + }; + if payment.kind != "transfer" { + continue; + } + if payment_identity(payment).is_some_and(|id| received_transfer_ids.contains(id)) { + message.is_received = Some(true); + } + } } /// Check if the source XML indicates the current user is @-mentioned. @@ -201,9 +391,7 @@ pub fn find_message_db<'a>( let check = query_wechat_db( &db_path, key, - &format!( - "SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';" - ), + &format!("SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"), ); if !check.is_empty() { return Some((db_name.to_string(), key)); @@ -255,7 +443,8 @@ pub fn list_messages( let mut map = HashMap::new(); if let Some(contact_key) = keys.get("contact.db") { // Collect unique sender wxids - let senders: Vec = rows.iter() + let senders: Vec = rows + .iter() .filter_map(|row| { row.get("sender_name") .and_then(|v| v.as_str()) @@ -268,7 +457,11 @@ pub fn list_messages( if !senders.is_empty() { let contact_db = get_db_path(account_dir, "contact.db"); - let placeholders = senders.iter().map(|s| format!("'{}'", s.replace('\'', "''"))).collect::>().join(","); + let placeholders = senders + .iter() + .map(|s| format!("'{}'", s.replace('\'', "''"))) + .collect::>() + .join(","); let contacts = query_wechat_db( &contact_db, contact_key, @@ -276,8 +469,15 @@ pub fn list_messages( ); for c in contacts { if let Some(username) = c.get("username").and_then(|v| v.as_str()) { - let name = c.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) - .or_else(|| c.get("nick_name").and_then(|v| v.as_str()).filter(|s| !s.is_empty())) + let name = c + .get("remark") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + c.get("nick_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) .unwrap_or(username); map.insert(username.to_string(), name.to_string()); } @@ -287,17 +487,12 @@ pub fn list_messages( map }; - rows.iter() + let mut messages: Vec = rows + .iter() .filter_map(|row| { let local_id = row.get("local_id")?.as_i64()?; - let server_id = row - .get("server_id") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let msg_type = row - .get("local_type") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; + let server_id = row.get("server_id").and_then(|v| v.as_i64()).unwrap_or(0); + let msg_type = row.get("local_type").and_then(|v| v.as_i64()).unwrap_or(0) as i32; let hex_content = row .get("hex_content") @@ -327,6 +522,10 @@ pub fn list_messages( // Extract reply info before cleaning (needs raw XML) let reply = extract_reply_info(&body, msg_type); + let app_msg_type = extract_appmsg_type(&body); + let payment = extract_payment_info(&body, msg_type); + let kind = message_kind(msg_type, app_msg_type, payment.as_ref()); + let is_received = payment.as_ref().map(|_| false); // Clean content for display (replace XML with summaries) let content = clean_content(&body, msg_type); @@ -343,10 +542,7 @@ pub fn list_messages( // Check @-mention from source XML (only for group chats) let is_mentioned = if is_group { - let hex_source = row - .get("hex_source") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let hex_source = row.get("hex_source").and_then(|v| v.as_str()).unwrap_or(""); let source_compressed = row .get("source_compressed") .and_then(|v| v.as_i64()) @@ -379,7 +575,8 @@ pub fn list_messages( // Check if message was sent by the logged-in user let is_self = sender.as_ref().map(|s| account_dir.starts_with(s.as_str())); - let sender_name = sender.as_ref() + let sender_name = sender + .as_ref() .and_then(|wxid| contact_names.get(wxid)) .cloned(); @@ -390,12 +587,141 @@ pub fn list_messages( sender, sender_name, msg_type, + kind, + app_msg_type, content, timestamp, is_mentioned, is_self, + is_received, + received_at: None, reply, + payment, }) }) - .collect() + .collect(); + + infer_payment_receipt_state(&mut messages); + messages +} + +#[cfg(test)] +mod tests { + use super::{ + decode_message_content, extract_appmsg_type, extract_payment_info, + infer_payment_receipt_state, parse_amount_cents, + }; + use crate::ia::types::{Message, PaymentInfo}; + + const SAMPLE_TRANSFER_CONTENT_HEX: &str = "28B52FFD60AA032D1300569E6C40308BB60D10F0ADC733222241E6EB85D10C0BE14199FF24C6D51E58CC0FFCF6DF2B8B4B2BD112AD93889422303370E29ECA15F8F6D844727CD6107F188019540163005E0057000C444038428E47A4D3F1E9703C1C40F07139F18F8D0F0847A87F8BF3B2B0E8B810F2E0107210F26142874F0545010A02032A00CCEBBD5DF78C22E99A262B3F0663D39269B2B68A319574DA624C67245D9DB653BA2E934FE464DDAFEAFC188967359719DF516CEDCB6FEA61FB7BA39FAD8DB766016AA745FB69938C8CC9AD9AFA99C9CE1FC6A08E5180C67B5F7819DCF7F7FDB438DD9DBA1E6F7694DCAF6E8D7A78C28D04A08F0621C7135AD00845D000D258F0792021CCD00161A01052B061A44294AC24A809295D467885728AAA480B55A1DC5D7A45D92AD5F4A579430819D1F46FFFDC8CA2488A5366A0DE503CA30DC55AE6E36EDD9AF73298F9BCCC8217DB310BBB3B8390EED6D6FC86F88DE60E831886DDCFDA4F8B01D2D5691BA774C51929AA93D640C5D87856A9635BD564E8CDAF412C3FA6BEF6F9B36FB10DC59B6E8FA95B7B8E7B4BC5F373B7769F51A4AC262428168BB4756FD5A3985B65045559A4AC2895CAA5BB533C3F57054CA8D18D71024244C108A5052529742042832CAC6D12C0334290641CAD40918BF5FF3B8024D50D71942B18C2492922F281454762F5198422D1D7985C9C6B409737D83991DED48E889E058C9B771DDD3742A46817D3BED941FA2231E79F4189F02BC44CA3B3D5FAD61C801DD1C383081CD650F0D6B209E1CCE3A87D3F9A84F29247323EAEC691F8EDB1533CFFD7C615FD77C43046FD304A407E41EEC2485EC18603036ED8B8131955E8C59BC772E31BAA"; + const SAMPLE_TRANSFER_SOURCE_HEX: &str = "28B52FFD20853D030054053C6D7367736F757263653E0A093C7369676E61747572653E4E305F56315F63654F77784A51507C76315F652F564F37726D523C2F0A093C746D705F6E6F64093C7075626C69736865722D69643E3C2F0A093C2F3C2F05007C3018AA8D94B925E080286D0A0A"; + const SAMPLE_RED_PACKET_CONTENT_HEX: &str = "28B52FFD607D073D1A0086AA9042308D9B030005FB7E303030AC050F6E161F7AD558378EF945CD81FDC9646B6BA334648BB291DD3B4929F703DB74FEECF213DA35444476F7CA957E6450ABA5087608038D0073007A00DF89590CF907FA7C3C2EAC784FFEFB60E7DB0BC1FF3896037D24CB85FCD35AD2DECD84FCDB2F66BECF411F49A2083F51E43AB023651B0C8C898C0D149308A3C8C8C84387148E93110F38C69092910612510312514689C8A363ACF04134C164E4A181726181C02200808B896C133ECE2A15CBE672D180EF59804389CB4583F3F7B9C416EAEFF3AC62D96CA1CE024E6B555DAD5E6DBE35B1D7FE6A9AC557C5F09CA8E9977E55B1CBFAD85BD55EF8319D64B5ACBDAC8B5AD6C2D7C4BCD2AE89DF6AD485B1B4D3538FB16B52EC2DA5E4B4AC3B29B79ABDE51073612A5A6B65510CBBACE9925325AF29E7242B46492925DE1F62260DCEDDEFEDDCFD42E2166ACCA40917468E93112123291E1D23C8C7B8C1010AB8BD9BB34AC5E2754A29A96BC9A714E3EBAF461D4F14B6AEC9A7539C5FF4EED32F66154FDB39E4DFFD6CFBCB584E92B24E92E776DB1EB7E92C6E19AB3B9C8510220E20C649CAF9F1BA4ADDA7B5528C6A95D2FC52F3AA5C93D4A5EEF84EA7DCBA584E149B785A6CEF0C96CD166A29B3680FD4A44CCA2489FDD83DD05AB55258B5289D5E6711E7CE3D3BEFCBE170EEBCDEE6759D5FAFDB421DF24F82BF77C6794A39697467733A173D11A3E04001020338090418B0D0BCF60D7ED8A900BFC3DBB9DF3C0C5F33C8F6CE2B4914EFC9A2EF8F8E312344C406C808030511F4E2EFBF787F984103828451246CA8210E322515C88814252948E1204262A09B320F1200F3590EA350E59C2D2C3FCA9F643A28008E1840D5FAB0F3415A48833C13B70E22F0F5404C677EC05965E9F8A45B3B2F003607E66CCC8EBA23219AB09695EC21693827CE4F8C24CE25927A316A4F54D67FA5C71639D881CB9632400F35E7D449224DC7783DC40DFCC4AC05E5C2D0594E9F221D31FA2CC6C446F2E798D61CA56530FD90F0D0E1405FA0C069268D382FBB208D9176EC7B909963D34D2B6D001319745587174D2369A9385E00DB528D5346FAD014AC9081785BDC2DF36B373B647652629E0C4065B0EAA9ACB2958A36D8420176F7A9224A3442CA345029EF7FB2778AB7DEF4523104DC23F01155"; + const SAMPLE_RED_PACKET_SOURCE_HEX: &str = "28B52FFD20A7ED030092C6181D606B1E004A93F8DB688EF55DC1539172AF7BDA188AC04EC10104BC730EDCDC2871AFE7800A9BF747E3D1D91C691B256E02331404921D9A82441484325030EC9A5AB44062C17A2D6346FF49DD3A167F79187F74E1E4818EE6AFD3282DE8EAA51F5F7608001E6330541B21735F62041207E4A4F683736A08A618"; + + #[test] + fn parses_transfer_payment_metadata() { + let content = decode_message_content(SAMPLE_TRANSFER_CONTENT_HEX, true); + let source = decode_message_content(SAMPLE_TRANSFER_SOURCE_HEX, true); + let payment = extract_payment_info(&content, 49).expect("transfer payment info"); + + assert!(source.contains("")); + assert_eq!(extract_appmsg_type(&content), Some(2000)); + assert_eq!(payment.kind, "transfer"); + assert_eq!(payment.amount_text.as_deref(), Some("¥0.10")); + assert_eq!(payment.amount_cents, Some(10)); + assert_eq!( + payment.transaction_id.as_deref(), + Some("53010002627162202604101192831230") + ); + assert_eq!( + payment.transfer_id.as_deref(), + Some("1000050001202604101036531173241") + ); + } + + #[test] + fn parses_red_packet_metadata() { + let content = decode_message_content(SAMPLE_RED_PACKET_CONTENT_HEX, true); + let source = decode_message_content(SAMPLE_RED_PACKET_SOURCE_HEX, true); + let payment = extract_payment_info(&content, 49).expect("red-packet info"); + + assert!(source.contains("")); + assert_eq!(extract_appmsg_type(&content), Some(2001)); + assert_eq!(payment.kind, "red_packet"); + assert_eq!(payment.amount_text, None); + assert_eq!(payment.amount_cents, None); + assert_eq!( + payment.send_id.as_deref(), + Some("1000039801202604106156997548874") + ); + assert_eq!( + payment.pay_msg_id.as_deref(), + Some("1000039801202604106156997548874") + ); + assert_eq!( + payment.receiver_title.as_deref(), + Some("恭喜发财,大吉大利") + ); + } + + #[test] + fn parses_amount_text_to_cents() { + assert_eq!(parse_amount_cents("¥0.10"), Some(10)); + assert_eq!(parse_amount_cents("¥12"), Some(1200)); + assert_eq!(parse_amount_cents("12.3元"), Some(1230)); + } + + fn transfer_message(local_id: i64, transaction_id: &str, is_self: bool) -> Message { + Message { + local_id, + server_id: 0, + chat_id: "APEX_GLORY".to_string(), + sender: Some(if is_self { + "wxid_self".to_string() + } else { + "APEX_GLORY".to_string() + }), + sender_name: None, + msg_type: 49, + kind: "transfer".to_string(), + app_msg_type: Some(2000), + content: "微信转账 ¥0.10".to_string(), + timestamp: "2026-04-10T09:29:30+08:00".to_string(), + is_mentioned: None, + is_self: Some(is_self), + is_received: Some(false), + received_at: None, + reply: None, + payment: Some(PaymentInfo { + kind: "transfer".to_string(), + app_msg_type: Some(2000), + amount_text: Some("¥0.10".to_string()), + amount_cents: Some(10), + currency: Some("CNY".to_string()), + transaction_id: Some(transaction_id.to_string()), + transfer_id: None, + send_id: None, + pay_msg_id: None, + receiver_title: None, + native_url: None, + }), + } + } + + #[test] + fn infers_received_transfer_from_matching_self_receipt() { + let mut messages = vec![ + transfer_message(5, "new-transfer", false), + transfer_message(4, "accepted-transfer", true), + transfer_message(2, "accepted-transfer", false), + transfer_message(1, "outgoing-unmatched", true), + ]; + + infer_payment_receipt_state(&mut messages); + + assert_eq!(messages[0].is_received, Some(false)); + assert_eq!(messages[1].is_received, Some(true)); + assert_eq!(messages[2].is_received, Some(true)); + assert_eq!(messages[3].is_received, Some(false)); + } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4443abe..dbdaa2e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -368,6 +368,32 @@ messagesCmd await cmdSend(getClient(), chatId, opts.text, image, file); }); +messagesCmd + .command("transfer") + .description("Transfer commands") + .command("receive ") + .description("Receive an incoming transfer") + .option("-t, --transactionId ", "Optional transaction ID to receive") + .option("-l, --localId ", "Optional local message ID to receive", (v) => parseInt(v, 10)) + .action(async (chatId: string, opts: { transactionId?: string; localId?: number }) => { + await cmdReceiveTransfer(getClient(), chatId, opts.transactionId, opts.localId); + }); + +messagesCmd + .command("red-packet") + .description("Red packet commands") + .command("receive ") + .description("Attempt to receive a red packet") + .option("-l, --localId ", "Optional local message ID", (v) => parseInt(v, 10)) + .option("--sendId ", "Optional red-packet send ID") + .option("--payMsgId ", "Optional red-packet pay message ID") + .action(async ( + chatId: string, + opts: { localId?: number; sendId?: string; payMsgId?: string }, + ) => { + await cmdReceiveRedPacket(getClient(), chatId, opts); + }); + // ============================================ // Update Command // ============================================ @@ -617,15 +643,19 @@ const APPMSG_SUB_TYPES: Record = { 63: "livestream", }; -function getMsgTypeLabel(rawType: number): string { +function getMsgTypeLabel(msg: { type: number; kind?: string; appMsgType?: number }): string { + if (msg.kind && msg.kind !== "unknown") { + return msg.kind; + } + + const rawType = msg.type; const base = rawType & 0xFFFFFFFF; - const sub = Math.floor(rawType / 0x100000000); const baseLabel = MSG_BASE_TYPES[base]; if (!baseLabel) return `type:${rawType}`; - if (base === 49 && sub > 0) { - return APPMSG_SUB_TYPES[sub] ?? `appmsg:${sub}`; + if (base === 49 && msg.appMsgType != null) { + return APPMSG_SUB_TYPES[msg.appMsgType] ?? `appmsg:${msg.appMsgType}`; } return baseLabel; } @@ -648,7 +678,7 @@ async function cmdMessages(client: WeChatClient, chatId: string, limit: number = // Compute column widths const maxIdLen = Math.max(2, ...sorted.map(m => String(m.localId).length)); - const maxTypeLen = Math.max(4, ...sorted.map(m => getMsgTypeLabel(m.type).length)); + const maxTypeLen = Math.max(4, ...sorted.map(m => getMsgTypeLabel(m).length)); const formatSender = (m: (typeof sorted)[number]) => { const name = m.senderName; const id = m.sender; @@ -668,7 +698,7 @@ async function cmdMessages(client: WeChatClient, chatId: string, limit: number = for (const msg of sorted) { const time = new Date(msg.timestamp).toLocaleString(); - const typeLabel = getMsgTypeLabel(msg.type); + const typeLabel = getMsgTypeLabel(msg); const id = String(msg.localId).padEnd(maxIdLen); const mention = hasAnyMention ? (msg.isMentioned ? "Y" : "").padEnd(5) : ""; const sender = formatSender(msg).padEnd(maxSenderLen); @@ -678,6 +708,9 @@ async function cmdMessages(client: WeChatClient, chatId: string, limit: number = const rSnippet = msg.reply.content.length > 40 ? msg.reply.content.slice(0, 40) + "..." : msg.reply.content; preview = `[Re: ${rSender}${rSnippet}] ${preview}`; } + if (msg.isReceived != null) { + preview = `[${msg.isReceived ? "received" : "unreceived"}] ${preview}`; + } console.log(`${id} ${time.padEnd(22)} ${mention}${typeLabel.padEnd(maxTypeLen)} ${sender} ${preview}`); } @@ -815,6 +848,95 @@ async function cmdSend(client: WeChatClient, chatId: string, text?: string, imag } } +async function cmdReceiveTransfer( + client: WeChatClient, + chatId: string, + transactionId?: string, + localId?: number, +) { + console.log(`Receiving transfer for ${chatId}...`); + const result = await client.receiveTransfer(chatId, transactionId, localId); + + if (result.success) { + console.log("Transfer received successfully!"); + if (result.amountText) { + console.log(`Amount: ${result.amountText}`); + } + if (result.localId != null) { + console.log(`Local ID: ${result.localId}`); + } + if (result.transactionId) { + console.log(`Transaction ID: ${result.transactionId}`); + } + if (result.transferId) { + console.log(`Transfer ID: ${result.transferId}`); + } + if (result.receivedAt) { + console.log(`Received At: ${result.receivedAt}`); + } + } else if (result.error === "NOT_LOGGED_IN") { + console.error("Not logged in. Run: pnpm cli auth login"); + process.exit(1); + } else { + console.error(`Failed to receive transfer: ${result.error || "Unknown error"}`); + if (result.amountText) { + console.error(`Amount: ${result.amountText}`); + } + if (result.localId != null) { + console.error(`Local ID: ${result.localId}`); + } + process.exit(1); + } +} + +async function cmdReceiveRedPacket( + client: WeChatClient, + chatId: string, + opts: { localId?: number; sendId?: string; payMsgId?: string }, +) { + console.log(`Receiving red packet for ${chatId}...`); + const result = await client.receiveRedPacket(chatId, opts); + + if (result.success) { + console.log("Red packet received successfully!"); + if (result.amountText) { + console.log(`Amount: ${result.amountText}`); + } + return; + } + + if (result.error === "NOT_LOGGED_IN") { + console.error("Not logged in. Run: pnpm cli auth login"); + process.exit(1); + } + + if (result.error === "UNSUPPORTED_ON_LINUX_WECHAT_CLIENT") { + console.error("Red packet receipt is not supported by the Linux WeChat client."); + if (result.localId != null) { + console.error(`Local ID: ${result.localId}`); + } + if (result.sendId) { + console.error(`Send ID: ${result.sendId}`); + } + if (result.payMsgId) { + console.error(`PayMsg ID: ${result.payMsgId}`); + } + process.exit(1); + } + + console.error(`Failed to receive red packet: ${result.error || "Unknown error"}`); + if (result.localId != null) { + console.error(`Local ID: ${result.localId}`); + } + if (result.sendId) { + console.error(`Send ID: ${result.sendId}`); + } + if (result.payMsgId) { + console.error(`PayMsg ID: ${result.payMsgId}`); + } + process.exit(1); +} + async function cmdScreenshot(client: WeChatClient, outputPath: string) { console.log(`Capturing screenshot...`); const result = await client.screenshot(); diff --git a/packages/openclaw-extension/src/agent-tools.test.ts b/packages/openclaw-extension/src/agent-tools.test.ts new file mode 100644 index 0000000..be96c07 --- /dev/null +++ b/packages/openclaw-extension/src/agent-tools.test.ts @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ResolvedWeChatAccount } from "./types.ts"; +import { + createWeChatReceiveTransferTool, +} from "./agent-tools.ts"; +import { formatPaymentBody } from "./payment-format.ts"; + +function baseAccount(overrides: Partial = {}): ResolvedWeChatAccount { + return { + accountId: "default", + enabled: true, + serverUrl: "http://localhost:6174", + token: "token", + dmPolicy: "open", + allowFrom: [], + groupPolicy: "open", + groupAllowFrom: [], + groups: {}, + pollIntervalMs: 1000, + authPollIntervalMs: 30000, + ...overrides, + }; +} + +test("wechat_receive_transfer posts the targeted transfer request", async () => { + const tool = createWeChatReceiveTransferTool(baseAccount()); + const calls: Array<{ url: string; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => { + calls.push({ url: String(input), init }); + return new Response( + JSON.stringify({ + success: true, + kind: "transfer", + localId: 16, + amountText: "¥0.30", + transactionId: "tx-16", + transferId: "tr-16", + receivedAt: "2026-04-13T10:00:00Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + try { + const result = await tool.execute("tool-call", { + chatId: "wechat:APEX_GLORY", + localId: 16, + }); + + assert.equal(calls.length, 1); + assert.equal( + calls[0].url, + "http://localhost:6174/api/messages/APEX_GLORY/transfer/receive", + ); + assert.equal(calls[0].init?.method, "POST"); + assert.equal( + calls[0].init?.body, + JSON.stringify({ transactionId: undefined, localId: 16 }), + ); + assert.match(result.content[0]?.text ?? "", /Received the WeChat transfer ¥0.30/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("formatPaymentBody includes status and targeting metadata", () => { + const text = formatPaymentBody({ + localId: 16, + serverId: 16, + chatId: "APEX_GLORY", + sender: "APEX_GLORY", + senderName: "APEX_GLORY", + type: 49, + kind: "transfer", + appMsgType: 2000, + content: "微信转账 ¥0.30", + timestamp: "2026-04-13T10:00:00Z", + isSelf: false, + isReceived: false, + payment: { + kind: "transfer", + appMsgType: 2000, + amountText: "¥0.30", + amountCents: 30, + currency: "CNY", + transactionId: "tx-16", + transferId: "tr-16", + }, + }); + + assert.equal( + text, + "[Transfer - ¥0.30 - unreceived] 微信转账 ¥0.30 (localId: 16, transactionId: tx-16)", + ); +}); diff --git a/packages/openclaw-extension/src/agent-tools.ts b/packages/openclaw-extension/src/agent-tools.ts index c99402c..f94a890 100644 --- a/packages/openclaw-extension/src/agent-tools.ts +++ b/packages/openclaw-extension/src/agent-tools.ts @@ -1,12 +1,23 @@ import type { ResolvedWeChatAccount } from "./types.js"; import { WeChatClient } from "@agent-wechat/shared"; -import { loginStart, getActiveLoginState } from "./login.js"; -export function createWeChatLoginTool(account: ResolvedWeChatAccount) { - const client = new WeChatClient({ +function createClient(account: ResolvedWeChatAccount) { + return new WeChatClient({ baseUrl: account.serverUrl, token: account.token, }); +} + +function normalizeChatId(raw: unknown): string | null { + if (typeof raw !== "string") { + return null; + } + const chatId = raw.replace(/^wechat:/i, "").trim(); + return chatId || null; +} + +export function createWeChatLoginTool(account: ResolvedWeChatAccount) { + const client = createClient(account); return { label: "WeChat Login", @@ -56,6 +67,8 @@ export function createWeChatLoginTool(account: ResolvedWeChatAccount) { } case "start": { + const { getActiveLoginState, loginStart } = await import("./login.js"); + // Check for existing active login session const existing = getActiveLoginState(account.accountId); if (existing.active && !force) { @@ -160,3 +173,93 @@ export function createWeChatLoginTool(account: ResolvedWeChatAccount) { }, }; } + +export function createWeChatReceiveTransferTool(account: ResolvedWeChatAccount) { + const client = createClient(account); + + return { + label: "WeChat Transfer", + name: "wechat_receive_transfer", + description: + "Receive an incoming WeChat transfer in a chat. Use this when the current WeChat message is an unreceived transfer. Prefer passing the localId from the transfer message when available. If localId or transactionId are omitted, the latest receivable transfer in the chat is used.", + parameters: { + type: "object", + properties: { + chatId: { + type: "string", + description: + "WeChat chat ID for the transfer, for example a wxid, a contact username, or a wechat: prefixed chat target from the current conversation context.", + }, + localId: { + type: "number", + description: + "Optional local message ID for the exact transfer to receive. Prefer the localId shown in the current transfer message when available.", + }, + transactionId: { + type: "string", + description: + "Optional transfer transaction ID. Use this when localId is unavailable.", + }, + }, + required: ["chatId"], + }, + execute: async (_toolCallId: string, params: unknown) => { + const args = params as Record; + const chatId = normalizeChatId(args.chatId); + const localId = typeof args.localId === "number" + ? args.localId + : undefined; + const transactionId = typeof args.transactionId === "string" && + args.transactionId.trim() + ? args.transactionId.trim() + : undefined; + + if (!chatId) { + return { + content: [{ + type: "text" as const, + text: "Failed to receive the WeChat transfer: chatId is required.", + }], + details: { error: true, reason: "missing_chat_id" }, + }; + } + + try { + const result = await client.receiveTransfer( + chatId, + transactionId, + localId, + ); + const amount = result.amountText ? ` ${result.amountText}` : ""; + const details = [ + `Received the WeChat transfer${amount} in ${chatId}.`, + result.localId != null ? `Local ID: ${result.localId}` : undefined, + result.transactionId + ? `Transaction ID: ${result.transactionId}` + : undefined, + result.transferId ? `Transfer ID: ${result.transferId}` : undefined, + result.receivedAt ? `Received at: ${result.receivedAt}` : undefined, + ].filter(Boolean); + + if (result.success) { + return { + content: [{ type: "text" as const, text: details.join("\n") }], + details: result, + }; + } + + const failureText = `Failed to receive the WeChat transfer in ${chatId}: ${result.error ?? "Unknown error"}.`; + return { + content: [{ type: "text" as const, text: failureText }], + details: result, + }; + } catch (err) { + const text = `Failed to receive the WeChat transfer in ${chatId}: ${err instanceof Error ? err.message : String(err)}`; + return { + content: [{ type: "text" as const, text }], + details: { error: true }, + }; + } + }, + }; +} diff --git a/packages/openclaw-extension/src/channel.ts b/packages/openclaw-extension/src/channel.ts index 141fc4a..4ee7d25 100644 --- a/packages/openclaw-extension/src/channel.ts +++ b/packages/openclaw-extension/src/channel.ts @@ -9,7 +9,10 @@ import { collectWeChatStatusIssues } from "./status.js"; import { WeChatClient } from "@agent-wechat/shared"; import { loginStart, loginWait, loginTerminal } from "./login.js"; // loginWait still used by gateway.loginWithQrWait -import { createWeChatLoginTool } from "./agent-tools.js"; +import { + createWeChatLoginTool, + createWeChatReceiveTransferTool, +} from "./agent-tools.js"; import { normalizeWeChatCommandBody, normalizeWeChatId } from "./access-control.js"; const meta: ChannelPlugin["meta"] = { @@ -414,7 +417,10 @@ export const wechatPlugin: ChannelPlugin = { agentTools: (({ cfg }: { cfg?: any }) => { const account = resolveWeChatAccount(cfg as Record); if (!account?.serverUrl) return []; - return [createWeChatLoginTool(account)]; + return [ + createWeChatLoginTool(account), + createWeChatReceiveTransferTool(account), + ]; }) as any, // ---- Directory adapter ---- diff --git a/packages/openclaw-extension/src/monitor.ts b/packages/openclaw-extension/src/monitor.ts index afc5b7b..cea2716 100644 --- a/packages/openclaw-extension/src/monitor.ts +++ b/packages/openclaw-extension/src/monitor.ts @@ -12,6 +12,7 @@ import { resolveWeChatPolicyContext, type WeChatPolicyContext, } from "./access-control.js"; +import { formatPaymentBody } from "./payment-format.js"; // Message types that may have downloadable media const MEDIA_TYPES = new Set([3, 34, 43]); // image, voice, video @@ -296,9 +297,10 @@ async function prepareMessage( let hasMedia = false; const baseType = msg.type & 0x7fffffff; + const isPaymentMessage = msg.kind === "transfer" || msg.kind === "red_packet"; // Type 49 (appmsg) may contain file attachments — the server resolves subtypes // and returns type="file" for subtype 6. Try fetching media for type 49 as well. - const mayHaveMedia = MEDIA_TYPES.has(baseType) || baseType === 49; + const mayHaveMedia = !isPaymentMessage && (MEDIA_TYPES.has(baseType) || baseType === 49); if (mayHaveMedia) { log?.info?.(`[wechat:${liveAccount.accountId}] Checking media for msg ${msg.localId} (type ${baseType})`); @@ -345,7 +347,7 @@ async function prepareMessage( } const timestamp = new Date(msg.timestamp).getTime(); - let rawBody = msg.content || ""; + let rawBody = formatPaymentBody(msg) ?? msg.content ?? ""; if (mediaPath && mediaMime) { if (!rawBody) { if (mediaMime.startsWith("audio/")) { @@ -599,6 +601,20 @@ async function dispatchSegment( CommandAuthorized: commandAuthorized, OriginatingChannel: "wechat", OriginatingTo: `wechat:${chatId}`, + ...(msg.payment + ? { + PaymentKind: msg.payment.kind, + PaymentLocalId: msg.localId, + PaymentAmountText: msg.payment.amountText, + PaymentAmountCents: msg.payment.amountCents, + PaymentCurrency: msg.payment.currency, + PaymentIsReceived: msg.isReceived, + PaymentTransactionId: msg.payment.transactionId, + PaymentTransferId: msg.payment.transferId, + PaymentSendId: msg.payment.sendId, + PaymentPayMsgId: msg.payment.payMsgId, + } + : {}), ...(mediaPath ? { MediaPath: mediaPath, MediaUrl: mediaPath, MediaType: mediaMime } : {}), ...(msg.reply ? { ReplyToBody: msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content, diff --git a/packages/openclaw-extension/src/payment-format.ts b/packages/openclaw-extension/src/payment-format.ts new file mode 100644 index 0000000..31c7425 --- /dev/null +++ b/packages/openclaw-extension/src/payment-format.ts @@ -0,0 +1,36 @@ +import type { Message } from "@agent-wechat/shared"; + +export function formatPaymentBody(msg: Message): string | undefined { + if (msg.kind !== "transfer" && msg.kind !== "red_packet") { + return undefined; + } + + const label = msg.kind === "transfer" ? "Transfer" : "Red packet"; + const status = msg.isReceived == null + ? undefined + : msg.isReceived + ? "received" + : "unreceived"; + const parts = [label]; + + if (msg.payment?.amountText) { + parts.push(msg.payment.amountText); + } + if (status) { + parts.push(status); + } + + const metadata = [`localId: ${msg.localId}`]; + if (msg.kind === "transfer" && msg.payment?.transactionId) { + metadata.push(`transactionId: ${msg.payment.transactionId}`); + } + if (msg.kind === "red_packet" && msg.payment?.sendId) { + metadata.push(`sendId: ${msg.payment.sendId}`); + } + if (msg.kind === "red_packet" && msg.payment?.payMsgId) { + metadata.push(`payMsgId: ${msg.payment.payMsgId}`); + } + + const summary = `[${parts.join(" - ")}] ${msg.content || ""}`.trim(); + return `${summary} (${metadata.join(", ")})`; +} diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 7d845c1..a13f707 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -3,6 +3,7 @@ import type { Contact, Message, SendResult, + ReceivePaymentResult, MediaResult, LoginResult, LoginSubscriptionEvent, @@ -192,6 +193,31 @@ export class WeChatClient { return this.post("/api/messages/send", params); } + async receiveTransfer( + chatId: string, + transactionId?: string, + localId?: number, + ): Promise { + return this.post( + `/api/messages/${encodeURIComponent(chatId)}/transfer/receive`, + { transactionId, localId }, + ); + } + + async receiveRedPacket( + chatId: string, + options?: { + localId?: number; + sendId?: string; + payMsgId?: string; + }, + ): Promise { + return this.post( + `/api/messages/${encodeURIComponent(chatId)}/red-packet/receive`, + options ?? {}, + ); + } + // ---- Debug ---- async screenshot(): Promise<{ base64: string }> { diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 3f28e5a..66a0dd6 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -168,8 +168,31 @@ export const messageSchema = z.object({ sender: z.string().optional(), senderName: z.string().optional(), type: z.number().int(), + kind: z.string(), + appMsgType: z.number().int().optional(), content: z.string(), timestamp: z.string(), + isMentioned: z.boolean().optional(), + isSelf: z.boolean().optional(), + isReceived: z.boolean().optional(), + receivedAt: z.string().optional(), + reply: z.object({ + sender: z.string().optional(), + content: z.string(), + }).optional(), + payment: z.object({ + kind: z.string(), + appMsgType: z.number().int().optional(), + amountText: z.string().optional(), + amountCents: z.number().int().optional(), + currency: z.string().optional(), + transactionId: z.string().optional(), + transferId: z.string().optional(), + sendId: z.string().optional(), + payMsgId: z.string().optional(), + receiverTitle: z.string().optional(), + nativeUrl: z.string().optional(), + }).optional(), }); export const listMessagesParamsSchema = z.object({ @@ -197,6 +220,22 @@ export const sendResultSchema = z.object({ error: z.string().optional(), }); +export const receivePaymentResultSchema = z.object({ + success: z.boolean(), + kind: z.string(), + error: z.string().optional(), + localId: z.number().int().optional(), + isReceived: z.boolean().optional(), + receivedAt: z.string().optional(), + amountText: z.string().optional(), + amountCents: z.number().int().optional(), + currency: z.string().optional(), + transactionId: z.string().optional(), + transferId: z.string().optional(), + sendId: z.string().optional(), + payMsgId: z.string().optional(), +}); + export const getMediaParamsSchema = z.object({ chatId: z.string().min(1), localId: z.number().int(), @@ -237,6 +276,7 @@ export type Message = z.infer; export type ListMessagesParams = z.infer; export type SendParams = z.infer; export type SendResult = z.infer; +export type ReceivePaymentResult = z.infer; export type GetMediaParams = z.infer; export type MediaResult = z.infer; export type AgentConfig = z.infer; diff --git a/packages/shared/src/types/generated/Message.ts b/packages/shared/src/types/generated/Message.ts index 51fedf9..19b4bcc 100644 --- a/packages/shared/src/types/generated/Message.ts +++ b/packages/shared/src/types/generated/Message.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PaymentInfo } from "./PaymentInfo.js"; import type { ReplyInfo } from "./ReplyInfo.js"; -export type Message = { localId: number, serverId: number, chatId: string, sender?: string, senderName?: string, type: number, content: string, timestamp: string, isMentioned?: boolean, isSelf?: boolean, reply?: ReplyInfo, }; +export type Message = { localId: number, serverId: number, chatId: string, sender?: string, senderName?: string, type: number, kind: string, appMsgType?: number, content: string, timestamp: string, isMentioned?: boolean, isSelf?: boolean, isReceived?: boolean, receivedAt?: string, reply?: ReplyInfo, payment?: PaymentInfo, }; diff --git a/packages/shared/src/types/generated/PaymentInfo.ts b/packages/shared/src/types/generated/PaymentInfo.ts new file mode 100644 index 0000000..a47293b --- /dev/null +++ b/packages/shared/src/types/generated/PaymentInfo.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PaymentInfo = { kind: string, appMsgType?: number, amountText?: string, amountCents?: number, currency?: string, transactionId?: string, transferId?: string, sendId?: string, payMsgId?: string, receiverTitle?: string, nativeUrl?: string, }; diff --git a/packages/shared/src/types/generated/ReceivePaymentResult.ts b/packages/shared/src/types/generated/ReceivePaymentResult.ts new file mode 100644 index 0000000..4fe2dcf --- /dev/null +++ b/packages/shared/src/types/generated/ReceivePaymentResult.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReceivePaymentResult = { success: boolean, kind: string, error?: string, localId?: number, isReceived?: boolean, receivedAt?: string, amountText?: string, amountCents?: number, currency?: string, transactionId?: string, transferId?: string, sendId?: string, payMsgId?: string, }; diff --git a/packages/shared/src/types/generated/index.ts b/packages/shared/src/types/generated/index.ts index 555a843..d412777 100644 --- a/packages/shared/src/types/generated/index.ts +++ b/packages/shared/src/types/generated/index.ts @@ -10,6 +10,8 @@ export type { LoginSubscriptionEvent } from "./LoginSubscriptionEvent.js"; export type { MediaResult } from "./MediaResult.js"; export type { Message } from "./Message.js"; export type { OpenChatResult } from "./OpenChatResult.js"; +export type { PaymentInfo } from "./PaymentInfo.js"; +export type { ReceivePaymentResult } from "./ReceivePaymentResult.js"; export type { ReplyInfo } from "./ReplyInfo.js"; export type { SendParams } from "./SendParams.js"; export type { SendResult } from "./SendResult.js"; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index cdb56fd..c7c01d2 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -6,7 +6,9 @@ export type { Chat } from "./generated/Chat.js"; export type { Contact } from "./generated/Contact.js"; export type { Message } from "./generated/Message.js"; +export type { PaymentInfo } from "./generated/PaymentInfo.js"; export type { LoginSubscriptionEvent } from "./generated/LoginSubscriptionEvent.js"; +export type { ReceivePaymentResult } from "./generated/ReceivePaymentResult.js"; export type { SendParams } from "./generated/SendParams.js"; export type { ImageData } from "./generated/ImageData.js"; export type { FileData } from "./generated/FileData.js"; @@ -129,6 +131,19 @@ export interface GetMediaParams { localId: number; } +export interface ReceiveTransferParams { + chatId: string; + transactionId?: string; + localId?: number; +} + +export interface ReceiveRedPacketParams { + chatId: string; + localId?: number; + sendId?: string; + payMsgId?: string; +} + // Note: MediaResult kept handwritten (Rust uses plain string for type, // TS has richer string literal union) export interface MediaResult { diff --git a/scripts/generate-types.sh b/scripts/generate-types.sh index 74ae77f..75a6bfe 100755 --- a/scripts/generate-types.sh +++ b/scripts/generate-types.sh @@ -22,7 +22,7 @@ TS_RS_EXPORT_DIR="$GENERATED_DIR" cargo test --quiet 2>/dev/null # Fix imports: ts-rs generates `from "./Foo"` but NodeNext requires `from "./Foo.js"` for f in "$GENERATED_DIR"/*.ts; do if grep -q 'from "./' "$f"; then - sed -i 's|from "\(\./[^"]*\)"|from "\1.js"|g' "$f" + perl -pi -e 's|from "(\./[^"]+)"|from "$1.js"|g' "$f" fi done