diff --git a/Cargo.lock b/Cargo.lock index 55c26eb2..9c9b0371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2097,10 +2097,10 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.22.4", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tower-service", ] @@ -3122,6 +3122,7 @@ dependencies = [ "ratatui", "reqwest 0.12.0", "rusqlite", + "rustls 0.23.43", "screenshots", "serde", "serde_json", @@ -3134,6 +3135,7 @@ dependencies = [ "time-core", "time-macros", "tokio", + "tokio-tungstenite", "toml 0.8.12", "toml_edit 0.22.8", "tracing", @@ -3843,7 +3845,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.22.4", "rustls-pemfile", "rustls-pki-types", "serde", @@ -3851,7 +3853,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 0.1.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tokio-util", "tower-service", "url", @@ -4007,7 +4009,21 @@ dependencies = [ "log", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -4041,6 +4057,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -5164,11 +5191,21 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" dependencies = [ - "rustls", + "rustls 0.22.4", "rustls-pki-types", "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.43", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -5177,8 +5214,12 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", + "rustls 0.23.43", + "rustls-pki-types", "tokio", + "tokio-rustls 0.26.4", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -5487,6 +5528,8 @@ dependencies = [ "httparse", "log", "rand", + "rustls 0.23.43", + "rustls-pki-types", "sha1", "thiserror 2.0.18", "utf-8", diff --git a/apps/omninova-tauri/src-tauri/src/lib.rs b/apps/omninova-tauri/src-tauri/src/lib.rs index 1e790835..fe305f8b 100644 --- a/apps/omninova-tauri/src-tauri/src/lib.rs +++ b/apps/omninova-tauri/src-tauri/src/lib.rs @@ -5,13 +5,15 @@ mod desktop_capture; use base64::Engine; use omninova_core::channels::{ChannelKind, InboundMessage}; use omninova_core::config::{ - ChannelEntry, ChannelsConfig, Config, GatewayPublicConfig, ModelProviderConfig, + ChannelEntry, ChannelsConfig, Config, GatewayPublicConfig, GatewayPublicMode, ModelProviderConfig, ProviderConfig, RobotConfig, }; use omninova_core::gateway::{ - check_gateway_public_health, normalize_gateway_public_config, + check_dingtalk_public_route, check_gateway_public_health, + dingtalk_diagnostic_config_state, feishu_diagnostic_config_state, + feishu_public_callback_urls, normalize_gateway_public_config, normalize_public_webhook_base_url, GatewayHealth, GatewayInboundResponse, - GatewayPublicHealthStatus, GatewayRuntime, GatewayRuntimeStatus, + DingtalkPublicRouteProbe, GatewayPublicHealthStatus, GatewayRuntime, GatewayRuntimeStatus, GatewaySessionHistoryResponse, GatewaySessionTreeQuery, GatewaySessionTreeResponse, }; use omninova_core::providers::{ProviderSelection, build_provider_with_selection}; @@ -783,9 +785,10 @@ async fn open_workspace_dir( #[tauri::command] async fn save_setup_config( - config: SetupAppConfig, + mut config: SetupAppConfig, validate_all_channels: Option, active_channel_id: Option, + changed_channel_ids: Option>, state: tauri::State<'_, Arc>>, ) -> Result { let state_ref = state.inner().clone(); @@ -804,19 +807,32 @@ async fn save_setup_config( } else { ChannelValidationScope::Current(active_channel_id.as_deref().unwrap_or("feishu")) }; + if let (Some(channels), Some(changed_channel_ids)) = + (config.channels.as_mut(), changed_channel_ids.as_deref()) + { + retain_changed_setup_channels(channels, changed_channel_ids); + } let mut next = setup_config_to_core(current, config, validation_scope)?; let next_gateway_url = format!("http://{}:{}", next.gateway.host, next.gateway.port); - let workspace_changed = current_workspace_dir != next.workspace_dir; + let workspace_changed = !workspace_paths_equivalent(¤t_workspace_dir, &next.workspace_dir); save_config_with_fallback(&mut next)?; runtime.set_config(next).await.map_err(|e| e.to_string())?; let mut restarted = false; - if current_gateway_url != next_gateway_url || workspace_changed { + let gateway_url_changed = current_gateway_url != next_gateway_url; + if gateway_url_changed || workspace_changed { + println!( + "[gateway-lifecycle] restart_trigger=config_compare gateway_url_changed={} workspace_changed={} dingtalk_changed={} feishu_changed={}", + gateway_url_changed, + workspace_changed, + false, + false + ); // Restart gateway so new workspace_dir takes effect and tools are recreated. - stop_gateway_inner(&state_ref).await; + stop_gateway_inner(&state_ref, "config_change").await; sleep(Duration::from_millis(200)).await; - if let Err(e) = start_gateway_inner(state_ref.clone()).await { + if let Err(e) = start_gateway_inner(state_ref.clone(), "config_change").await { return Err(format!("配置已保存但网关重启失败: {e}")); } restarted = true; @@ -1309,7 +1325,11 @@ async fn preflight_gateway_bind(host: &str, port: u16) -> Result<(), (String, St } /// 启动本机 HTTP 网关(与 `omninova` CLI 使用同一配置与端口,便于后台常驻后命令行调用)。 -async fn start_gateway_inner(state_ref: Arc>) -> Result { +async fn start_gateway_inner( + state_ref: Arc>, + reason: &'static str, +) -> Result { + println!("[gateway-lifecycle] start reason={reason}"); sync_gateway_task_state(&state_ref).await; let runtime = { let app_state = state_ref.lock().await; @@ -1442,7 +1462,7 @@ async fn start_gateway( state: tauri::State<'_, Arc>>, ) -> Result { let state_ref = state.inner().clone(); - start_gateway_inner(state_ref).await + start_gateway_inner(state_ref, "user_command").await } #[tauri::command] @@ -1450,7 +1470,7 @@ async fn stop_gateway( state: tauri::State<'_, Arc>>, ) -> Result { let state_ref = state.inner().clone(); - stop_gateway_inner(&state_ref).await; + stop_gateway_inner(&state_ref, "user_command").await; Ok(gateway_status_from_state(&state_ref).await) } @@ -1459,9 +1479,9 @@ async fn restart_gateway( state: tauri::State<'_, Arc>>, ) -> Result { let state_ref = state.inner().clone(); - stop_gateway_inner(&state_ref).await; + stop_gateway_inner(&state_ref, "user_command").await; sleep(Duration::from_millis(100)).await; - start_gateway_inner(state_ref).await + start_gateway_inner(state_ref, "user_command").await } #[tauri::command] @@ -1543,6 +1563,7 @@ async fn test_gateway_health( #[tauri::command] async fn test_gateway_public_health( + base_url: Option, state: tauri::State<'_, Arc>>, ) -> Result { let state_ref = state.inner().clone(); @@ -1551,12 +1572,29 @@ async fn test_gateway_public_health( let app_state = state_ref.lock().await; (app_state.runtime.clone(), app_state.gateway_task.is_some()) }; - let config = runtime.get_config().await; + let mut config = runtime.get_config().await; + let requested_base_url = match base_url { + Some(value) => match normalize_public_webhook_base_url(&value) { + Some(value) => { + // This is an ephemeral probe target from the current UI draft. + // It must not persist or be overridden by a stale named-tunnel + // field while the request is in flight. + config.gateway_public.mode = GatewayPublicMode::ExternalPublicUrl; + config.gateway_public.public_webhook_base_url = Some(value.clone()); + Some(value) + } + None => { + let result = GatewayPublicHealthStatus::not_configured(); + let mut app_state = state_ref.lock().await; + app_state.last_public_health = Some(result.clone()); + return Ok(result); + } + }, + None => omninova_core::gateway::resolve_public_webhook_base_url(&config), + }; let mut result = if running { check_gateway_public_health(&config).await - } else if let Some(base_url) = - omninova_core::gateway::resolve_public_webhook_base_url(&config) - { + } else if let Some(base_url) = requested_base_url { GatewayPublicHealthStatus { configured: true, ok: false, @@ -1579,14 +1617,209 @@ async fn test_gateway_public_health( // Keep the configured base in the cached snapshot so stale results are // discarded automatically after the user changes Public Base URL. if result.base_url.is_none() { - result.base_url = - omninova_core::gateway::resolve_public_webhook_base_url(&config); + result.base_url = omninova_core::gateway::resolve_public_webhook_base_url(&config); } let mut app_state = state_ref.lock().await; app_state.last_public_health = Some(result.clone()); Ok(result) } +#[tauri::command] +async fn dingtalk_diagnostics( + state: tauri::State<'_, Arc>>, +) -> Result { + let state_ref = state.inner().clone(); + sync_gateway_task_state(&state_ref).await; + let status = gateway_status_from_state(&state_ref).await; + let runtime = { + let app_state = state_ref.lock().await; + app_state.runtime.clone() + }; + let config = runtime.get_config().await; + let config_status = dingtalk_diagnostic_config_state(&config); + let worker_started = status.running && runtime.dingtalk_worker_started().await; + let public_base_url = omninova_core::gateway::resolve_public_webhook_base_url(&config); + let final_dingtalk_callback_url = dingtalk_callback_url(public_base_url.as_deref()); + let public_health = if !status.public_health.configured { + "not_configured" + } else if status.public_health.ok { + "ok" + } else if status.public_health.error_kind.as_deref() == Some("not_checked") { + "not_checked" + } else { + "failed" + } + .to_string(); + let local_health = if !status.running { + "not_ready" + } else if status.health_ok { + "ok" + } else { + "failed" + } + .to_string(); + + let mut next_steps = Vec::new(); + if !config_status.enabled { + next_steps.push("启用 DingTalk 频道后重新启动 Gateway。".to_string()); + } + if !config_status.app_key_present { + next_steps.push("配置 DingTalk App Key。".to_string()); + } + if !config_status.app_secret_present { + next_steps.push("配置 DingTalk App Secret。".to_string()); + } + if !config_status.robot_code_present { + next_steps.push("配置 DingTalk RobotCode。".to_string()); + } + if config_status.outbound_mode.trim().is_empty() + || config_status.outbound_mode == "disabled" + { + next_steps.push("配置 DingTalk outbound mode。".to_string()); + } + if !status.running { + next_steps.push("启动 Gateway。".to_string()); + } + if public_base_url.is_none() { + next_steps.push("填写 Public Base URL 公网根地址。".to_string()); + } else if public_health == "failed" { + next_steps.push("检查 cloudflared 是否运行,公网地址是否已过期。".to_string()); + } + if status.running && !worker_started { + next_steps.push("DingTalk worker 未启动,请重启 Gateway 并检查启动日志。".to_string()); + } + + Ok(DingtalkDiagnosticsPayload { + dingtalk_enabled: config_status.enabled, + app_key_present: config_status.app_key_present, + app_secret_present: config_status.app_secret_present, + robot_code_present: config_status.robot_code_present, + webhook_path: config_status.webhook_path, + local_gateway_running: status.running, + local_health, + public_base_url_present: public_base_url.is_some(), + public_base_url, + public_health, + public_health_status_code: status.public_health.status_code, + public_health_error: status.public_health.error, + final_dingtalk_callback_url, + worker_started, + outbound_mode: config_status.outbound_mode, + public_mode: status.gateway_public_mode, + quick_tunnel: status.quick_tunnel_non_production, + next_steps, + }) +} + +#[tauri::command] +async fn feishu_diagnostics( + state: tauri::State<'_, Arc>>, +) -> Result { + let state_ref = state.inner().clone(); + sync_gateway_task_state(&state_ref).await; + let status = gateway_status_from_state(&state_ref).await; + let runtime = { + let app_state = state_ref.lock().await; + app_state.runtime.clone() + }; + let config = runtime.get_config().await; + let config_status = feishu_diagnostic_config_state(&config); + let public_base_url = omninova_core::gateway::resolve_public_webhook_base_url(&config); + let (final_feishu_event_callback_url, final_feishu_card_callback_url) = + feishu_public_callback_urls(&config); + let public_health = if !status.public_health.configured { + "not_configured" + } else if status.public_health.ok { + "ok" + } else if status.public_health.error_kind.as_deref() == Some("not_checked") { + "not_checked" + } else { + "failed" + } + .to_string(); + let local_health = if !status.running { + "not_ready" + } else if status.health_ok { + "ok" + } else { + "failed" + } + .to_string(); + + let mut next_steps = Vec::new(); + if !config_status.enabled { + next_steps.push("启用 Feishu 频道后重新启动 Gateway。".to_string()); + } + if !config_status.app_id_present { + next_steps.push("配置 Feishu App ID。".to_string()); + } + if !config_status.app_secret_present { + next_steps.push("配置 Feishu App Secret。".to_string()); + } + if matches!(config_status.security_mode.as_str(), "token" | "encrypted") + && !config_status.verification_token_present + { + next_steps.push("当前安全模式需要配置 Verification Token。".to_string()); + } + if config_status.security_mode == "encrypted" && !config_status.encrypt_key_present { + next_steps.push("encrypted 模式需要配置 Encrypt Key。".to_string()); + } + if config_status.outbound_mode == "disabled" { + next_steps.push("配置 Feishu outbound mode。".to_string()); + } + if !status.running { + next_steps.push("启动 Gateway。".to_string()); + } + if public_base_url.is_none() { + next_steps.push("填写 Public Base URL 公网根地址。".to_string()); + } else if public_health == "failed" { + next_steps.push("检查 Public Health、cloudflared 和公网地址是否有效。".to_string()); + } + + Ok(FeishuDiagnosticsPayload { + feishu_enabled: config_status.enabled, + app_id_present: config_status.app_id_present, + app_secret_present: config_status.app_secret_present, + verification_token_present: config_status.verification_token_present, + encrypt_key_present: config_status.encrypt_key_present, + security_mode: config_status.security_mode, + outbound_mode: config_status.outbound_mode, + store_opened: status.store_opened, + retry_worker_started: status.retry_worker_enabled, + local_gateway_running: status.running, + local_health, + public_base_url_present: public_base_url.is_some(), + public_base_url, + public_health, + public_health_status_code: status.public_health.status_code, + public_health_error: status.public_health.error, + final_feishu_event_callback_url, + final_feishu_card_callback_url, + quick_tunnel: status.quick_tunnel_non_production, + next_steps, + }) +} + +#[tauri::command] +async fn test_dingtalk_public_route( + base_url: Option, + state: tauri::State<'_, Arc>>, +) -> Result { + let runtime = { + let app_state = state.lock().await; + app_state.runtime.clone() + }; + let config = runtime.get_config().await; + let base_url = base_url + .as_deref() + .and_then(normalize_public_webhook_base_url) + .or_else(|| omninova_core::gateway::resolve_public_webhook_base_url(&config)); + Ok(match base_url { + Some(base_url) => check_dingtalk_public_route(&base_url).await, + None => check_dingtalk_public_route("").await, + }) +} + #[tauri::command] fn cli_install_status(app: AppHandle) -> Result { cli_install::cli_install_status(&app) @@ -1868,18 +2101,28 @@ fn extract_error_code(error: &str) -> Option { } } -async fn stop_gateway_inner(state: &Arc>) { - let task = { +async fn stop_gateway_inner(state: &Arc>, reason: &'static str) { + println!("[gateway-lifecycle] stop reason={reason}"); + let (task, runtime) = { let mut app_state = state.lock().await; let task = app_state.gateway_task.take(); + let runtime = app_state.runtime.clone(); app_state.last_gateway_error = None; app_state.last_gateway_error_code = None; - task + // A successful public probe is no longer authoritative after the + // local origin stops. Clear it so restart cannot resurrect stale OK. + app_state.last_public_health = None; + (task, runtime) }; if let Some(task) = task { + // Stop the parent first so it cannot race by starting a child after an + // early NotRunning observation. Dropping serve_http signals the child. task.abort(); let _ = task.await; } + // The Stream reconnect loop is independently spawned. Explicitly join it + // after the parent is gone so restart is physically 1 -> 0 -> 1. + runtime.shutdown_dingtalk_stream_and_join().await; } fn setup_config_from_core(config: &Config) -> SetupAppConfig { @@ -1945,7 +2188,7 @@ fn setup_config_from_core(config: &Config) -> SetupAppConfig { gateway_public, robot: config.robot.clone(), providers, - channels: Some(channels_from_core(&config.channels_config)), + channels: Some(channels_from_core(config)), multimodal: SetupMultimodalConfig { desktop_vision_enabled: config.multimodal.desktop_vision_enabled, desktop_vision_max_dimension_px: config.multimodal.desktop_vision_max_dimension_px, @@ -2025,7 +2268,24 @@ fn channel_entry_from_core(entry: &Option) -> Option SetupChannelsConfig { +fn channels_from_core(config: &Config) -> SetupChannelsConfig { + let cfg = &config.channels_config; + let mut dingtalk = channel_entry_from_core(&cfg.dingtalk); + if let Some(entry) = dingtalk.as_mut() { + entry + .extra + .entry("transport_mode".to_string()) + .or_insert_with(|| serde_json::json!(config.gateway.dingtalk.transport_mode.as_str())); + if !config.gateway.dingtalk.card_template_id.trim().is_empty() { + entry + .extra + .entry("card_template_id".to_string()) + .or_insert_with(|| { + serde_json::json!(config.gateway.dingtalk.card_template_id.trim()) + }); + } + } + SetupChannelsConfig { telegram: channel_entry_from_core(&cfg.telegram), discord: channel_entry_from_core(&cfg.discord), @@ -2034,7 +2294,7 @@ fn channels_from_core(cfg: &ChannelsConfig) -> SetupChannelsConfig { wechat: channel_entry_from_core(&cfg.wechat), feishu: channel_entry_from_core(&cfg.feishu), lark: channel_entry_from_core(&cfg.lark), - dingtalk: channel_entry_from_core(&cfg.dingtalk), + dingtalk, matrix: channel_entry_from_core(&cfg.matrix), email: channel_entry_from_core(&cfg.email), msteams: channel_entry_from_core(&cfg.msteams), @@ -2553,6 +2813,78 @@ fn merge_channel_entry( } } +#[derive(Debug, Clone, Serialize)] +struct DingtalkDiagnosticsPayload { + dingtalk_enabled: bool, + app_key_present: bool, + app_secret_present: bool, + robot_code_present: bool, + webhook_path: String, + local_gateway_running: bool, + local_health: String, + public_base_url_present: bool, + public_base_url: Option, + public_health: String, + public_health_status_code: Option, + public_health_error: Option, + final_dingtalk_callback_url: Option, + worker_started: bool, + outbound_mode: String, + public_mode: String, + quick_tunnel: bool, + next_steps: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct FeishuDiagnosticsPayload { + feishu_enabled: bool, + app_id_present: bool, + app_secret_present: bool, + verification_token_present: bool, + encrypt_key_present: bool, + security_mode: String, + outbound_mode: String, + store_opened: bool, + retry_worker_started: bool, + local_gateway_running: bool, + local_health: String, + public_base_url_present: bool, + public_base_url: Option, + public_health: String, + public_health_status_code: Option, + public_health_error: Option, + final_feishu_event_callback_url: Option, + final_feishu_card_callback_url: Option, + quick_tunnel: bool, + next_steps: Vec, +} + +fn dingtalk_callback_url(base_url: Option<&str>) -> Option { + base_url + .and_then(normalize_public_webhook_base_url) + .map(|base| format!("{base}/api/v1/gateway/dingtalk/events")) +} + +/// Keep only channels the current editor explicitly changed. Missing entries +/// are merged from the persisted config by `channels_to_core`, preventing a +/// stale/default selected channel from disabling another active integration. +fn retain_changed_setup_channels(channels: &mut SetupChannelsConfig, changed: &[String]) { + let has = |channel_id: &str| changed.iter().any(|item| item == channel_id); + if !has("telegram") { channels.telegram = None; } + if !has("discord") { channels.discord = None; } + if !has("slack") { channels.slack = None; } + if !has("whatsapp") { channels.whatsapp = None; } + if !has("wechat") { channels.wechat = None; } + if !has("feishu") { channels.feishu = None; } + if !has("lark") { channels.lark = None; } + if !has("dingtalk") { channels.dingtalk = None; } + if !has("matrix") { channels.matrix = None; } + if !has("email") { channels.email = None; } + if !has("msteams") { channels.msteams = None; } + if !has("irc") { channels.irc = None; } + if !has("webhook") { channels.webhook = None; } +} + /// Validate that enabled Feishu/Lark channels have required extra fields fn validate_feishu_like_channels(channels: &SetupChannelsConfig) -> Result<(), String> { // Check Feishu @@ -2702,6 +3034,31 @@ fn expand_tilde_path(value: &str) -> PathBuf { PathBuf::from(value) } +/// Semantic path comparison for gateway-restart decisions. +/// +/// A frontend round-trip can normalize a path (trailing slash, `~` expansion, +/// repeated separators) without the user changing anything. Treating those +/// representations as "changed" would cause a false-positive gateway restart. +/// Comparison is ASCII case-insensitive on Windows where the filesystem is. +fn workspace_paths_equivalent(left: L, right: R) -> bool +where + L: AsRef, + R: AsRef, +{ + let normalize = |path: &std::path::Path| -> String { + let mut normalized = path.to_string_lossy().replace('\\', "/"); + while normalized.ends_with('/') { + normalized.pop(); + } + #[cfg(windows)] + { + normalized = normalized.to_ascii_lowercase(); + } + normalized + }; + normalize(left.as_ref()) == normalize(right.as_ref()) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct TaskArtifactPreview { @@ -3068,6 +3425,9 @@ pub fn run() { restart_gateway, test_gateway_health, test_gateway_public_health, + feishu_diagnostics, + dingtalk_diagnostics, + test_dingtalk_public_route, ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { @@ -3149,7 +3509,7 @@ pub fn run() { let state_autostart = state.clone(); tauri::async_runtime::spawn(async move { sleep(Duration::from_millis(500)).await; - match start_gateway_inner(state_autostart).await { + match start_gateway_inner(state_autostart, "startup_autostart").await { Ok(s) => eprintln!("[gateway] background started: {}", s.url), Err(e) => eprintln!("[gateway] auto-start failed: {e}"), } @@ -3269,11 +3629,290 @@ mod webview_startup_tests { } } +#[cfg(test)] +mod gateway_lifecycle_tests { + use super::*; + + #[test] + fn workspace_paths_equivalent_ignores_trailing_slash_and_case() { + // A config round-trip must never decide "workspace changed" merely + // because the path lost its trailing slash or changed case. + assert!(workspace_paths_equivalent( + PathBuf::from(r"D:\123"), + PathBuf::from(r"D:\123\") + )); + assert!(workspace_paths_equivalent( + PathBuf::from(r"D:\123"), + PathBuf::from(r"d:\123") + )); + assert!(workspace_paths_equivalent( + PathBuf::from("/home/user/ws"), + PathBuf::from("/home/user/ws/") + )); + // A real change must still be detected. + assert!(!workspace_paths_equivalent( + PathBuf::from(r"D:\123"), + PathBuf::from(r"D:\456") + )); + assert!(!workspace_paths_equivalent( + PathBuf::from(""), + PathBuf::from(r"D:\123") + )); + } + + #[test] + fn workspace_paths_equivalent_empty_vs_empty() { + assert!(workspace_paths_equivalent(PathBuf::from(""), PathBuf::from(""))); + } + + #[test] + fn workspace_paths_equivalent_mixed_separators() { + // Frontend may send forward slashes while the stored value uses + // backslashes on Windows; both describe the same directory. + #[cfg(windows)] + assert!(workspace_paths_equivalent( + PathBuf::from(r"D:\123\"), + PathBuf::from("D:/123") + )); + } +} + #[cfg(test)] mod channel_tests { use super::*; use std::collections::HashMap; + #[test] + fn dingtalk_callback_url_uses_public_root_without_duplicate_slashes() { + assert_eq!( + dingtalk_callback_url(Some("https://example.test")), + Some("https://example.test/api/v1/gateway/dingtalk/events".to_string()) + ); + assert_eq!( + dingtalk_callback_url(Some("https://example.test/")), + Some("https://example.test/api/v1/gateway/dingtalk/events".to_string()) + ); + assert_eq!( + dingtalk_callback_url(Some( + "https://example.test/api/v1/gateway/dingtalk/events" + )), + Some("https://example.test/api/v1/gateway/dingtalk/events".to_string()) + ); + assert_eq!(dingtalk_callback_url(None), None); + } + + #[test] + fn dingtalk_diagnostics_payload_contains_no_sensitive_values() { + let payload = DingtalkDiagnosticsPayload { + dingtalk_enabled: true, + app_key_present: true, + app_secret_present: true, + robot_code_present: true, + webhook_path: "/api/v1/gateway/dingtalk/events".to_string(), + local_gateway_running: true, + local_health: "ok".to_string(), + public_base_url_present: true, + public_base_url: Some("https://example.test".to_string()), + public_health: "ok".to_string(), + public_health_status_code: Some(200), + public_health_error: None, + final_dingtalk_callback_url: Some( + "https://example.test/api/v1/gateway/dingtalk/events".to_string(), + ), + worker_started: true, + outbound_mode: "session_webhook".to_string(), + public_mode: "quick_tunnel".to_string(), + quick_tunnel: true, + next_steps: Vec::new(), + }; + let rendered = serde_json::to_string(&payload).expect("serialize diagnostics"); + for forbidden in [ + "secret-value", + "access-token-value", + "session-webhook-value", + "robot-code-value", + "conversation-id-value", + "message-id-value", + ] { + assert!(!rendered.contains(forbidden)); + } + } + + #[test] + fn setup_save_retains_only_explicitly_changed_channel_entries() { + let mut setup = SetupChannelsConfig { + feishu: Some(SetupChannelEntry { + enabled: false, + ..Default::default() + }), + dingtalk: Some(SetupChannelEntry { + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + + retain_changed_setup_channels(&mut setup, &["dingtalk".to_string()]); + + assert!(setup.feishu.is_none()); + assert!(setup.dingtalk.is_some()); + } + + #[test] + fn setup_hydrates_dingtalk_transport_without_clearing_template() { + use omninova_core::config::schema::DingtalkTransportMode; + + let mut config = Config::default(); + config.gateway.dingtalk.transport_mode = DingtalkTransportMode::Stream; + config.gateway.dingtalk.card_template_id = "saved-template".to_string(); + config.channels_config.dingtalk = Some(ChannelEntry { + enabled: true, + ..Default::default() + }); + + let setup = channels_from_core(&config); + let dingtalk = setup.dingtalk.expect("DingTalk setup entry"); + assert_eq!( + dingtalk.extra.get("transport_mode"), + Some(&serde_json::json!("stream")) + ); + assert_eq!( + dingtalk.extra.get("card_template_id"), + Some(&serde_json::json!("saved-template")) + ); + } + + #[test] + fn setup_save_does_not_disable_unchanged_enabled_channels() { + let current = ChannelsConfig { + feishu: Some(ChannelEntry { + enabled: true, + ..Default::default() + }), + dingtalk: Some(ChannelEntry { + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + let mut setup = SetupChannelsConfig { + feishu: Some(SetupChannelEntry { + enabled: false, + ..Default::default() + }), + dingtalk: Some(SetupChannelEntry { + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + retain_changed_setup_channels(&mut setup, &["dingtalk".to_string()]); + + let merged = channels_to_core(setup, ¤t); + assert!(merged.feishu.as_ref().is_some_and(|entry| entry.enabled)); + assert!(merged.dingtalk.as_ref().is_some_and(|entry| entry.enabled)); + } + + #[test] + fn enabled_channel_status_reports_feishu_and_dingtalk_together() { + let channels = ChannelsConfig { + feishu: Some(ChannelEntry { + enabled: true, + ..Default::default() + }), + dingtalk: Some(ChannelEntry { + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!(enabled_channel_names(&channels), vec!["feishu", "dingtalk"]); + } + + #[test] + fn saving_dingtalk_preserves_complete_feishu_configuration() { + let feishu_extra = HashMap::from([ + ("app_id".to_string(), serde_json::json!("saved-feishu-app")), + ("app_secret".to_string(), serde_json::json!("saved-feishu-secret")), + ("outbound_mode".to_string(), serde_json::json!("real")), + ]); + let current = ChannelsConfig { + feishu: Some(ChannelEntry { + enabled: true, + security_mode: Some("encrypted".to_string()), + verification_token: Some("saved-feishu-token".to_string()), + encrypt_key: Some("saved-feishu-key".to_string()), + extra: feishu_extra.clone(), + ..Default::default() + }), + dingtalk: Some(ChannelEntry { + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + let mut setup = SetupChannelsConfig { + dingtalk: Some(SetupChannelEntry { + enabled: true, + extra: HashMap::from([( + "app_key".to_string(), + serde_json::json!("updated-dingtalk-app"), + )]), + ..Default::default() + }), + ..Default::default() + }; + retain_changed_setup_channels(&mut setup, &["dingtalk".to_string()]); + + let merged = channels_to_core(setup, ¤t); + let feishu = merged.feishu.expect("Feishu must be preserved"); + assert!(feishu.enabled); + assert_eq!(feishu.extra, feishu_extra); + assert_eq!(feishu.security_mode.as_deref(), Some("encrypted")); + assert_eq!(feishu.verification_token.as_deref(), Some("saved-feishu-token")); + assert_eq!(feishu.encrypt_key.as_deref(), Some("saved-feishu-key")); + } + + #[test] + fn saving_feishu_preserves_complete_dingtalk_configuration() { + let dingtalk_extra = HashMap::from([ + ("app_key".to_string(), serde_json::json!("saved-dingtalk-app")), + ("app_secret".to_string(), serde_json::json!("saved-dingtalk-secret")), + ("robot_code".to_string(), serde_json::json!("saved-dingtalk-robot")), + ("outbound_mode".to_string(), serde_json::json!("session_webhook")), + ]); + let current = ChannelsConfig { + feishu: Some(ChannelEntry { + enabled: true, + ..Default::default() + }), + dingtalk: Some(ChannelEntry { + enabled: true, + extra: dingtalk_extra.clone(), + ..Default::default() + }), + ..Default::default() + }; + let mut setup = SetupChannelsConfig { + feishu: Some(SetupChannelEntry { + enabled: true, + extra: HashMap::from([( + "app_id".to_string(), + serde_json::json!("updated-feishu-app"), + )]), + ..Default::default() + }), + ..Default::default() + }; + retain_changed_setup_channels(&mut setup, &["feishu".to_string()]); + + let merged = channels_to_core(setup, ¤t); + let dingtalk = merged.dingtalk.expect("DingTalk must be preserved"); + assert!(dingtalk.enabled); + assert_eq!(dingtalk.extra, dingtalk_extra); + } + #[test] fn setup_migrates_legacy_public_webhook_url_to_gateway_public() { let mut core = Config::default(); diff --git a/apps/omninova-tauri/src/components/Setup/ChannelConfigForm.tsx b/apps/omninova-tauri/src/components/Setup/ChannelConfigForm.tsx index 93b5da39..b67c6ee0 100644 --- a/apps/omninova-tauri/src/components/Setup/ChannelConfigForm.tsx +++ b/apps/omninova-tauri/src/components/Setup/ChannelConfigForm.tsx @@ -19,6 +19,10 @@ interface ChannelConfigFormProps { onCopyWebhookUrl?: (url: string) => void; selectedChannelId?: string; onSelectedChannelChange?: (channelId: string) => void; + /** Runtime truth returned by gateway_status; independent from the editor selection. */ + enabledChannelIds?: string[]; + /** Shared Gateway Public Base URL used by status, callbacks, and health checks. */ + publicBaseUrl?: string; } /** Get card callback path for feishu channel (used when channel is feishu) */ @@ -32,7 +36,7 @@ function getWebhookPath(channelId: string): string { case "feishu": return "/webhook/feishu"; case "lark": return "/webhook/lark"; case "wechat": return "/webhook/wechat"; - case "dingtalk": return "/webhook/dingtalk"; + case "dingtalk": return "/api/v1/gateway/dingtalk/events"; case "webhook": return "/webhook"; default: return "/webhook"; } @@ -47,6 +51,8 @@ function isLocalhost(url: string): boolean { function normalizePublicWebhookBaseUrl(value: string): string { return value .trim() + .replace(/\/api\/v1\/gateway\/dingtalk\/events\/?$/i, "") + .replace(/\/webhook\/dingtalk\/?$/i, "") .replace(/\/webhook\/feishu\/card\/?$/i, "") .replace(/\/webhook\/feishu\/?$/i, "") .replace(/\/$/, ""); @@ -72,6 +78,8 @@ export function ChannelConfigForm({ onCopyWebhookUrl, selectedChannelId, onSelectedChannelChange, + enabledChannelIds, + publicBaseUrl, }: ChannelConfigFormProps) { const [uncontrolledSelectedId, setUncontrolledSelectedId] = useState(DEFAULT_CHANNEL_ID); const selectedId = selectedChannelId ?? uncontrolledSelectedId; @@ -156,7 +164,6 @@ export function ChannelConfigForm({ || field.key === "security_mode" || field.key === "verification_token" || field.key === "encrypt_key" - || field.key === "public_webhook_base_url" : base.has(field.key) ); } @@ -184,15 +191,25 @@ export function ChannelConfigForm({ onChange({ ...value, [id]: cleanedEntry }); }; - const enabledList = CHANNEL_PRESETS.filter( - (preset) => getEntry(preset.id).enabled + const displayedEnabledChannelIds = enabledChannelIds + ?? CHANNEL_PRESETS.filter((preset) => getEntry(preset.id).enabled).map( + (preset) => preset.id + ); + const enabledList = CHANNEL_PRESETS.filter((preset) => + displayedEnabledChannelIds.includes(preset.id) ).map((preset) => preset.name); const entry = selectedPreset ? getEntry(selectedPreset.id) : { ...EMPTY_ENTRY, extra: {} }; + const dingtalkTransportMode = selectedPreset?.id === "dingtalk" + ? entry.extra?.["transport_mode"] || "http" + : "http"; const publicWebhookBaseUrl = selectedPreset?.id === "feishu" ? normalizePublicWebhookBaseUrl(entry.extra?.["public_webhook_base_url"] ?? "") : ""; - const webhookBaseUrl = publicWebhookBaseUrl || gatewayUrl?.trim() || ""; + const webhookBaseUrl = normalizePublicWebhookBaseUrl(publicBaseUrl ?? "") + || publicWebhookBaseUrl + || gatewayUrl?.trim() + || ""; const fullWebhookUrl = webhookBaseUrl ? `${webhookBaseUrl.replace(/\/$/, "")}${webhookPath}` : ""; @@ -466,6 +483,20 @@ export function ChannelConfigForm({
{visibleFields.map((field) => { + if (field.key === "transport_mode" && selectedPreset.id === "dingtalk") { + return ( + + ); + } // Special handling for outbound_mode - render as dropdown if (field.key === "outbound_mode") { return ( @@ -527,6 +558,7 @@ export function ChannelConfigForm({ handleFieldChange(field, event.target.value) } @@ -540,6 +572,26 @@ export function ChannelConfigForm({ })}
+ {selectedPreset.id === "dingtalk" && ( +
+

互动卡片

+ {dingtalkTransportMode === "stream" ? ( + <> + Stream 模式已选择 +

互动卡片可用条件:Stream 已连接,且 Card Template ID 已配置。

+ + ) : ( + <> + 当前不可用 +

+ OmniNova 互动卡片需要 DingTalk Stream 模式。HTTP 模式仍支持普通文本消息和 Agent 对话。 + Card Template ID 会保留,切回 Stream 后无需重新填写。 +

+ + )} +
+ )} + {selectedPreset.id === "feishu" && (

飞书接入指引

@@ -594,7 +646,11 @@ export function ChannelConfigForm({
  • 获取 App Key 和 App Secret
  • - 配置消息接收地址为 /webhook/dingtalk + 如需 sendFromApp fallback,请在本地配置中设置 + gateway.dingtalk.robot_code;诊断区只显示 present / missing。 +
  • +
  • + 配置消息接收地址为 /api/v1/gateway/dingtalk/events
  • diff --git a/apps/omninova-tauri/src/components/Setup/Setup.tsx b/apps/omninova-tauri/src/components/Setup/Setup.tsx index 921f5e27..8cd89ad3 100644 --- a/apps/omninova-tauri/src/components/Setup/Setup.tsx +++ b/apps/omninova-tauri/src/components/Setup/Setup.tsx @@ -1,8 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + CHANNEL_PRESETS, DEFAULT_PROVIDERS, DEFAULT_ROBOT_CONFIG, type Config, + type DingtalkDiagnostics, + type DingtalkPublicRouteProbe, + type FeishuDiagnostics, type GatewayPublicMode, type GatewayStatus, } from "../../types/config"; @@ -38,10 +42,43 @@ const SENSITIVE_KEYS = new Set([ const LARK_BLOCKER_MESSAGE = "Lark 已启用但缺少 App ID。请补全 Lark 配置或关闭 Lark。"; -function enabledChannelIds(config: Config): string[] { - return Object.entries(config.channels) - .filter(([, channel]) => channel?.enabled) - .map(([channelId]) => channelId); +type HealthUiStatus = "not_configured" | "not_ready" | "idle" | "ok" | "error"; + +interface LocalHealthResult { + ok: boolean; + status_code?: number | null; + message: string; +} + +const CHANNEL_LABELS = new Map( + CHANNEL_PRESETS.map((preset) => [preset.id, preset.name]) +); + +function formatEnabledChannels(channelIds: string[]): string { + return channelIds + .map((channelId) => CHANNEL_LABELS.get(channelId) ?? channelId) + .join("、"); +} + +function channelIdIsKnown(channelId: string | null | undefined): channelId is string { + return Boolean(channelId && CHANNEL_LABELS.has(channelId)); +} + +function readRememberedChannelId(): string | null { + try { + const remembered = window.sessionStorage.getItem("omninova.setup.activeChannel"); + return channelIdIsKnown(remembered) ? remembered : null; + } catch { + return null; + } +} + +function rememberChannelId(channelId: string): void { + try { + window.sessionStorage.setItem("omninova.setup.activeChannel", channelId); + } catch { + // Storage can be unavailable in privacy-restricted browser contexts. + } } function formatGatewayStartError(message: string): string { @@ -55,7 +92,10 @@ function normalizePublicBaseUrl(value: string | null | undefined): string | null if (!trimmed) { return null; } - const normalized = trimmed.replace(/\/webhook\/feishu(?:\/card)?$/i, ""); + const normalized = trimmed + .replace(/\/api\/v1\/gateway\/dingtalk\/events$/i, "") + .replace(/\/webhook\/dingtalk$/i, "") + .replace(/\/webhook\/feishu(?:\/card)?$/i, ""); const base = normalized.replace(/\/+$/, ""); if (/^https?:\/\//i.test(base)) { try { @@ -92,6 +132,16 @@ function normalizeNamedTunnelHostname(value: string | null | undefined): string } } +function resolveDraftPublicBase( + gatewayPublic: Config["gateway_public"] +): string | null { + if (gatewayPublic?.mode === "named_cloudflare_tunnel") { + const hostname = normalizeNamedTunnelHostname(gatewayPublic.named_tunnel_hostname); + return hostname ? `https://${hostname}` : null; + } + return normalizePublicBaseUrl(gatewayPublic?.public_webhook_base_url); +} + /** Redact sensitive values in a JSON object for display */ function redactSensitiveFields(obj: unknown): unknown { if (obj === null || obj === undefined) { @@ -269,6 +319,10 @@ export function Setup({ } }; const [config, setConfig] = useState(initialConfig); + const gatewayPublicDraftRef = useRef( + resolveDraftPublicBase(initialConfig.gateway_public) + ); + gatewayPublicDraftRef.current = resolveDraftPublicBase(config.gateway_public); const [previewCollapsed, setPreviewCollapsed] = useState(true); const [gatewayStatus, setGatewayStatus] = useState({ running: false, @@ -296,11 +350,33 @@ export function Setup({ last_error: null, }); const [busyAction, setBusyAction] = useState< - "load" | "save" | "start" | "stop" | "restart" | "health" | "public-health" | null + "load" | "save" | "start" | "stop" | "restart" | null >(null); const [actionMessage, setActionMessage] = useState(""); const [channelValidationError, setChannelValidationError] = useState(); - const [activeChannelId, setActiveChannelId] = useState("feishu"); + const [activeChannelId, setActiveChannelIdState] = useState(""); + const dirtyChannelIdsRef = useRef>(new Set()); + const [localHealthStatus, setLocalHealthStatus] = useState("not_ready"); + const [localHealthMessage, setLocalHealthMessage] = useState("Gateway 未运行。"); + const [localHealthStatusCode, setLocalHealthStatusCode] = useState(null); + const [localHealthLoading, setLocalHealthLoading] = useState(false); + const [publicHealthStatus, setPublicHealthStatus] = useState( + "not_configured" + ); + const [publicHealthMessage, setPublicHealthMessage] = useState( + "Public Base URL 未配置。" + ); + const [publicHealthStatusCode, setPublicHealthStatusCode] = useState(null); + const [publicHealthLoading, setPublicHealthLoading] = useState(false); + const [feishuDiagnostics, setFeishuDiagnostics] = + useState(null); + const [feishuDiagnosticsLoading, setFeishuDiagnosticsLoading] = useState(false); + const [dingtalkDiagnostics, setDingtalkDiagnostics] = + useState(null); + const [dingtalkDiagnosticsLoading, setDingtalkDiagnosticsLoading] = useState(false); + const [dingtalkRouteProbe, setDingtalkRouteProbe] = + useState(null); + const [dingtalkRouteLoading, setDingtalkRouteLoading] = useState(false); const [cliInstall, setCliInstall] = useState(null); const [cliBusy, setCliBusy] = useState(false); const enabledProviders = useMemo( @@ -371,6 +447,93 @@ export function Setup({ ? `${config.default_provider}::${config.default_model}` : ""; + const setActiveChannelId = useCallback((channelId: string) => { + if (!channelIdIsKnown(channelId)) { + return; + } + setActiveChannelIdState(channelId); + rememberChannelId(channelId); + }, []); + + const syncHealthFromGatewayStatus = useCallback(( + status: GatewayStatus, + syncPublicHealth = true, + ) => { + if (!status.running) { + setLocalHealthStatus("not_ready"); + setLocalHealthMessage("Gateway 未运行,本地 Health 未就绪。"); + setLocalHealthStatusCode(null); + } else if (status.health_ok) { + setLocalHealthStatus("ok"); + setLocalHealthMessage("Gateway 运行中,本地 Health 正常。"); + setLocalHealthStatusCode(200); + } else { + setLocalHealthStatus("idle"); + setLocalHealthMessage("Gateway 已启动,尚未完成本地 Health 检测。"); + setLocalHealthStatusCode(null); + } + + const health = status.public_health; + if (!status.running) { + setPublicHealthStatus(health?.configured ? "not_ready" : "not_configured"); + setPublicHealthMessage( + health?.configured + ? "Gateway 已停止,之前的公网 Health 结果已失效。" + : "Public Base URL 未配置。" + ); + setPublicHealthStatusCode(null); + return; + } + if (!syncPublicHealth) { + return; + } + if (!health?.configured) { + setPublicHealthStatus("not_configured"); + setPublicHealthMessage("Public Base URL 未配置。"); + setPublicHealthStatusCode(null); + } else if (health.ok && health.status_code === 200) { + setPublicHealthStatus("ok"); + setPublicHealthMessage("公网 Health 正常。"); + setPublicHealthStatusCode(200); + } else if (health.error_kind === "not_checked") { + setPublicHealthStatus("idle"); + setPublicHealthMessage("公网 Health 尚未检测。"); + setPublicHealthStatusCode(null); + } else { + setPublicHealthStatus("error"); + setPublicHealthMessage(health.error ?? "公网 Health 异常。"); + setPublicHealthStatusCode(health.status_code ?? null); + } + }, []); + + const refreshGatewayStatus = useCallback(async (): Promise => { + const status = await invokeTauri("gateway_status"); + const draftBase = gatewayPublicDraftRef.current; + const statusBase = normalizePublicBaseUrl(status.public_webhook_base_url); + const syncPublicHealth = !draftBase || draftBase === statusBase; + setGatewayStatus((current) => syncPublicHealth + ? status + : { + ...status, + public_webhook_base_url: draftBase, + public_health: current.public_health, + }); + syncHealthFromGatewayStatus(status, syncPublicHealth); + return status; + }, [syncHealthFromGatewayStatus]); + + const refreshDingtalkDiagnostics = useCallback(async () => { + const diagnostics = await invokeTauri("dingtalk_diagnostics"); + setDingtalkDiagnostics(diagnostics); + return diagnostics; + }, []); + + const refreshFeishuDiagnostics = useCallback(async () => { + const diagnostics = await invokeTauri("feishu_diagnostics"); + setFeishuDiagnostics(diagnostics); + return diagnostics; + }, []); + const refreshCliInstall = useCallback(async () => { try { const s = await invokeTauri("cli_install_status"); @@ -397,10 +560,7 @@ export function Setup({ let disposed = false; const refresh = async () => { try { - const status = await invokeTauri("gateway_status"); - if (!disposed) { - setGatewayStatus(status); - } + if (!disposed) await refreshGatewayStatus(); } catch { // Keep the most recent snapshot. Explicit actions surface errors. } @@ -411,7 +571,25 @@ export function Setup({ disposed = true; window.clearInterval(interval); }; - }, [activeTab]); + }, [activeTab, refreshGatewayStatus]); + + useEffect(() => { + if (activeTab !== "channels" || activeChannelId !== "feishu") { + return; + } + void refreshFeishuDiagnostics().catch(() => { + // Explicit button actions surface Tauri/browser errors to the user. + }); + }, [activeTab, activeChannelId, refreshFeishuDiagnostics]); + + useEffect(() => { + if (activeTab !== "channels" || activeChannelId !== "dingtalk") { + return; + } + void refreshDingtalkDiagnostics().catch(() => { + // Explicit button actions surface Tauri/browser errors to the user. + }); + }, [activeTab, activeChannelId, refreshDingtalkDiagnostics]); const loadSetupState = async () => { setBusyAction("load"); @@ -445,6 +623,26 @@ export function Setup({ default_model, }); setGatewayStatus(nextGatewayStatus); + syncHealthFromGatewayStatus(nextGatewayStatus); + dirtyChannelIdsRef.current.clear(); + setActiveChannelIdState((currentChannelId) => { + if (channelIdIsKnown(currentChannelId)) { + return currentChannelId; + } + const enabledChannels = nextGatewayStatus.enabled_channels.filter(channelIdIsKnown); + const remembered = readRememberedChannelId(); + const rememberedEnabled = remembered && enabledChannels.includes(remembered) + ? remembered + : null; + const firstEnabled = enabledChannels[0] + ?? Object.entries(merged.channels).find(([, entry]) => entry?.enabled)?.[0]; + const nextChannelId = rememberedEnabled + ?? (channelIdIsKnown(firstEnabled) ? firstEnabled : null) + ?? remembered + ?? "feishu"; + rememberChannelId(nextChannelId); + return nextChannelId; + }); setActionMessage("已加载当前配置。"); } catch (error) { setActionMessage( @@ -459,14 +657,18 @@ export function Setup({ validateAllChannels: boolean, configToSave = config, channelId = activeChannelId, + changedChannelIds = [...dirtyChannelIdsRef.current], ): Promise => { const result = await invokeTauri<{ gateway_restarted: boolean }>("save_setup_config", { config: configToSave, validateAllChannels, activeChannelId: channelId, + changedChannelIds, }); - const nextGatewayStatus = await invokeTauri("gateway_status"); - setGatewayStatus(nextGatewayStatus); + for (const changedChannelId of changedChannelIds) { + dirtyChannelIdsRef.current.delete(changedChannelId); + } + await refreshGatewayStatus(); return result?.gateway_restarted ?? false; }; @@ -503,15 +705,15 @@ export function Setup({ try { const restarted = await saveSetupConfig(true); notifySetupConfigUpdated(); - const nextGatewayStatus = await invokeTauri("start_gateway"); - setGatewayStatus(nextGatewayStatus); + await invokeTauri("start_gateway"); + const nextGatewayStatus = await refreshGatewayStatus(); if (nextGatewayStatus.running) { - const enabledChannels = enabledChannelIds(config); + const enabledChannels = formatEnabledChannels(nextGatewayStatus.enabled_channels); const msg = restarted ? `Workspace 已切换,网关已重启:${nextGatewayStatus.url}` : `网关已启动:${nextGatewayStatus.url}`; - setActionMessage(`${msg}。已启用频道:${enabledChannels.join(", ") || "无"}`); - if (onConfigSuccess) { + setActionMessage(`${msg}。已启用频道:${enabledChannels || "无"}`); + if (onConfigSuccess && !embedded) { onConfigSuccess(); } } else { @@ -525,8 +727,7 @@ export function Setup({ setActionMessage(formatGatewayStartError(errorMsg)); // Refresh status try { - const nextGatewayStatus = await invokeTauri("gateway_status"); - setGatewayStatus(nextGatewayStatus); + await refreshGatewayStatus(); } catch { // Ignore status refresh errors } @@ -552,7 +753,7 @@ export function Setup({ setBusyAction("save"); try { - await saveSetupConfig(false, nextConfig, "lark"); + await saveSetupConfig(false, nextConfig, "lark", ["lark"]); notifySetupConfigUpdated(); setConfig(nextConfig); setChannelValidationError(undefined); @@ -606,8 +807,8 @@ export function Setup({ setBusyAction("stop"); setActionMessage(""); // Clear previous errors try { - const nextGatewayStatus = await invokeTauri("stop_gateway"); - setGatewayStatus(nextGatewayStatus); + await invokeTauri("stop_gateway"); + const nextGatewayStatus = await refreshGatewayStatus(); if (!nextGatewayStatus.running) { setActionMessage("网关已停止。"); } else { @@ -620,8 +821,7 @@ export function Setup({ setActionMessage(errorMsg); // Refresh status try { - const nextGatewayStatus = await invokeTauri("gateway_status"); - setGatewayStatus(nextGatewayStatus); + await refreshGatewayStatus(); } catch { // Ignore status refresh errors } @@ -636,8 +836,8 @@ export function Setup({ try { await saveSetupConfig(true); notifySetupConfigUpdated(); - const nextGatewayStatus = await invokeTauri("restart_gateway"); - setGatewayStatus(nextGatewayStatus); + await invokeTauri("restart_gateway"); + const nextGatewayStatus = await refreshGatewayStatus(); setActionMessage( nextGatewayStatus.running ? `Gateway 已重启:${nextGatewayStatus.url}` @@ -648,7 +848,7 @@ export function Setup({ formatGatewayStartError(error instanceof Error ? error.message : String(error)) ); try { - setGatewayStatus(await invokeTauri("gateway_status")); + await refreshGatewayStatus(); } catch { // Keep the last known status. } @@ -658,42 +858,152 @@ export function Setup({ }; const handleTestGatewayHealth = async () => { - setBusyAction("health"); + setLocalHealthLoading(true); try { - const result = await invokeTauri<{ - ok: boolean; - status_code?: number | null; - message: string; - }>("test_gateway_health"); + const result = await invokeTauri("test_gateway_health"); setActionMessage(result.message); - setGatewayStatus(await invokeTauri("gateway_status")); + const status = await refreshGatewayStatus(); + if (!status.running) { + setLocalHealthStatus("not_ready"); + } else { + setLocalHealthStatus(result.ok ? "ok" : "error"); + } + setLocalHealthMessage(result.message); + setLocalHealthStatusCode(result.status_code ?? null); } catch (error) { - setActionMessage( - `Gateway 健康检查失败:${error instanceof Error ? error.message : String(error)}` - ); + const message = error instanceof Error ? error.message : String(error); + setLocalHealthStatus("error"); + setLocalHealthMessage(message); + setLocalHealthStatusCode(null); + setActionMessage(`Gateway 健康检查失败:${message}`); } finally { - setBusyAction(null); + setLocalHealthLoading(false); } }; const handleTestGatewayPublicHealth = async () => { - setBusyAction("public-health"); + setPublicHealthLoading(true); try { + const latestStatus = await refreshGatewayStatus(); + const currentInputBase = resolveDraftPublicBase(config.gateway_public); + const baseUrl = currentInputBase + ?? normalizePublicBaseUrl(latestStatus.public_webhook_base_url); + if (!baseUrl) { + setPublicHealthStatus("not_configured"); + setPublicHealthMessage("Public Base URL 未配置。"); + setPublicHealthStatusCode(null); + setActionMessage("Public Base URL 未配置。"); + return; + } const result = await invokeTauri( - "test_gateway_public_health" + "test_gateway_public_health", + { baseUrl } + ); + setPublicHealthStatus(result.ok && result.status_code === 200 ? "ok" : "error"); + setPublicHealthMessage( + result.ok ? "公网 Health 正常。" : result.error ?? "公网 Health 异常。" ); + setPublicHealthStatusCode(result.status_code ?? null); + setGatewayStatus((current) => ({ + ...current, + public_webhook_base_url: result.base_url ?? baseUrl, + public_health: result, + })); setActionMessage( result.ok - ? `公网 Health 检查通过:${result.checked_url ?? result.base_url ?? ""}` + ? `公网 Health 检查通过(HTTP ${result.status_code ?? 200})。` : `公网 Health 检查失败:${result.error ?? "未知错误"}` ); - setGatewayStatus(await invokeTauri("gateway_status")); } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setPublicHealthStatus("error"); + setPublicHealthMessage(message); + setPublicHealthStatusCode(null); + setActionMessage(`公网 Health 检查失败:${message}`); + } finally { + setPublicHealthLoading(false); + } + }; + + const handleRunDingtalkDiagnostics = async () => { + setDingtalkDiagnosticsLoading(true); + try { + await refreshGatewayStatus(); + const diagnostics = await refreshDingtalkDiagnostics(); setActionMessage( - `公网 Health 检查失败:${error instanceof Error ? error.message : String(error)}` + diagnostics.next_steps.length === 0 + ? "钉钉本地诊断通过,建议继续测试公网路由。" + : `钉钉诊断完成:发现 ${diagnostics.next_steps.length} 项待处理。` ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setActionMessage(`钉钉诊断失败:${message}`); } finally { - setBusyAction(null); + setDingtalkDiagnosticsLoading(false); + } + }; + + const handleRunFeishuDiagnostics = async () => { + setFeishuDiagnosticsLoading(true); + try { + await refreshGatewayStatus(); + const diagnostics = await refreshFeishuDiagnostics(); + setActionMessage( + diagnostics.next_steps.length === 0 + ? "飞书诊断通过;公网连通性请使用 Public Health 检测。" + : `飞书诊断完成:发现 ${diagnostics.next_steps.length} 项待处理。` + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setActionMessage(`飞书诊断失败:${message}`); + } finally { + setFeishuDiagnosticsLoading(false); + } + }; + + const handleTestFeishuPublicHealth = async () => { + await handleTestGatewayPublicHealth(); + await refreshFeishuDiagnostics().catch(() => { + // The shared Public Health result remains visible even if refresh fails. + }); + }; + + const handleTestDingtalkPublicRoute = async () => { + const baseUrl = resolveDraftPublicBase(config.gateway_public) + ?? normalizePublicBaseUrl(gatewayStatus.public_webhook_base_url); + if (!baseUrl) { + const result: DingtalkPublicRouteProbe = { + configured: false, + reachable: false, + status_code: null, + result_kind: "not_configured", + message: "Public Base URL 未配置。", + }; + setDingtalkRouteProbe(result); + setActionMessage(result.message); + return; + } + + setDingtalkRouteLoading(true); + try { + const result = await invokeTauri( + "test_dingtalk_public_route", + { baseUrl } + ); + setDingtalkRouteProbe(result); + setActionMessage(result.message); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setDingtalkRouteProbe({ + configured: true, + reachable: false, + status_code: null, + result_kind: "network_error", + message, + }); + setActionMessage(`钉钉公网路由检测失败:${message}`); + } finally { + setDingtalkRouteLoading(false); } }; @@ -740,10 +1050,13 @@ export function Setup({ : null; const draftPublicBase = namedTunnelMode ? draftNamedTunnelBase - : normalizePublicBaseUrl(config.gateway_public?.public_webhook_base_url); - const callbackBase = namedTunnelMode - ? draftNamedTunnelBase - : draftPublicBase ?? gatewayStatus.url?.replace(/\/$/, "") ?? null; + : normalizePublicBaseUrl(config.gateway_public?.public_webhook_base_url) + ?? normalizePublicBaseUrl(gatewayStatus.public_webhook_base_url); + const callbackBase = draftPublicBase; + const rawPublicBaseInput = config.gateway_public?.public_webhook_base_url?.trim() ?? ""; + const publicBaseContainsDingtalkPath = + /\/api\/v1\/gateway\/dingtalk\/events\/?(?:[?#].*)?$/i.test(rawPublicBaseInput) + || /\/webhook\/dingtalk\/?(?:[?#].*)?$/i.test(rawPublicBaseInput); const namedTunnelNameConfigured = Boolean(config.gateway_public?.named_tunnel_name?.trim()); const namedTunnelConfigComplete = @@ -752,12 +1065,29 @@ export function Setup({ const runtimeCardCallbackUrl = callbackBase ? `${callbackBase}/webhook/feishu/card` : null; + const runtimeDingtalkCallbackUrl = callbackBase + ? `${callbackBase}/api/v1/gateway/dingtalk/events` + : null; const lastStartedLabel = gatewayStatus.last_started_at ? new Date(gatewayStatus.last_started_at * 1000).toLocaleString() : "尚未记录"; const publicHealthCheckedLabel = gatewayStatus.public_health?.checked_at ? new Date(gatewayStatus.public_health.checked_at * 1000).toLocaleString() : "尚未检测"; + const enabledChannelLabel = formatEnabledChannels(gatewayStatus.enabled_channels); + + useEffect(() => { + const statusBase = normalizePublicBaseUrl(gatewayStatus.public_webhook_base_url); + if (!draftPublicBase) { + setPublicHealthStatus("not_configured"); + setPublicHealthMessage("Public Base URL 未配置。"); + setPublicHealthStatusCode(null); + } else if (draftPublicBase !== statusBase) { + setPublicHealthStatus("idle"); + setPublicHealthMessage("Public Base URL 已修改,等待重新检测。"); + setPublicHealthStatusCode(null); + } + }, [draftPublicBase, gatewayStatus.public_webhook_base_url]); const renderTabContent = () => { switch (activeTab) { @@ -1163,6 +1493,11 @@ export function Setup({ ? "由 Named Tunnel Hostname 自动生成并保存。" : "只填写域名 Base;保存时会自动移除 webhook 路径后缀。"} + {publicBaseContainsDingtalkPath ? ( + + 请只填写公网根地址;保存时会移除钉钉回调路径。 + + ) : null} {config.gateway_public?.mode === "named_cloudflare_tunnel" ? ( <> @@ -1255,15 +1590,36 @@ export function Setup({ Retry worker {gatewayStatus.retry_worker_enabled ? "已启动" : "未启动"} -
    本地 Health{gatewayStatus.health_ok ? "正常" : "未就绪"}
    - 公网 Health + 本地 Health - {!gatewayStatus.public_health?.configured - ? "未配置" - : gatewayStatus.public_health.ok + {localHealthLoading + ? "检测中…" + : localHealthStatus === "ok" ? "正常" - : "异常"} + : localHealthStatus === "error" + ? "异常" + : gatewayStatus.running + ? "未检测" + : "未运行"} + {localHealthStatusCode ? ` · HTTP ${localHealthStatusCode}` : ""} + +
    +
    + 公网 Health + + {publicHealthLoading + ? "检测中…" + : publicHealthStatus === "not_configured" + ? "未配置" + : publicHealthStatus === "ok" + ? "正常" + : publicHealthStatus === "not_ready" + ? "未运行" + : publicHealthStatus === "error" + ? "异常" + : "未检测"} + {publicHealthStatusCode ? ` · HTTP ${publicHealthStatusCode}` : ""}
    公网检测时间{publicHealthCheckedLabel}
    @@ -1296,7 +1652,7 @@ export function Setup({
    - 已启用频道:{gatewayStatus.enabled_channels?.join("、") || "无"} + 已启用频道:{enabledChannelLabel || "无"} {gatewayStatus.store_path ? ` · Store:${gatewayStatus.store_path}` : ""} {` · cloudflared path:${gatewayStatus.cloudflared_configured ? "已配置" : "未配置"}`} {` · cloudflared found:${gatewayStatus.cloudflared_found ? "true" : "false"}`} @@ -1306,16 +1662,18 @@ export function Setup({ type="button" className="setup-btn setup-btn--secondary" onClick={handleTestGatewayPublicHealth} - disabled={busyAction !== null} + disabled={publicHealthLoading || busyAction !== null} > - {busyAction === "public-health" ? "检测中…" : "测试公网 Health"} + {publicHealthLoading ? "检测中…" : "测试公网 Health"} - {gatewayStatus.public_health?.error && - !["not_checked", "url_not_configured"].includes( - gatewayStatus.public_health.error_kind ?? "" - ) ? ( + {publicHealthMessage ? ( + + 公网检测:{publicHealthMessage} + + ) : null} + {localHealthMessage ? ( - 公网检测:{gatewayStatus.public_health.error} + 本地检测:{localHealthMessage} ) : null}
    @@ -1349,23 +1707,266 @@ export function Setup({ setConfig({ ...config, channels })} + onChange={(channels) => { + if (activeChannelId) { + dirtyChannelIdsRef.current.add(activeChannelId); + } + setConfig({ ...config, channels }); + }} validationError={channelValidationError} onValidationChange={setChannelValidationError} selectedChannelId={activeChannelId} onSelectedChannelChange={setActiveChannelId} + enabledChannelIds={gatewayStatus.enabled_channels} + publicBaseUrl={draftPublicBase ?? undefined} gatewayUrl={gatewayStatus.running ? gatewayStatus.url : undefined} onHealthCheck={async () => { - const result = await invokeTauri<{ - ok: boolean; - message: string; - }>("test_gateway_health"); - return result; + setLocalHealthLoading(true); + try { + const result = await invokeTauri("test_gateway_health"); + setLocalHealthStatus(result.ok ? "ok" : "error"); + setLocalHealthMessage(result.message); + setLocalHealthStatusCode(result.status_code ?? null); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setLocalHealthStatus("error"); + setLocalHealthMessage(message); + setLocalHealthStatusCode(null); + throw error; + } finally { + setLocalHealthLoading(false); + } }} onCopyWebhookUrl={(url) => { void navigator.clipboard.writeText(url); }} /> + {activeChannelId === "feishu" ? ( +
    +
    +
    +

    飞书诊断

    +
    + 只读检查配置、Gateway、Store、Retry worker 与 Public Health,不模拟飞书签名请求。 +
    +
    + +
    + +
    +
    + 普通事件回调 + {runtimeWebhookUrl || "Public Base URL 未配置"} + +
    +
    + 卡片交互回调 + {runtimeCardCallbackUrl || "Public Base URL 未配置"} + +
    +
    + + {feishuDiagnostics ? ( +
    +
    Feishu{feishuDiagnostics.feishu_enabled ? "enabled" : "disabled"}
    +
    App ID{feishuDiagnostics.app_id_present ? "present" : "missing"}
    +
    App Secret{feishuDiagnostics.app_secret_present ? "present" : "missing"}
    +
    Verification Token{feishuDiagnostics.verification_token_present ? "present" : "missing"}
    +
    Encrypt Key{feishuDiagnostics.encrypt_key_present ? "present" : "missing"}
    +
    Security{feishuDiagnostics.security_mode}
    +
    Outbound{feishuDiagnostics.outbound_mode}
    +
    Gateway{feishuDiagnostics.local_gateway_running ? "running" : "stopped"}
    +
    Local Health{feishuDiagnostics.local_health}
    +
    Public Base URL{feishuDiagnostics.public_base_url_present ? "present" : "missing"}
    +
    + Public Health + + {feishuDiagnostics.public_health} + {feishuDiagnostics.public_health_status_code + ? ` · HTTP ${feishuDiagnostics.public_health_status_code}` + : ""} + +
    +
    Store{feishuDiagnostics.store_opened ? "opened" : "not_opened"}
    +
    Retry worker{feishuDiagnostics.retry_worker_started ? "started" : "not_started"}
    +
    + ) : ( +

    正在读取飞书诊断状态…

    + )} + +
    + + + {publicHealthMessage} + {publicHealthStatusCode ? `(HTTP ${publicHealthStatusCode})` : ""} + +
    + + {(config.gateway_public?.mode === "quick_tunnel" + || feishuDiagnostics?.quick_tunnel) ? ( +
    + Quick Tunnel 地址可能变化;重启 cloudflared 后,需要更新飞书后台两个回调地址。 +
    + ) : null} + {feishuDiagnostics?.next_steps.length ? ( +
    + 建议下一步 +
      + {feishuDiagnostics.next_steps.map((step) => ( +
    • {step}
    • + ))} +
    +
    + ) : null} +
    + ) : null} + {activeChannelId === "dingtalk" ? ( +
    +
    +
    +

    钉钉诊断

    +
    + 只读检查本地配置、Gateway、worker 与公网路由,不调用钉钉平台 API。 +
    +
    + +
    + +
    +
    + 钉钉消息接收地址 + {runtimeDingtalkCallbackUrl || "Public Base URL 未配置"} + +
    +
    + + {dingtalkDiagnostics ? ( +
    +
    + DingTalk + {dingtalkDiagnostics.dingtalk_enabled ? "enabled" : "disabled"} +
    +
    App Key{dingtalkDiagnostics.app_key_present ? "present" : "missing"}
    +
    App Secret{dingtalkDiagnostics.app_secret_present ? "present" : "missing"}
    +
    RobotCode{dingtalkDiagnostics.robot_code_present ? "present" : "missing"}
    +
    Gateway{dingtalkDiagnostics.local_gateway_running ? "running" : "stopped"}
    +
    Local Health{dingtalkDiagnostics.local_health}
    +
    Public Base URL{dingtalkDiagnostics.public_base_url_present ? "present" : "missing"}
    +
    + Public Health + + {dingtalkDiagnostics.public_health} + {dingtalkDiagnostics.public_health_status_code + ? ` · HTTP ${dingtalkDiagnostics.public_health_status_code}` + : ""} + +
    +
    Worker{dingtalkDiagnostics.worker_started ? "started" : "not_started"}
    +
    Outbound{dingtalkDiagnostics.outbound_mode || "missing"}
    +
    Webhook Path{dingtalkDiagnostics.webhook_path}
    +
    + ) : ( +

    正在读取钉钉诊断状态…

    + )} + +
    + + {dingtalkRouteProbe ? ( + + {dingtalkRouteProbe.message} + {dingtalkRouteProbe.status_code + ? `(HTTP ${dingtalkRouteProbe.status_code})` + : ""} + + ) : null} +
    + + {(config.gateway_public?.mode === "quick_tunnel" + || dingtalkDiagnostics?.quick_tunnel) ? ( +
    + Quick Tunnel 地址可能变化;重启 cloudflared 后,需要更新钉钉后台回调地址并重新发布应用。 +
    + ) : null} + {dingtalkDiagnostics?.public_health === "failed" ? ( +
    + 本地 Gateway 正常但公网检测失败时,请优先检查 cloudflared 是否运行以及公网地址是否过期。 +
    + ) : null} + {dingtalkDiagnostics?.next_steps.length ? ( +
    + 建议下一步 +
      + {dingtalkDiagnostics.next_steps.map((step) => ( +
    • {step}
    • + ))} +
    +
    + ) : null} +
    + ) : null} ); case "skills": @@ -1448,9 +2049,9 @@ export function Setup({ type="button" className="setup-btn setup-btn--secondary" onClick={handleTestGatewayHealth} - disabled={busyAction !== null || !gatewayStatus.running} + disabled={busyAction !== null || localHealthLoading} > - {busyAction === "health" ? "检测中…" : "测试本地 Health"} + {localHealthLoading ? "检测中…" : "测试本地 Health"} {actionMessage ?

    {actionMessage}

    : null} @@ -1597,9 +2198,9 @@ export function Setup({ type="button" className="setup-btn setup-btn--secondary setup-btn--block" onClick={handleTestGatewayHealth} - disabled={busyAction !== null || !gatewayStatus.running} + disabled={busyAction !== null || localHealthLoading} > - {busyAction === "health" ? "检测中…" : "测试本地 Health"} + {localHealthLoading ? "检测中…" : "测试本地 Health"} {actionMessage ? (

    {actionMessage}

    diff --git a/apps/omninova-tauri/src/types/config.ts b/apps/omninova-tauri/src/types/config.ts index 167838c5..c04c0a47 100644 --- a/apps/omninova-tauri/src/types/config.ts +++ b/apps/omninova-tauri/src/types/config.ts @@ -193,6 +193,8 @@ export const CHANNEL_PRESETS: ChannelPreset[] = [ category: "im", tokenEnvHint: "DINGTALK_TOKEN", fields: [ + { key: "transport_mode", label: "连接模式", placeholder: "http/stream", type: "text", isExtra: true }, + { key: "card_template_id", label: "Card Template ID", placeholder: "DingTalk advanced card template ID (optional)", type: "text", isExtra: true }, { key: "app_id", label: "App Key", placeholder: "钉钉应用 AppKey", type: "text", isExtra: true }, { key: "app_secret", label: "App Secret", placeholder: "钉钉应用 AppSecret", type: "password", isExtra: true }, { key: "token", label: "签名密钥", placeholder: "自定义机器人签名密钥", type: "password" }, @@ -377,6 +379,67 @@ export interface GatewayStatus { error_code?: string | null; } +export interface DingtalkDiagnostics { + dingtalk_enabled: boolean; + app_key_present: boolean; + app_secret_present: boolean; + robot_code_present: boolean; + webhook_path: string; + local_gateway_running: boolean; + local_health: "ok" | "failed" | "not_ready"; + public_base_url_present: boolean; + public_base_url?: string | null; + public_health: "ok" | "failed" | "not_checked" | "not_configured"; + public_health_status_code?: number | null; + public_health_error?: string | null; + final_dingtalk_callback_url?: string | null; + worker_started: boolean; + outbound_mode: string; + public_mode: GatewayPublicMode | string; + quick_tunnel: boolean; + next_steps: string[]; +} + +export interface FeishuDiagnostics { + feishu_enabled: boolean; + app_id_present: boolean; + app_secret_present: boolean; + verification_token_present: boolean; + encrypt_key_present: boolean; + security_mode: string; + outbound_mode: string; + store_opened: boolean; + retry_worker_started: boolean; + local_gateway_running: boolean; + local_health: "ok" | "failed" | "not_ready"; + public_base_url_present: boolean; + public_base_url?: string | null; + public_health: "ok" | "failed" | "not_checked" | "not_configured"; + public_health_status_code?: number | null; + public_health_error?: string | null; + final_feishu_event_callback_url?: string | null; + final_feishu_card_callback_url?: string | null; + quick_tunnel: boolean; + next_steps: string[]; +} + +export interface DingtalkPublicRouteProbe { + configured: boolean; + reachable: boolean; + status_code?: number | null; + result_kind: + | "ok" + | "missing_timestamp" + | "signature_mismatch" + | "tunnel_unreachable" + | "not_configured" + | "timeout" + | "network_error" + | "client_error" + | "http_error"; + message: string; +} + export type ChannelKindValue = | "cli" | "web" diff --git a/config.template.toml b/config.template.toml index e7393ecb..091754f8 100644 --- a/config.template.toml +++ b/config.template.toml @@ -207,6 +207,27 @@ # webhook_signature_strict_priority = false # webhook_signing_include_timestamp = false # webhook_signing_require_timestamp = false +# +# --- Gateway DingTalk (企业内部应用机器人) --- +# Default disabled. When enabled=false the /api/v1/gateway/dingtalk/events +# route is registered but every webhook is rejected and no outbound calls +# are made. Real secrets MUST be supplied via environment variables +# (OMNINOVA_DINGTALK_APP_KEY / OMNINOVA_DINGTALK_APP_SECRET) and never +# committed to this file. +# [gateway.dingtalk] +# enabled = false +# app_key = "" # AppKey (prefer env var) +# app_key_env = "OMNINOVA_DINGTALK_APP_KEY" # override env var name +# app_secret = "" # AppSecret (prefer env var, NEVER commit) +# app_secret_env = "OMNINOVA_DINGTALK_APP_SECRET" +# robot_code = "" # robotCode (prefer env var) +# robot_code_env = "OMNINOVA_DINGTALK_ROBOT_CODE" +# card_template_id = "" # advanced card template id (optional) +# card_template_id_env = "OMNINOVA_DINGTALK_CARD_TEMPLATE_ID" +# transport_mode = "http" # http | stream (interactive cards require stream) +# webhook_path = "/api/v1/gateway/dingtalk/events" # informational +# outbound_mode = "session_webhook" # disabled | session_webhook | mock +# redact_sensitive_logs = true # log redaction (always-on) # --- Proxy --- # [proxy] diff --git a/crates/omninova-core/Cargo.toml b/crates/omninova-core/Cargo.toml index e66280e7..63162ff6 100644 --- a/crates/omninova-core/Cargo.toml +++ b/crates/omninova-core/Cargo.toml @@ -49,6 +49,8 @@ rusqlite = { version = "0.31", features = ["bundled"] } url.workspace = true axum = { version = "0.8.8", features = ["ws"] } futures-util = "0.3" +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } hmac = "0.12.1" sha2 = "0.10.9" hex = "0.4.3" diff --git a/crates/omninova-core/src/config/env.rs b/crates/omninova-core/src/config/env.rs index f06aecb1..85d8b625 100644 --- a/crates/omninova-core/src/config/env.rs +++ b/crates/omninova-core/src/config/env.rs @@ -94,6 +94,33 @@ pub fn apply_env_overrides(cfg: &mut Config) { cfg.gateway.allow_public_bind = v == "true" || v == "1"; }); + // --- Gateway DingTalk --- + // AppKey / AppSecret / robotCode always come from the env var when it + // is set, regardless of whether `app_key` etc. are populated in the + // config file. This keeps real secrets out of committed config files. + env_opt("OMNINOVA_DINGTALK_APP_KEY", |v| { + cfg.gateway.dingtalk.app_key = v; + }); + env_opt("OMNINOVA_DINGTALK_APP_SECRET", |v| { + cfg.gateway.dingtalk.app_secret = v; + }); + env_opt("OMNINOVA_DINGTALK_ROBOT_CODE", |v| { + cfg.gateway.dingtalk.robot_code = v; + }); + env_opt("OMNINOVA_DINGTALK_WEBHOOK_PATH", |v| { + if !v.trim().is_empty() { + cfg.gateway.dingtalk.webhook_path = v; + } + }); + env_opt("OMNINOVA_DINGTALK_ENABLED", |v| { + cfg.gateway.dingtalk.enabled = v == "true" || v == "1"; + }); + env_opt("OMNINOVA_DINGTALK_OUTBOUND_MODE", |v| { + if !v.trim().is_empty() { + cfg.gateway.dingtalk.outbound_mode = v; + } + }); + // --- Runtime reasoning --- env_opt("OMNINOVA_REASONING_ENABLED", |v| { cfg.runtime.reasoning_enabled = v == "true" || v == "1"; diff --git a/crates/omninova-core/src/config/mod.rs b/crates/omninova-core/src/config/mod.rs index 522411b1..b0dbc07b 100644 --- a/crates/omninova-core/src/config/mod.rs +++ b/crates/omninova-core/src/config/mod.rs @@ -9,8 +9,8 @@ pub use schema::{ BrowserConfig, CameraConfig, ChannelEntry, ChannelsConfig, ComposioConfig, Config, CoordinationConfig, CostConfig, CronConfig, CronJobConfig, DelegateAgentConfig, DriveConfig, EmbeddingConfig, EmbeddingRouteConfig, Esp32Config, EstopConfig, GatewayConfig, - GatewayPublicConfig, GatewayPublicMode, GoalLoopConfig, HardwareConfig, HeartbeatConfig, - HooksConfig, HttpRequestConfig, + GatewayDingtalkConfig, GatewayPublicConfig, GatewayPublicMode, GoalLoopConfig, HardwareConfig, + HeartbeatConfig, HooksConfig, HttpRequestConfig, IdentityConfig, MemoryConfig, ModelProviderConfig, ModelRouteConfig, MultimodalConfig, ObservabilityConfig, OtpConfig, PeripheralsConfig, ProviderApiMode, ProviderBehaviorConfig, ProviderConfig, ProxyConfig, QueryClassificationConfig, ReliabilityConfig, ResearchPhaseConfig, diff --git a/crates/omninova-core/src/config/schema.rs b/crates/omninova-core/src/config/schema.rs index 79c548a2..714f8135 100644 --- a/crates/omninova-core/src/config/schema.rs +++ b/crates/omninova-core/src/config/schema.rs @@ -1016,6 +1016,97 @@ pub struct GatewayConfig { pub webhook_signing_include_timestamp: bool, #[serde(default)] pub webhook_signing_require_timestamp: bool, + /// DingTalk (企业内部应用机器人) gateway config. Defaults to disabled. + /// When disabled, the `/api/v1/gateway/dingtalk/events` route is registered + /// but rejects all requests; no outbound DingTalk calls are made. + #[serde(default)] + pub dingtalk: GatewayDingtalkConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayDingtalkConfig { + /// Master switch. When `false` (the default), DingTalk routes are + /// registered but every webhook is rejected and no outbound calls happen. + #[serde(default)] + pub enabled: bool, + /// DingTalk AppKey. Prefer `app_key_env` (or `OMNINOVA_DINGTALK_APP_KEY` + /// environment variable) for real secrets; the inline value should stay + /// empty in committed config files. + #[serde(default)] + pub app_key: String, + /// Optional environment variable name holding the AppKey. Resolved after + /// the inline `app_key` field. + #[serde(default)] + pub app_key_env: Option, + /// DingTalk AppSecret. Strongly prefer the env var path; never commit + /// a real secret here. + #[serde(default)] + pub app_secret: String, + /// Optional environment variable name holding the AppSecret. + #[serde(default)] + pub app_secret_env: Option, + /// DingTalk robotCode (unique identifier for the enterprise app bot). + #[serde(default)] + pub robot_code: String, + /// Optional environment variable name holding the robotCode. + #[serde(default)] + pub robot_code_env: Option, + /// DingTalk advanced-card template id used by createAndDeliver. An empty + /// value disables only the interactive menu card, not the Gateway. + #[serde(default)] + pub card_template_id: String, + /// Optional environment variable holding the advanced-card template id. + #[serde(default)] + pub card_template_id_env: Option, + /// DingTalk connection mode. HTTP keeps the stable webhook + text bot + /// flow; Stream additionally enables OmniNova advanced interactive cards. + #[serde(default)] + pub transport_mode: DingtalkTransportMode, + /// HTTP path that DingTalk servers POST events to. Exposed for visibility + /// only; the actual route is registered as `/webhook/dingtalk` and the + /// public callback URL is `/api/v1/gateway/dingtalk/events`. + #[serde(default = "default_gateway_dingtalk_webhook_path")] + pub webhook_path: String, + /// Outbound delivery mode: + /// - `disabled` (default): never send replies back to DingTalk. + /// - `session_webhook`: reply via the sessionWebhook URL supplied in the + /// inbound payload (the standard app-bot flow). + /// - `mock`: log only, no network call. + #[serde(default = "default_gateway_dingtalk_outbound_mode")] + pub outbound_mode: String, + /// When `true` (the default), DingTalk log lines redact sensitive + /// fields (app_secret, access_token, sessionWebhook, senderStaffId, + /// conversationId, messageId). + #[serde(default = "default_true")] + pub redact_sensitive_logs: bool, +} + +impl Default for GatewayDingtalkConfig { + fn default() -> Self { + Self { + enabled: false, + app_key: String::new(), + app_key_env: None, + app_secret: String::new(), + app_secret_env: None, + robot_code: String::new(), + robot_code_env: None, + card_template_id: String::new(), + card_template_id_env: None, + transport_mode: DingtalkTransportMode::default(), + webhook_path: default_gateway_dingtalk_webhook_path(), + outbound_mode: default_gateway_dingtalk_outbound_mode(), + redact_sensitive_logs: true, + } + } +} + +fn default_gateway_dingtalk_webhook_path() -> String { + "/api/v1/gateway/dingtalk/events".into() +} + +fn default_gateway_dingtalk_outbound_mode() -> String { + "session_webhook".into() } fn default_gateway_host() -> String { @@ -1063,6 +1154,7 @@ impl Default for GatewayConfig { webhook_signature_strict_priority: false, webhook_signing_include_timestamp: false, webhook_signing_require_timestamp: false, + dingtalk: GatewayDingtalkConfig::default(), } } } @@ -2391,4 +2483,72 @@ workspace_dir = "C:\\agent-workspace" assert!(!serialized.to_ascii_lowercase().contains("secret")); assert!(!serialized.to_ascii_lowercase().contains("token")); } + + #[test] + fn legacy_dingtalk_config_defaults_card_template_to_disabled() { + let cfg: GatewayDingtalkConfig = toml::from_str( + r#" +enabled = true +outbound_mode = "session_webhook" +"#, + ) + .unwrap(); + assert!(cfg.card_template_id.is_empty()); + assert!(cfg.card_template_id_env.is_none()); + assert_eq!(cfg.transport_mode, DingtalkTransportMode::Http); + } + + #[test] + fn dingtalk_card_template_round_trips() { + let mut cfg = GatewayDingtalkConfig::default(); + cfg.card_template_id = "template-id".to_string(); + cfg.card_template_id_env = Some("OMNINOVA_DINGTALK_CARD_TEMPLATE_ID".to_string()); + cfg.transport_mode = DingtalkTransportMode::Stream; + let serialized = toml::to_string_pretty(&cfg).unwrap(); + let restored: GatewayDingtalkConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(restored.card_template_id, "template-id"); + assert_eq!( + restored.card_template_id_env.as_deref(), + Some("OMNINOVA_DINGTALK_CARD_TEMPLATE_ID") + ); + assert_eq!(restored.transport_mode, DingtalkTransportMode::Stream); + } + + #[test] + fn dingtalk_transport_mode_preserves_template_when_switching_to_http() { + let mut cfg = GatewayDingtalkConfig::default(); + cfg.card_template_id = "template-id".to_string(); + cfg.transport_mode = DingtalkTransportMode::Stream; + cfg.transport_mode = DingtalkTransportMode::Http; + + let restored: GatewayDingtalkConfig = + toml::from_str(&toml::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!(restored.transport_mode, DingtalkTransportMode::Http); + assert_eq!(restored.card_template_id, "template-id"); + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum DingtalkTransportMode { + #[default] + Http, + Stream, +} + +impl DingtalkTransportMode { + pub fn from_config_value(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "http" => Some(Self::Http), + "stream" => Some(Self::Stream), + _ => None, + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Stream => "stream", + } + } } diff --git a/crates/omninova-core/src/gateway/agent_menu.rs b/crates/omninova-core/src/gateway/agent_menu.rs new file mode 100644 index 00000000..404bf759 --- /dev/null +++ b/crates/omninova-core/src/gateway/agent_menu.rs @@ -0,0 +1,407 @@ +//! Shared data model for the OmniNova Agent menu. +//! +//! Channel adapters render this pure specification in their native format. +//! Feishu renders interactive buttons. DingTalk advanced cards use the same +//! specification, while the structured-text and legacy ActionCard renderers +//! remain available as compatibility fallbacks. + +pub const DINGTALK_MENU_CARD_CALLBACK_PATH: &str = "/api/v1/gateway/dingtalk/card/callback"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentMenuActionStyle { + Primary, + Default, +} + +impl AgentMenuActionStyle { + pub fn as_feishu_button_type(self) -> &'static str { + match self { + Self::Primary => "primary", + Self::Default => "default", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentMenuAction { + pub label: &'static str, + pub action: &'static str, + pub style: AgentMenuActionStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentMenuPanel { + pub title: &'static str, + pub introduction: &'static str, + pub ordinary_chat_label: &'static str, + pub primary_actions: &'static [AgentMenuAction], + pub secondary_actions: &'static [AgentMenuAction], + pub safety_note: &'static str, +} + +const PRIMARY_ACTIONS: &[AgentMenuAction] = &[ + AgentMenuAction { + label: "桌面监控 30 秒", + action: "monitor_30s", + style: AgentMenuActionStyle::Primary, + }, + AgentMenuAction { + label: "桌面监控 60 秒", + action: "monitor_60s", + style: AgentMenuActionStyle::Primary, + }, +]; + +const SECONDARY_ACTIONS: &[AgentMenuAction] = &[ + AgentMenuAction { + label: "Gateway 状态", + action: "gateway_status", + style: AgentMenuActionStyle::Default, + }, + AgentMenuAction { + label: "最近任务", + action: "recent_jobs", + style: AgentMenuActionStyle::Default, + }, + AgentMenuAction { + label: "帮助说明", + action: "help", + style: AgentMenuActionStyle::Default, + }, +]; + +/// Return the canonical cross-channel Agent menu definition. +pub fn build_agent_menu_panel() -> AgentMenuPanel { + AgentMenuPanel { + title: "OmniNova Agent 功能菜单", + introduction: + "请选择要执行的操作。普通聊天可以直接发送文字;工具任务请使用按钮或 slash 命令。", + ordinary_chat_label: "🟢 普通聊天说明", + primary_actions: PRIMARY_ACTIONS, + secondary_actions: SECONDARY_ACTIONS, + safety_note: "高风险工具不在普通聊天中直接执行。", + } +} + +/// Render the shared menu through DingTalk's existing plain-text outbound. +/// +/// DingTalk does not currently expose a stable action callback in this flow, +/// so the Feishu actions are intentionally presented as menu entries rather +/// than pretending to be clickable buttons. +pub fn render_agent_menu_as_dingtalk_text() -> String { + let panel = build_agent_menu_panel(); + let mut lines = vec![ + panel.title.to_string(), + String::new(), + panel + .ordinary_chat_label + .trim_start_matches("🟢 ") + .to_string(), + "直接发送文字即可与 Agent 对话。".to_string(), + String::new(), + "功能项(当前 DingTalk 以结构化文本展示,不是可点击按钮):".to_string(), + ]; + + for action in panel.primary_actions.iter().chain(panel.secondary_actions) { + lines.push(format!("- {}", action.label)); + } + + lines.extend([ + String::new(), + "菜单命令:menu / /menu / 菜单 / panel / /panel / 面板 / help / 帮助".to_string(), + "状态命令:status / /status / 状态".to_string(), + String::new(), + panel.safety_note.to_string(), + ]); + + lines.join("\n") +} + +/// Build the DingTalk `sampleActionCard5` message fields from the shared menu. +/// +/// The returned value deliberately contains only `msgKey` and the JSON-string +/// `msgParam`. The worker adds `robotCode` and `openConversationId` immediately +/// before sending, so platform identifiers never enter the reusable renderer. +pub fn build_dingtalk_agent_menu_card_payload(callback_url: &str) -> serde_json::Value { + let panel = build_agent_menu_panel(); + let actions = panel + .primary_actions + .iter() + .chain(panel.secondary_actions) + .collect::>(); + debug_assert_eq!(actions.len(), 5); + + let mut params = serde_json::json!({ + "title": panel.title, + "text": format!( + "{}\n\n{}\n\n{}", + panel.introduction, panel.ordinary_chat_label, panel.safety_note + ), + }); + + for (index, action) in actions.iter().enumerate() { + let field_number = index + 1; + params[format!("actionTitle{field_number}")] = serde_json::json!(action.label); + params[format!("actionURL{field_number}")] = + serde_json::json!(dingtalk_action_callback_url(callback_url, action.action)); + } + + serde_json::json!({ + "msgKey": "sampleActionCard5", + "msgParam": params.to_string(), + }) +} + +/// Compatibility alias matching the renderer naming used by the channel +/// adapters. Keeping both names makes the payload contract explicit in tests. +pub fn render_agent_menu_as_dingtalk_card(callback_url: &str) -> serde_json::Value { + build_dingtalk_agent_menu_card_payload(callback_url) +} + +/// Render the shared menu as the standard ActionCard accepted by the +/// sessionWebhook supplied with an inbound DingTalk app-bot message. +pub fn render_agent_menu_as_dingtalk_session_action_card(callback_url: &str) -> serde_json::Value { + let panel = build_agent_menu_panel(); + let buttons = panel + .primary_actions + .iter() + .chain(panel.secondary_actions) + .map(|action| { + serde_json::json!({ + "title": action.label, + "actionURL": dingtalk_action_callback_url(callback_url, action.action), + }) + }) + .collect::>(); + + serde_json::json!({ + "msgtype": "actionCard", + "actionCard": { + "title": panel.title, + "text": format!( + "{}\n\n{}\n直接发送文字即可与 Agent 对话。\n\n{}", + panel.introduction, panel.ordinary_chat_label, panel.safety_note + ), + "btnOrientation": "0", + "btns": buttons, + } + }) +} + +fn dingtalk_action_callback_url(callback_url: &str, action: &str) -> String { + let separator = if callback_url.contains('?') { '&' } else { '?' }; + format!( + "{}{separator}action={action}", + callback_url.trim_end_matches('/') + ) +} + +/// Map DingTalk-specific action aliases onto the canonical action keys shared +/// with Feishu. No platform keeps an unrelated second action vocabulary. +pub fn canonical_agent_menu_action(action: &str) -> Option<&'static str> { + match action.trim() { + "monitor_30s" | "desktop_monitor_30" => Some("monitor_30s"), + "monitor_60s" | "desktop_monitor_60" => Some("monitor_60s"), + "gateway_status" => Some("gateway_status"), + "recent_jobs" | "recent_tasks" => Some("recent_jobs"), + "help" => Some("help"), + _ => None, + } +} + +/// Extract a DingTalk card action without retaining or logging any sender, +/// conversation, robot, or message identifiers. +pub fn extract_dingtalk_agent_menu_action(payload: &serde_json::Value) -> Option { + const PATHS: &[&[&str]] = &[ + &["action"], + &["actionKey"], + &["actionId"], + &["params", "action"], + &["params", "actionKey"], + &["value", "action"], + &["value", "actionKey"], + &["data", "action"], + &["data", "actionKey"], + &["cardPrivateData", "action"], + ]; + + PATHS.iter().find_map(|path| { + path.iter() + .try_fold(payload, |value, segment| value.get(*segment)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_menu_contains_existing_feishu_items() { + let panel = build_agent_menu_panel(); + let labels = panel + .primary_actions + .iter() + .chain(panel.secondary_actions) + .map(|item| item.label) + .collect::>(); + + assert_eq!(panel.title, "OmniNova Agent 功能菜单"); + assert_eq!( + labels, + vec![ + "桌面监控 30 秒", + "桌面监控 60 秒", + "Gateway 状态", + "最近任务", + "帮助说明", + ] + ); + assert!(panel.safety_note.contains("高风险工具")); + } + + #[test] + fn dingtalk_menu_is_structured_text_without_sensitive_fields() { + let rendered = render_agent_menu_as_dingtalk_text(); + for expected in [ + "OmniNova Agent 功能菜单", + "普通聊天说明", + "桌面监控 30 秒", + "桌面监控 60 秒", + "Gateway 状态", + "最近任务", + "帮助说明", + "高风险工具不在普通聊天中直接执行", + ] { + assert!(rendered.contains(expected), "missing {expected:?}"); + } + for forbidden in [ + "app_secret", + "access_token", + "sessionWebhook", + "robotCode", + "conversationId", + "openConversationId", + "msgId", + "messageId", + ] { + assert!(!rendered.contains(forbidden), "leaked {forbidden}"); + } + } + + #[test] + fn dingtalk_card_payload_contains_five_shared_actions_and_string_msg_param() { + let payload = build_dingtalk_agent_menu_card_payload( + "https://gateway.example.test/api/v1/gateway/dingtalk/card/callback", + ); + assert_eq!(payload["msgKey"], "sampleActionCard5"); + let msg_param = payload["msgParam"] + .as_str() + .expect("msgParam must be a JSON string"); + let params: serde_json::Value = serde_json::from_str(msg_param).unwrap(); + + for (index, expected) in [ + "桌面监控 30 秒", + "桌面监控 60 秒", + "Gateway 状态", + "最近任务", + "帮助说明", + ] + .iter() + .enumerate() + { + let number = index + 1; + assert_eq!(params[format!("actionTitle{number}")], *expected); + assert!(params[format!("actionURL{number}")] + .as_str() + .is_some_and(|url| url.contains("/dingtalk/card/callback?action="))); + } + } + + #[test] + fn dingtalk_card_payload_contains_no_sensitive_identifiers() { + let payload = render_agent_menu_as_dingtalk_card( + "https://gateway.example.test/api/v1/gateway/dingtalk/card/callback", + ); + let serialized = payload.to_string(); + for forbidden in [ + "app_secret", + "access_token", + "sessionWebhook", + "robotCode", + "conversationId", + "openConversationId", + "msgId", + "messageId", + ] { + assert!(!serialized.contains(forbidden), "leaked {forbidden}"); + } + } + + #[test] + fn dingtalk_session_webhook_action_card_contains_five_shared_actions() { + let payload = render_agent_menu_as_dingtalk_session_action_card( + "https://gateway.example.test/api/v1/gateway/dingtalk/card/callback", + ); + assert_eq!(payload["msgtype"], "actionCard"); + assert_eq!(payload["actionCard"]["title"], "OmniNova Agent 功能菜单"); + assert_eq!(payload["actionCard"]["btnOrientation"], "0"); + let buttons = payload["actionCard"]["btns"].as_array().unwrap(); + assert_eq!(buttons.len(), 5); + for (button, expected) in buttons.iter().zip([ + ("桌面监控 30 秒", "monitor_30s"), + ("桌面监控 60 秒", "monitor_60s"), + ("Gateway 状态", "gateway_status"), + ("最近任务", "recent_jobs"), + ("帮助说明", "help"), + ]) { + assert_eq!(button["title"], expected.0); + assert!(button["actionURL"] + .as_str() + .is_some_and(|url| url.ends_with(&format!("?action={}", expected.1)))); + } + + let serialized = payload.to_string(); + for forbidden in [ + "app_secret", + "access_token", + "sessionWebhook", + "robotCode", + "conversationId", + "openConversationId", + "msgId", + "messageId", + ] { + assert!(!serialized.contains(forbidden), "leaked {forbidden}"); + } + } + + #[test] + fn dingtalk_action_extractor_and_aliases_use_canonical_keys() { + for (payload, expected) in [ + ( + serde_json::json!({"action": "gateway_status"}), + "gateway_status", + ), + ( + serde_json::json!({"params": {"actionKey": "recent_tasks"}}), + "recent_jobs", + ), + (serde_json::json!({"value": {"action": "help"}}), "help"), + ( + serde_json::json!({"actionId": "desktop_monitor_30"}), + "monitor_30s", + ), + ( + serde_json::json!({"data": {"action": "desktop_monitor_60"}}), + "monitor_60s", + ), + ] { + let extracted = extract_dingtalk_agent_menu_action(&payload).unwrap(); + assert_eq!(canonical_agent_menu_action(&extracted), Some(expected)); + } + } +} diff --git a/crates/omninova-core/src/gateway/dingtalk_card.rs b/crates/omninova-core/src/gateway/dingtalk_card.rs new file mode 100644 index 00000000..fb824960 --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_card.rs @@ -0,0 +1,1037 @@ +//! DingTalk advanced-card OpenAPI adapter. +//! +//! This module owns only create/update operations. It never logs platform +//! identifiers, credentials, request bodies, or response bodies. + +use crate::channels::InboundMessage; +use crate::config::schema::DingtalkTransportMode; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +pub const CREATE_AND_DELIVER_URL: &str = + "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"; +pub const UPDATE_CARD_URL: &str = "https://api.dingtalk.com/v1.0/card/instances"; +const HTTP_TIMEOUT_SECS: u64 = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DingtalkCardAvailability { + UnsupportedTransport, + MissingTemplate, + StreamDisconnected, + MissingContext, + Available, +} + +impl DingtalkCardAvailability { + pub const fn log_value(self) -> &'static str { + match self { + Self::UnsupportedTransport => "unsupported_transport", + Self::MissingTemplate => "missing_template", + Self::StreamDisconnected => "stream_disconnected", + Self::MissingContext => "missing_context", + Self::Available => "available", + } + } +} + +/// Decide whether an advanced card can be created. This is deliberately pure: +/// it performs no network request and never inspects or logs platform IDs. +/// +/// Card availability requires Stream to be truly registered (not just WebSocket open). +pub fn determine_card_availability( + transport_mode: DingtalkTransportMode, + template_configured: bool, + stream_registered: bool, + context_complete: bool, +) -> DingtalkCardAvailability { + if transport_mode != DingtalkTransportMode::Stream { + return DingtalkCardAvailability::UnsupportedTransport; + } + if !template_configured { + return DingtalkCardAvailability::MissingTemplate; + } + if !stream_registered { + return DingtalkCardAvailability::StreamDisconnected; + } + if !context_complete { + return DingtalkCardAvailability::MissingContext; + } + DingtalkCardAvailability::Available +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DingtalkCardTarget { + Group { + open_conversation_id: String, + robot_code: String, + user_id: Option, + }, + Direct { + user_id: String, + robot_code: String, + }, +} + +/// Conversation kind carried by the **robot callback** topic +/// (`/v1.0/im/bot/messages/get`): `"1"` = single chat, `"2"` = group chat. +/// +/// This deliberately does NOT reuse the interactive-card API conversation +/// type codes (where the numbering differs). It is named explicitly +/// `DingtalkRobotConversationType` so the two APIs can never be mixed up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DingtalkRobotConversationType { + Direct, + Group, +} + +impl DingtalkRobotConversationType { + /// Parse a robot callback conversationType. Accepts both `"1"` / `"2"` + /// strings and numeric `1` / `2` scalars (delivery-path drift). + /// Unknown or missing values are an error: a mistargeted card is worse + /// than a text fallback. + pub fn parse(value: &str) -> Result { + match value.trim() { + "1" => Ok(Self::Direct), + "2" => Ok(Self::Group), + _ => Err("unknown_robot_conversation_type".to_string()), + } + } + + pub const fn log_value(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Group => "group", + } + } +} + +/// Normalize a JSON scalar (string or number) to its string form. +fn json_scalar_to_string(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +impl DingtalkCardTarget { + pub fn from_inbound( + inbound: &InboundMessage, + fallback_robot_code: Option<&str>, + ) -> Result { + // Robot callback conversationType may arrive at metadata top level + // (Stream path) or nested inside raw_payload (HTTP webhook path). + // Both use robot callback semantics: "1"=single, "2"=group. + let conversation_type = inbound + .metadata + .get("conversationType") + .and_then(json_scalar_to_string) + .or_else(|| { + inbound + .metadata + .get("raw_payload") + .and_then(|raw| raw.get("conversationType")) + .and_then(json_scalar_to_string) + }) + .unwrap_or_default(); + let conversation_kind = DingtalkRobotConversationType::parse(&conversation_type)?; + let robot_code = inbound + .metadata + .get("robotCode") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + fallback_robot_code + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .ok_or_else(|| "missing_robot_code".to_string())? + .to_string(); + let user_id = inbound + .metadata + .get("senderStaffId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| inbound.user_id.clone()); + + println!( + "[dingtalk-routing] source=robot_callback conversation_kind={} conversation_id_present={} sender_staff_id_present={} robot_code_present={}", + conversation_kind.log_value(), + inbound + .session_id + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + || inbound + .metadata + .get("conversationId") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()), + user_id.as_ref().is_some(), + inbound.metadata.contains_key("robotCode") + ); + + match conversation_kind { + DingtalkRobotConversationType::Group => { + let open_conversation_id = inbound + .session_id + .clone() + .or_else(|| { + inbound + .metadata + .get("conversationId") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + }) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "missing_conversation_id".to_string())?; + Ok(Self::Group { + open_conversation_id, + robot_code, + user_id, + }) + } + DingtalkRobotConversationType::Direct => { + let user_id = user_id.ok_or_else(|| "missing_sender_staff_id".to_string())?; + Ok(Self::Direct { + user_id, + robot_code, + }) + } + } + } +} + +pub fn build_menu_create_payload( + card_template_id: &str, + out_track_id: &str, + target: &DingtalkCardTarget, +) -> serde_json::Value { + let panel = crate::gateway::agent_menu::build_agent_menu_panel(); + let button_groups = build_card_button_groups(); + let mut body = serde_json::json!({ + "cardTemplateId": card_template_id, + "outTrackId": out_track_id, + "callbackType": "STREAM", + "userIdType": 1, + "cardData": { + "cardParamMap": { + "title": panel.title, + "status": card_status_label("READY"), + "status_text": "Gateway 已连接", + "result": "请选择需要执行的操作", + "last_action": "-", + "btns": button_groups.btns, + "primary_actions": button_groups.primary_actions, + "monitor_actions": button_groups.monitor_actions, + "help_actions": button_groups.help_actions + } + } + }); + + match target { + DingtalkCardTarget::Group { + open_conversation_id, + robot_code, + user_id, + } => { + body["openSpaceId"] = + serde_json::json!(format!("dtv1.card//IM_GROUP.{open_conversation_id}")); + body["imGroupOpenSpaceModel"] = serde_json::json!({ "supportForward": true }); + body["imGroupOpenDeliverModel"] = serde_json::json!({ "robotCode": robot_code }); + if let Some(user_id) = user_id { + body["userId"] = serde_json::json!(user_id); + } + } + DingtalkCardTarget::Direct { + user_id, + robot_code, + } => { + body["openSpaceId"] = serde_json::json!(format!("dtv1.card//IM_ROBOT.{user_id}")); + body["userId"] = serde_json::json!(user_id); + body["imRobotOpenSpaceModel"] = serde_json::json!({ "supportForward": true }); + body["imRobotOpenDeliverModel"] = serde_json::json!({ + "robotCode": robot_code, + "spaceType": "IM_ROBOT" + }); + } + } + body +} + +pub fn build_card_update_payload( + out_track_id: &str, + status: &str, + status_text: &str, + result: &str, + last_action: &str, +) -> serde_json::Value { + let panel = crate::gateway::agent_menu::build_agent_menu_panel(); + let button_groups = build_card_button_groups(); + serde_json::json!({ + "outTrackId": out_track_id, + "userIdType": 1, + "cardData": { + "cardParamMap": { + "title": panel.title, + "status": card_status_label(status), + "status_text": status_text, + "result": result, + "last_action": last_action, + "btns": button_groups.btns, + "primary_actions": button_groups.primary_actions, + "monitor_actions": button_groups.monitor_actions, + "help_actions": button_groups.help_actions + } + }, + "cardUpdateOptions": { + "updateCardDataByKey": true + } + }) +} + +/// Build the DingTalk Advanced Card `btns` ButtonGroup JSON string. +/// +/// The DingTalk `cardParamMap` requires every value to be a JSON string, +/// so this returns a serialized JSON array of the 5 canonical menu +/// actions exposed by the shared `AgentMenu`. Action keys are **never** +/// duplicated here — they are resolved from the shared `AgentMenuPanel` +/// so Feishu and DingTalk stay in lockstep on what the palette means. +/// +/// Each button uses the standard `sendCardRequest` event so reactions +/// arrive on the Stream callback topic (`/v1.0/card/instances/callback`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DingtalkCardButtonGroups { + pub btns: String, + pub primary_actions: String, + pub monitor_actions: String, + pub help_actions: String, +} + +fn build_card_button(action: &crate::gateway::agent_menu::AgentMenuAction) -> serde_json::Value { + let color = match action.action { + "gateway_status" | "monitor_30s" | "monitor_60s" => "blue", + _ => "gray", + }; + serde_json::json!({ + "text": action.label, + "color": color, + "status": "normal", + "event": { + "type": "sendCardRequest", + "params": { + "actionId": action.action, + "params": { + "action": action.action + } + } + } + }) +} + +fn serialize_buttons(buttons: &[serde_json::Value]) -> String { + serde_json::to_string(buttons).unwrap_or_else(|_| "[]".to_string()) +} + +pub fn build_card_button_groups() -> DingtalkCardButtonGroups { + let panel = crate::gateway::agent_menu::build_agent_menu_panel(); + let actions = panel + .primary_actions + .iter() + .chain(panel.secondary_actions) + .collect::>(); + let select = |keys: &[&str]| { + keys.iter() + .filter_map(|key| actions.iter().find(|action| action.action == *key)) + .map(|action| build_card_button(action)) + .collect::>() + }; + + // DingTalk presentation slots only decide layout. Labels and canonical + // actions continue to come from the cross-channel AgentMenu spec. + let primary = select(&["gateway_status", "recent_jobs"]); + let monitors = select(&["monitor_30s", "monitor_60s"]); + let help = select(&["help"]); + let legacy = primary + .iter() + .chain(monitors.iter()) + .chain(help.iter()) + .cloned() + .collect::>(); + + DingtalkCardButtonGroups { + btns: serialize_buttons(&legacy), + primary_actions: serialize_buttons(&primary), + monitor_actions: serialize_buttons(&monitors), + help_actions: serialize_buttons(&help), + } +} + +pub fn build_card_buttons_json() -> String { + build_card_button_groups().btns +} + +pub fn card_status_label(status: &str) -> &'static str { + match status { + "READY" | "SUCCESS" => "在线", + "RUNNING" => "执行中", + "FAILED" => "上次操作失败", + "BUSY" => "正在执行其他任务", + _ => "状态未知", + } +} + +pub async fn create_and_deliver_menu_card( + access_token: &str, + card_template_id: &str, + target: &DingtalkCardTarget, +) -> Result { + if card_template_id.trim().is_empty() { + return Err("missing_card_template_id".to_string()); + } + let out_track_id = format!("omninova-menu-{}", uuid::Uuid::new_v4()); + let payload = build_menu_create_payload(card_template_id, &out_track_id, target); + let (target_kind, space_kind) = match target { + DingtalkCardTarget::Group { .. } => ("group", "IM_GROUP"), + DingtalkCardTarget::Direct { .. } => ("single", "IM_ROBOT"), + }; + println!( + "[dingtalk-card] create_start template_configured=true target_kind={} space_kind={} out_track_hash={}", + target_kind, + space_kind, + opaque_id(&out_track_id) + ); + match send_card_request("POST", CREATE_AND_DELIVER_URL, access_token, &payload).await { + Ok(()) => { + println!( + "[dingtalk-card] create_ok out_track_hash={}", + opaque_id(&out_track_id) + ); + Ok(out_track_id) + } + Err(error) => { + println!( + "[dingtalk-card] create_failed reason={}", + safe_error_kind(&error) + ); + Err(error) + } + } +} + +pub async fn update_card( + access_token: &str, + out_track_id: &str, + status: &str, + status_text: &str, + result: &str, + last_action: &str, +) -> Result<(), String> { + if out_track_id.trim().is_empty() { + return Err("missing_out_track_id".to_string()); + } + let payload = build_card_update_payload(out_track_id, status, status_text, result, last_action); + println!( + "[dingtalk-card] update_start state={} out_track_hash={}", + safe_state(status), + opaque_id(out_track_id) + ); + send_card_request("PUT", UPDATE_CARD_URL, access_token, &payload).await?; + println!( + "[dingtalk-card] update_ok state={} out_track_hash={}", + safe_state(status), + opaque_id(out_track_id) + ); + Ok(()) +} + +async fn send_card_request( + method: &str, + url: &str, + access_token: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + .map_err(|_| "card_http_client_error".to_string())?; + let request = match method { + "PUT" => client.put(url), + _ => client.post(url), + }; + let response = request + .header("Content-Type", "application/json") + .header("x-acs-dingtalk-access-token", access_token) + .json(payload) + .send() + .await + .map_err(|_| "card_network_error".to_string())?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|_| "card_response_read_error".to_string())?; + let parsed = serde_json::from_str::(&body).ok(); + let code = parsed + .as_ref() + .and_then(|value| value.get("code").or_else(|| value.get("errcode"))) + .map(|value| match value { + serde_json::Value::String(value) => value.clone(), + value => value.to_string(), + }) + .unwrap_or_else(|| { + if (200..300).contains(&status) { + "0" + } else { + "unknown" + } + .into() + }); + let message_len = parsed + .as_ref() + .and_then(|value| value.get("message").or_else(|| value.get("msg"))) + .and_then(serde_json::Value::as_str) + .map(str::len) + .unwrap_or(0); + let success = (200..300).contains(&status) && matches!(code.as_str(), "0" | "OK" | "ok"); + println!( + "[dingtalk-card] response operation={} http_status={} platform_code={} message_len={} body_len={}", + if method == "PUT" { "update" } else { "create" }, + status, + safe_code(&code), + message_len, + body.len() + ); + if success { + Ok(()) + } else { + Err(format!( + "card_platform_error:status={status}:code={}:message_len={message_len}", + safe_code(&code) + )) + } +} + +pub fn opaque_id(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..6]) +} + +fn safe_code(code: &str) -> String { + code.chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + .take(64) + .collect() +} + +fn safe_state(state: &str) -> &'static str { + match state { + "READY" => "READY", + "RUNNING" => "RUNNING", + "SUCCESS" => "SUCCESS", + "FAILED" => "FAILED", + "BUSY" => "BUSY", + _ => "UNKNOWN", + } +} + +fn safe_error_kind(error: &str) -> String { + error + .split([':', '=']) + .next() + .unwrap_or("unknown") + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + .take(64) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_availability_prioritizes_transport_and_stream_requirements() { + use DingtalkCardAvailability::*; + use DingtalkTransportMode::{Http, Stream}; + + assert_eq!( + determine_card_availability(Http, true, true, true), + UnsupportedTransport + ); + assert_eq!( + determine_card_availability(Http, false, false, false), + UnsupportedTransport + ); + assert_eq!( + determine_card_availability(Stream, false, true, true), + MissingTemplate + ); + assert_eq!( + determine_card_availability(Stream, true, false, true), + StreamDisconnected + ); + assert_eq!( + determine_card_availability(Stream, true, true, false), + MissingContext + ); + assert_eq!( + determine_card_availability(Stream, true, true, true), + Available + ); + } + + #[test] + fn robot_conversation_type_1_is_direct() { + assert_eq!( + DingtalkRobotConversationType::parse("1").unwrap(), + DingtalkRobotConversationType::Direct + ); + } + + #[test] + fn robot_conversation_type_2_is_group() { + assert_eq!( + DingtalkRobotConversationType::parse("2").unwrap(), + DingtalkRobotConversationType::Group + ); + } + + #[test] + fn robot_conversation_type_numeric_2_is_group() { + // Delivery-path drift: numeric 2 must normalize to Group the same way. + let inbound = InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("user-test".to_string()), + session_id: Some("group-conversation-test".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!(2)), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ("senderStaffId".to_string(), serde_json::json!("user-test")), + ]), + }; + let target = + DingtalkCardTarget::from_inbound(&inbound, None).expect("group target must build"); + assert!(matches!(target, DingtalkCardTarget::Group { .. })); + assert_eq!( + target, + DingtalkCardTarget::Group { + open_conversation_id: "group-conversation-test".to_string(), + robot_code: "robot-test".to_string(), + user_id: Some("user-test".to_string()), + } + ); + } + + #[test] + fn robot_conversation_type_unknown_is_error() { + assert!(DingtalkRobotConversationType::parse("3").is_err()); + assert!(DingtalkRobotConversationType::parse("").is_err()); + assert!(DingtalkRobotConversationType::parse("group").is_err()); + } + + #[test] + fn group_callback_builds_group_target_from_conversation_id() { + // Realistic group robot callback: conversationType=2 must route the + // card into the group (conversationId), never the sender's private chat. + let inbound = InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("user-test".to_string()), + session_id: Some("GROUP_A".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!("2")), + ("conversationId".to_string(), serde_json::json!("GROUP_A")), + ("senderStaffId".to_string(), serde_json::json!("USER_A")), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ]), + }; + let target = + DingtalkCardTarget::from_inbound(&inbound, None).expect("group target must build"); + let DingtalkCardTarget::Group { + ref open_conversation_id, + ref robot_code, + ref user_id, + } = target + else { + panic!("expected Group target"); + }; + assert_eq!(open_conversation_id, "GROUP_A"); + assert_eq!(robot_code, "robot-test"); + assert_eq!(user_id.as_deref(), Some("USER_A")); + + let payload = build_menu_create_payload("template", "track", &target); + assert_eq!( + payload["openSpaceId"].as_str().unwrap(), + "dtv1.card//IM_GROUP.GROUP_A", + "group card must use IM_GROUP space with conversationId" + ); + assert!( + !payload["openSpaceId"] + .as_str() + .unwrap() + .contains("USER_A"), + "group card must never use senderStaffId as space identity" + ); + } + + #[test] + fn group_callback_without_raw_payload_still_builds_group_target() { + // Stream path carries conversationType at metadata top level, not + // inside raw_payload. The builder must honor that. + let inbound = InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("user-test".to_string()), + session_id: Some("GROUP_B".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!("2")), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ]), + }; + let target = + DingtalkCardTarget::from_inbound(&inbound, None).expect("group target must build"); + let DingtalkCardTarget::Group { + open_conversation_id, .. + } = target + else { + panic!("expected Group target"); + }; + assert_eq!(open_conversation_id, "GROUP_B"); + } + + #[test] + fn direct_callback_builds_direct_target_from_sender_staff_id() { + // conversationType=1 must produce a Direct (IM_ROBOT) target whose + // identity comes from senderStaffId. + let inbound = InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("USER_A".to_string()), + session_id: Some("DIRECT_SESSION".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!("1")), + ("conversationId".to_string(), serde_json::json!("DIRECT_SESSION")), + ("senderStaffId".to_string(), serde_json::json!("USER_A")), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ]), + }; + let target = + DingtalkCardTarget::from_inbound(&inbound, None).expect("direct target must build"); + assert_eq!( + target, + DingtalkCardTarget::Direct { + user_id: "USER_A".to_string(), + robot_code: "robot-test".to_string(), + } + ); + let payload = build_menu_create_payload("template", "track", &target); + assert_eq!( + payload["openSpaceId"].as_str().unwrap(), + "dtv1.card//IM_ROBOT.USER_A" + ); + } + + #[test] + fn routing_identity_collision_group_wins_over_sender() { + // conversationId=GROUP_A with senderStaffId=USER_A must build the + // group space from GROUP_A. This is the regression guard for the + // group-card-to-private-chat bug. + let inbound = InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("USER_A".to_string()), + session_id: Some("GROUP_A".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!("2")), + ("conversationId".to_string(), serde_json::json!("GROUP_A")), + ("senderStaffId".to_string(), serde_json::json!("USER_A")), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ]), + }; + let target = + DingtalkCardTarget::from_inbound(&inbound, None).expect("group target must build"); + let payload = build_menu_create_payload("template", "track", &target); + let space = payload["openSpaceId"].as_str().unwrap(); + assert!(space.starts_with("dtv1.card//IM_GROUP.GROUP_A"), "space: {space}"); + assert!(!space.contains("USER_A"), "space must not use sender id: {space}"); + } + + #[test] + fn group_payload_uses_stream_and_group_space() { + let target = DingtalkCardTarget::Group { + open_conversation_id: "conversation-secret".into(), + robot_code: "robot-secret".into(), + user_id: Some("user-secret".into()), + }; + let payload = build_menu_create_payload("template", "track", &target); + assert_eq!(payload["callbackType"], "STREAM"); + assert_eq!(payload["cardData"]["cardParamMap"]["status"], "在线"); + assert_eq!( + payload["cardData"]["cardParamMap"]["status_text"], + "Gateway 已连接" + ); + assert_eq!( + payload["cardData"]["cardParamMap"]["result"], + "请选择需要执行的操作" + ); + assert_eq!(payload["cardData"]["cardParamMap"]["last_action"], "-"); + assert!(payload["openSpaceId"] + .as_str() + .is_some_and(|value| value.starts_with("dtv1.card//IM_GROUP."))); + assert_eq!( + payload["imGroupOpenDeliverModel"]["robotCode"], + "robot-secret" + ); + } + + #[test] + fn direct_payload_uses_robot_space() { + let target = DingtalkCardTarget::Direct { + user_id: "user-secret".into(), + robot_code: "robot-secret".into(), + }; + let payload = build_menu_create_payload("template", "track", &target); + assert!(payload["openSpaceId"] + .as_str() + .is_some_and(|value| value.starts_with("dtv1.card//IM_ROBOT."))); + assert_eq!(payload["imRobotOpenDeliverModel"]["spaceType"], "IM_ROBOT"); + } + + #[test] + fn update_payload_is_incremental_and_uses_same_track_id() { + let payload = build_card_update_payload( + "same-track", + "SUCCESS", + "done", + "status body", + "gateway_status", + ); + assert_eq!(payload["outTrackId"], "same-track"); + assert_eq!(payload["cardUpdateOptions"]["updateCardDataByKey"], true); + assert_eq!(payload["cardData"]["cardParamMap"]["status"], "在线"); + } + + #[test] + fn opaque_id_never_contains_original_identifier() { + let hash = opaque_id("secret-conversation-identifier"); + assert_eq!(hash.len(), 12); + assert!(!hash.contains("secret")); + } + + #[test] + fn card_buttons_only_contain_canonical_actions() { + let btns_str = build_card_buttons_json(); + let buttons: Vec = serde_json::from_str(&btns_str).unwrap(); + assert_eq!(buttons.len(), 5, "btns must contain exactly 5 canonical actions"); + + let allowed: std::collections::HashSet<&str> = [ + "gateway_status", + "monitor_30s", + "monitor_60s", + "recent_jobs", + "help", + ] + .iter() + .copied() + .collect(); + + for btn in &buttons { + // event.params.actionId is the canonical action + let action_id = btn["event"]["params"]["actionId"] + .as_str() + .expect("actionId must be string"); + // event.params.params.action is the same canonical action + let action = btn["event"]["params"]["params"]["action"] + .as_str() + .expect("params.action must be string"); + assert_eq!(action_id, action); + assert!( + allowed.contains(action), + "btn contains non-canonical action {action:?}" + ); + assert_eq!(btn["event"]["type"].as_str().unwrap(), "sendCardRequest"); + assert_eq!(btn["status"].as_str().unwrap(), "normal"); + } + } + + #[test] + fn card_buttons_match_dingtalk_template_order_and_labels() { + let btns_str = build_card_buttons_json(); + let buttons: Vec = serde_json::from_str(&btns_str).unwrap(); + + // Required order: gateway_status, recent_jobs, monitor_30s, + // monitor_60s, help — matches the DingTalk Card template slots. + let expected_order = [ + ("gateway_status", "Gateway 状态", "blue"), + ("recent_jobs", "最近任务", "gray"), + ("monitor_30s", "桌面监控 30 秒", "blue"), + ("monitor_60s", "桌面监控 60 秒", "blue"), + ("help", "帮助说明", "gray"), + ]; + for (i, (action, text, color)) in expected_order.iter().enumerate() { + let btn = &buttons[i]; + assert_eq!( + btn["event"]["params"]["actionId"].as_str().unwrap(), + *action + ); + assert_eq!( + btn["event"]["params"]["params"]["action"].as_str().unwrap(), + *action + ); + assert_eq!(btn["text"].as_str().unwrap(), *text); + assert_eq!(btn["color"].as_str().unwrap(), *color); + } + } + + #[test] + fn card_buttons_payload_is_json_string() { + // DingTalk cardParamMap values must be JSON strings. + let btns_str = build_card_buttons_json(); + let parsed: serde_json::Value = serde_json::from_str(&btns_str).unwrap(); + assert!(parsed.is_array(), "btns must be a JSON array"); + assert_eq!(parsed.as_array().unwrap().len(), 5); + } + + #[test] + fn create_payload_includes_btns() { + let target = DingtalkCardTarget::Direct { + user_id: "user-secret".into(), + robot_code: "robot-secret".into(), + }; + let payload = build_menu_create_payload("template", "track", &target); + let btns_str = payload["cardData"]["cardParamMap"]["btns"] + .as_str() + .expect("btns must be a JSON string in cardParamMap"); + let buttons: Vec = serde_json::from_str(btns_str).unwrap(); + assert_eq!(buttons.len(), 5); + assert_eq!( + buttons[0]["event"]["params"]["actionId"].as_str().unwrap(), + "gateway_status" + ); + } + + #[test] + fn update_payload_includes_btns() { + let payload = build_card_update_payload( + "track-xyz", + "SUCCESS", + "done", + "status body", + "gateway_status", + ); + let btns_str = payload["cardData"]["cardParamMap"]["btns"] + .as_str() + .expect("update payload must also carry btns"); + let buttons: Vec = serde_json::from_str(btns_str).unwrap(); + assert_eq!(buttons.len(), 5); + // Every button must be a canonical action so the palette stays + // intact across READY -> RUNNING -> SUCCESS updates. + for btn in &buttons { + let action = btn["event"]["params"]["actionId"] + .as_str() + .unwrap(); + assert!( + matches!( + action, + "gateway_status" | "monitor_30s" | "monitor_60s" | "recent_jobs" | "help" + ), + "update payload button contains non-canonical action {action:?}" + ); + } + } + + #[test] + fn create_and_update_share_the_same_btns_layout() { + let target = DingtalkCardTarget::Direct { + user_id: "user-secret".into(), + robot_code: "robot-secret".into(), + }; + let create = build_menu_create_payload("template", "track", &target); + let update = build_card_update_payload("track", "RUNNING", "running", "x", "gateway_status"); + + let create_btns = create["cardData"]["cardParamMap"]["btns"] + .as_str() + .unwrap(); + let update_btns = update["cardData"]["cardParamMap"]["btns"] + .as_str() + .unwrap(); + + // Both payloads must emit an identical button list so the visible + // palette does not disappear when the card enters RUNNING. + assert_eq!(create_btns, update_btns); + } + + #[test] + fn create_payload_includes_two_two_one_button_groups() { + let target = DingtalkCardTarget::Direct { + user_id: "user-secret".into(), + robot_code: "robot-secret".into(), + }; + let payload = build_menu_create_payload("template", "track", &target); + let params = &payload["cardData"]["cardParamMap"]; + let primary: Vec = + serde_json::from_str(params["primary_actions"].as_str().unwrap()).unwrap(); + let monitors: Vec = + serde_json::from_str(params["monitor_actions"].as_str().unwrap()).unwrap(); + let help: Vec = + serde_json::from_str(params["help_actions"].as_str().unwrap()).unwrap(); + assert_eq!(primary.len(), 2); + assert_eq!(monitors.len(), 2); + assert_eq!(help.len(), 1); + assert_eq!(primary[0]["event"]["params"]["actionId"], "gateway_status"); + assert_eq!(primary[1]["event"]["params"]["actionId"], "recent_jobs"); + assert_eq!(monitors[0]["event"]["params"]["actionId"], "monitor_30s"); + assert_eq!(monitors[1]["event"]["params"]["actionId"], "monitor_60s"); + assert_eq!(help[0]["event"]["params"]["actionId"], "help"); + } + + #[test] + fn update_payload_includes_the_same_group_fields_as_create() { + let target = DingtalkCardTarget::Direct { + user_id: "user-secret".into(), + robot_code: "robot-secret".into(), + }; + let create = build_menu_create_payload("template", "track", &target); + let update = build_card_update_payload( + "track", + "RUNNING", + "正在读取 Gateway 状态...", + "正在读取 Gateway 状态...", + "Gateway 状态", + ); + for key in [ + "title", + "status", + "status_text", + "result", + "last_action", + "btns", + "primary_actions", + "monitor_actions", + "help_actions", + ] { + assert!(create["cardData"]["cardParamMap"][key].is_string()); + assert!(update["cardData"]["cardParamMap"][key].is_string()); + } + for key in ["btns", "primary_actions", "monitor_actions", "help_actions"] { + assert_eq!( + create["cardData"]["cardParamMap"][key], + update["cardData"]["cardParamMap"][key] + ); + } + } + + #[test] + fn card_status_values_are_user_friendly() { + assert_eq!(card_status_label("READY"), "在线"); + assert_eq!(card_status_label("RUNNING"), "执行中"); + assert_eq!(card_status_label("SUCCESS"), "在线"); + assert_eq!(card_status_label("FAILED"), "上次操作失败"); + assert_eq!(card_status_label("BUSY"), "正在执行其他任务"); + } +} diff --git a/crates/omninova-core/src/gateway/dingtalk_card_stream.rs b/crates/omninova-core/src/gateway/dingtalk_card_stream.rs new file mode 100644 index 00000000..c62511fb --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_card_stream.rs @@ -0,0 +1,1511 @@ +//! DingTalk Stream client for advanced-card callbacks. +//! +//! The callback frame is acknowledged before any business work is spawned. +//! Card actions are processed asynchronously with dedupe and single-flight guards. + +use crate::config::schema::DingtalkTransportMode; +use crate::gateway::agent_menu::canonical_agent_menu_action; +use crate::gateway::dingtalk_card; +use crate::gateway::GatewayRuntime; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use std::sync::RwLock; +use tokio::sync::watch; +use tokio_tungstenite::tungstenite::Message; + +const DINGTALK_STREAM_GATEWAY_URL: &str = "https://api.dingtalk.com/v1.0/gateway/connections/open"; +pub const DINGTALK_CARD_CALLBACK_TOPIC: &str = "/v1.0/card/instances/callback"; + +/// In-memory dedupe cache for card callbacks to prevent duplicate action execution. +/// Uses HashSet with max size to prevent unbounded memory growth. +pub struct CallbackDedupeCache { + #[cfg(test)] + pub entries: RwLock>, + #[cfg(not(test))] + entries: RwLock>, + max_size: usize, + ttl: Duration, +} + +impl CallbackDedupeCache { + pub fn new(max_size: usize) -> Self { + Self { + entries: RwLock::new(HashMap::with_capacity(max_size)), + max_size, + ttl: Duration::from_secs(5 * 60), + } + } + + /// Try to insert a callback key. Returns false if already present (duplicate). + pub fn try_insert(&self, key: &str) -> bool { + let mut entries = self.entries.write().unwrap(); + let now = Instant::now(); + entries.retain(|_, inserted_at| now.duration_since(*inserted_at) < self.ttl); + if entries.contains_key(key) { + return false; + } + if entries.len() >= self.max_size { + if let Some(oldest) = entries + .iter() + .min_by_key(|(_, inserted_at)| **inserted_at) + .map(|(key, _)| key.clone()) + { + entries.remove(&oldest); + } + } + entries.insert(key.to_string(), now); + true + } + + /// Remove a callback key (for testing) + #[cfg(test)] + pub fn remove(&self, key: &str) { + self.entries.write().unwrap().remove(key); + } +} + +impl Default for CallbackDedupeCache { + fn default() -> Self { + Self::new(1000) + } +} + +pub struct DingtalkCardStreamGuard { + shutdown: Option>, + runtime: Arc, +} + +impl Drop for DingtalkCardStreamGuard { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(true); + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ParsedCardCallback { + pub out_track_id: String, + pub action: String, + pub callback_id: Option, + pub user_id: Option, + pub space_id: Option, +} + +impl std::fmt::Debug for ParsedCardCallback { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ParsedCardCallback") + .field("out_track_id_present", &!self.out_track_id.is_empty()) + .field("action", &safe_action(&self.action)) + .field("callback_id_present", &self.callback_id.is_some()) + .field("user_id_present", &self.user_id.is_some()) + .field("space_id_present", &self.space_id.is_some()) + .finish() + } +} + +pub async fn start(runtime: Arc) -> Option { + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + let enabled = + config.gateway.dingtalk.enabled || entry.map(|entry| entry.enabled).unwrap_or(false); + if !enabled { + println!("[dingtalk-card-stream] start_skipped reason=channel_disabled"); + return None; + } + let transport_mode = crate::gateway::resolve_dingtalk_transport_mode_for_worker(&config, entry); + if !should_start_card_stream(enabled, transport_mode) { + println!("[dingtalk-card-stream] start_skipped reason=unsupported_transport mode=http"); + return None; + } + let app_key = crate::gateway::resolve_dingtalk_app_key_for_worker(&config, entry); + let app_secret = crate::gateway::resolve_dingtalk_secret_for_worker(&config, entry); + let template_id = crate::gateway::resolve_dingtalk_card_template_for_worker(&config, entry); + println!( + "[dingtalk-card-stream] config app_key_present={} app_secret_present={} template_configured={}", + app_key.is_some(), + app_secret.is_some(), + template_id.is_some() + ); + let (Some(app_key), Some(app_secret)) = (app_key, app_secret) else { + println!("[dingtalk-card-stream] start_skipped reason=incomplete_configuration"); + return None; + }; + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let dedupe_cache = Arc::new(CallbackDedupeCache::default()); + tokio::spawn(run_reconnecting( + runtime.clone(), + app_key, + app_secret, + shutdown_rx, + dedupe_cache, + )); + Some(DingtalkCardStreamGuard { + shutdown: Some(shutdown_tx), + runtime, + }) +} + +pub fn should_start_card_stream( + channel_enabled: bool, + transport_mode: DingtalkTransportMode, +) -> bool { + channel_enabled && transport_mode == DingtalkTransportMode::Stream +} + +async fn run_reconnecting( + runtime: Arc, + app_key: String, + app_secret: String, + mut shutdown: watch::Receiver, + dedupe_cache: Arc, +) { + let mut attempt = 0u32; + loop { + if *shutdown.borrow() { + break; + } + match connect_once(runtime.clone(), &app_key, &app_secret, shutdown.clone(), dedupe_cache.clone()).await { + Ok(()) => attempt = 0, + Err(error) => { + attempt = attempt.saturating_add(1); + println!( + "[dingtalk-card-stream] disconnected reason={} reconnect_attempt={}", + safe_error_kind(&error), + attempt + ); + } + } + let delay = reconnect_delay(attempt); + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { break; } + } + _ = tokio::time::sleep(delay) => {} + } + } + println!("[dingtalk-card-stream] stopped=true"); +} + +async fn connect_once( + runtime: Arc, + app_key: &str, + app_secret: &str, + mut shutdown: watch::Receiver, + dedupe_cache: Arc, +) -> Result<(), String> { + let (endpoint, ticket) = request_stream_connection(app_key, app_secret).await?; + let mut url = + reqwest::Url::parse(&endpoint).map_err(|_| "invalid_stream_endpoint".to_string())?; + url.query_pairs_mut().append_pair("ticket", &ticket); + let (mut socket, _) = tokio_tungstenite::connect_async(url.as_str()) + .await + .map_err(|_| "stream_connect_error".to_string())?; + println!("[dingtalk-card-stream] connected=true topic=card_callback"); + + loop { + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + // Close the socket gracefully by dropping + return Ok(()); + } + } + frame = socket.next() => { + let Some(frame) = frame else { return Err("stream_closed".to_string()); }; + let frame = frame.map_err(|_| "stream_read_error".to_string())?; + match frame { + Message::Text(text) => { + handle_text_frame(&mut socket, runtime.clone(), text.as_ref(), dedupe_cache.clone()).await?; + } + Message::Binary(bytes) => { + if let Ok(text) = std::str::from_utf8(&bytes) { + handle_text_frame(&mut socket, runtime.clone(), text, dedupe_cache.clone()).await?; + } + } + Message::Ping(payload) => { + socket.send(Message::Pong(payload)).await.map_err(|_| "stream_write_error".to_string())?; + } + Message::Close(_) => return Err("stream_closed".to_string()), + _ => {} + } + } + } + } +} + +async fn request_stream_connection( + app_key: &str, + app_secret: &str, +) -> Result<(String, String), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|_| "stream_http_client_error".to_string())?; + let response = client + .post(DINGTALK_STREAM_GATEWAY_URL) + .json(&serde_json::json!({ + "clientId": app_key, + "clientSecret": app_secret, + "ua": "omninova-claw-rust/0.1", + "subscriptions": [{ + "type": "CALLBACK", + "topic": DINGTALK_CARD_CALLBACK_TOPIC + }] + })) + .send() + .await + .map_err(|_| "stream_gateway_network_error".to_string())?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|_| "stream_gateway_read_error".to_string())?; + let payload: Value = + serde_json::from_str(&body).map_err(|_| "stream_gateway_invalid_json".to_string())?; + if !(200..300).contains(&status) { + let code = payload + .get("code") + .map(safe_json_scalar) + .unwrap_or_else(|| "unknown".to_string()); + println!( + "[dingtalk-card-stream] gateway_failed http_status={} platform_code={} body_len={}", + status, + code, + body.len() + ); + return Err(format!("stream_gateway_http_error:{status}:{code}")); + } + let data = payload.get("data").unwrap_or(&payload); + let endpoint = data + .get("endpoint") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "stream_endpoint_missing".to_string())?; + let ticket = data + .get("ticket") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "stream_ticket_missing".to_string())?; + Ok((endpoint.to_string(), ticket.to_string())) +} + +async fn handle_text_frame( + socket: &mut S, + runtime: Arc, + text: &str, + dedupe_cache: Arc, +) -> Result<(), String> +where + S: futures_util::Sink + Unpin, +{ + let envelope: Value = + serde_json::from_str(text).map_err(|_| "invalid_stream_frame".to_string())?; + let frame_type = envelope + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + let topic = envelope + .get("headers") + .and_then(|headers| headers.get("topic")) + .and_then(Value::as_str) + .unwrap_or_default(); + + if frame_type == "SYSTEM" { + let ack = build_stream_ack(&envelope, serde_json::json!({})); + socket + .send(Message::Text(ack.to_string().into())) + .await + .map_err(|_| "stream_ack_error".to_string())?; + return Ok(()); + } + if frame_type != "CALLBACK" || topic != DINGTALK_CARD_CALLBACK_TOPIC { + return Ok(()); + } + + // Parse callback to extract action and identity + let callback = match parse_card_callback_envelope(&envelope) { + Ok(cb) => cb, + Err(error) => { + println!( + "[dingtalk-card] callback_parse_failed reason={}", + safe_error_kind(&error) + ); + // Still ACK to prevent DingTalk retry + let ack = build_stream_ack(&envelope, serde_json::json!({ "response": {} })); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + }; + + // Canonicalize action + let canonical_action = match canonical_agent_menu_action(&callback.action) { + Some(action) => action, + None => { + println!( + "[dingtalk-card] action_rejected action={} reason=not_allowed", + safe_action(&callback.action) + ); + let ack = build_stream_ack(&envelope, serde_json::json!({ "response": {} })); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + }; + + // Dedupe a delivery retry, not every future click on the same card. + // DingTalk's Stream `messageId` identifies this callback delivery. + let envelope_callback_id = envelope + .get("headers") + .and_then(|headers| { + headers + .get("messageId") + .or_else(|| headers.get("time")) + }) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let dedupe_key = callback_dedupe_key( + callback.callback_id.as_deref().or(envelope_callback_id), + &callback.out_track_id, + canonical_action, + text, + ); + if !dedupe_cache.try_insert(&dedupe_key) { + println!( + "[dingtalk-card] callback_duplicated dedupe_key_hash={}", + opaque_short_hash(&dedupe_key) + ); + let ack = build_stream_ack(&envelope, serde_json::json!({ "response": {} })); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + + // ACK immediately - before long-running work + let ack = build_stream_ack(&envelope, serde_json::json!({ "response": {} })); + match socket.send(Message::Text(ack.to_string().into())).await { + Ok(()) => { + println!("[dingtalk-card] ack_ok=true"); + } + Err(e) => { + println!("[dingtalk-card] ack_failed reason=websocket_write"); + return Err(format!("stream_ack_error:{}", e)); + } + } + + // Dispatch action + println!( + "[dingtalk-card] callback_received action={} accepted=true", + canonical_action + ); + + tokio::spawn(process_panel_action(runtime, callback)); + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PanelActionResult { + pub action: &'static str, + pub card_summary: String, + pub message_body: Option, + pub success: bool, + pub busy: bool, +} + +impl PanelActionResult { + pub(crate) fn success( + action: &'static str, + card_summary: impl Into, + body: String, + ) -> Self { + Self { + action, + card_summary: card_summary.into(), + message_body: Some(body), + success: true, + busy: false, + } + } + + pub(crate) fn failed(action: &'static str, card_summary: impl Into, body: String) -> Self { + Self { + action, + card_summary: card_summary.into(), + message_body: Some(body), + success: false, + busy: false, + } + } + + pub(crate) fn busy(action: &'static str) -> Self { + Self { + action, + card_summary: "已有桌面监控任务正在运行".to_string(), + message_body: None, + success: false, + busy: true, + } + } +} + +/// RAII guard that releases the DingtalkMonitorGuard when dropped. +/// The guard is stored inside the runtime's monitor guard; this struct just +/// holds the out_track_id so Drop can call release. +pub(crate) struct DingtalkMonitorLease { + guard: Arc, + out_track_id: String, + owner_id: String, +} + +impl Drop for DingtalkMonitorLease { + fn drop(&mut self) { + // We cannot use async Drop, so release synchronously. + // SAFETY: the guard uses tokio::RwLock which is not Sync, but Drop is sync + // and we're just taking the Arc. The release will happen on a best-effort + // basis. For production correctness, use a spawn + forget pattern. + let guard = self.guard.clone(); + let out_track_id = self.out_track_id.clone(); + let owner_id = self.owner_id.clone(); + tokio::spawn(async move { + let _ = guard.release(&out_track_id, &owner_id).await; + }); + } +} + +pub(crate) async fn process_panel_action( + runtime: Arc, + callback: ParsedCardCallback, +) { + let Some(action) = canonical_agent_menu_action(&callback.action) else { + println!("[dingtalk-panel] action_rejected=true"); + return; + }; + println!("[dingtalk-panel] action={action}"); + + // ------------------------------------------------------------------------- + // Admission-first for monitor actions: prevent concurrent monitor executions. + // This MUST happen before claiming the card generation. + // ------------------------------------------------------------------------- + let monitor_lease: Option = + if matches!(action, "monitor_30s" | "monitor_60s") { + let guard = runtime.dingtalk_monitor_guard(); + match guard.try_acquire(&callback.out_track_id).await { + Some(owner_id) => { + println!("[dingtalk-panel] monitor_admission=acquired action={action}"); + Some(DingtalkMonitorLease { + guard, + out_track_id: callback.out_track_id.clone(), + owner_id, + }) + } + None => { + println!( + "[dingtalk-panel] monitor_admission=busy action={action}" + ); + // BUSY without claiming generation — does not affect the + // running monitor's generation. + return; + } + } + } else { + None + }; + + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + let app_key = crate::gateway::resolve_dingtalk_app_key_for_worker(&config, entry); + let app_secret = crate::gateway::resolve_dingtalk_secret_for_worker(&config, entry); + let (Some(app_key), Some(app_secret)) = (app_key, app_secret) else { + println!("[dingtalk-panel] action_failed=true reason=missing_credentials"); + return; + }; + let token = match crate::gateway::dingtalk_worker::fetch_dingtalk_access_token( + &app_key, + &app_secret, + ) + .await + { + Ok(token) => token, + Err(error) => { + println!( + "[dingtalk-panel] action_failed=true reason={}", + safe_error_kind(&error) + ); + return; + } + }; + + // Claim ownership of the card's UI. Concurrent callbacks for the same + // `out_track_id` will get distinct generations, so only the latest action + // can complete the final READY update. + let store = runtime.dingtalk_store(); + let Some(store) = store else { + println!("[dingtalk-panel] action_failed=true reason=no_store"); + return; + }; + let generation = store.claim_card_generation(&callback.out_track_id).await; + let track_hash = opaque_short_hash(&callback.out_track_id); + println!( + "[dingtalk-panel] operation_started generation={generation} track_hash={track_hash} action={action}" + ); + + // Lookup + slide TTL atomically: a still-live panel will receive a + // refresh so the next action is not penalised. + let lookup = store + .lookup_and_touch(&callback.out_track_id) + .await; + let lookup_log = match lookup { + crate::gateway::dingtalk_store::PanelContextLookup::Hit(_) => "hit", + crate::gateway::dingtalk_store::PanelContextLookup::Missing => "missing", + crate::gateway::dingtalk_store::PanelContextLookup::Expired => "expired", + }; + println!("[dingtalk-panel] context_lookup={lookup_log}"); + let context = match lookup { + crate::gateway::dingtalk_store::PanelContextLookup::Hit(context) => context, + _ => { + // Card update must respect ownership: if we lost (or never had) a + // generation, skip the update entirely. + if store + .is_card_generation_current(&callback.out_track_id, generation) + .await + { + let _ = dingtalk_card::update_card( + &token, + &callback.out_track_id, + "READY", + "面板已失效", + "面板已失效,请重新发送 menu 打开新的控制面板。", + panel_action_label(action), + ) + .await; + println!( + "[dingtalk-panel] operation_completed generation={generation} reason=context_lost" + ); + } else { + println!( + "[dingtalk-panel] card_update_skipped reason=stale_generation generation={generation}" + ); + } + return; + } + }; + + let running_summary = panel_running_summary(action); + // RUNNING update is the *first* UI update; only emit it if we still + // own the card — if a fresher action already took over, skip. + if !store + .is_card_generation_current(&callback.out_track_id, generation) + .await + { + println!( + "[dingtalk-panel] card_update_skipped state=RUNNING reason=stale_generation generation={generation}" + ); + return; + } + println!("[dingtalk-panel] card_state=running action={action}"); + if let Err(error) = dingtalk_card::update_card( + &token, + &callback.out_track_id, + "RUNNING", + running_summary, + running_summary, + panel_action_label(action), + ) + .await + { + println!( + "[dingtalk-panel] card_running_failed=true reason={}", + safe_error_kind(&error) + ); + return; + } + + let result = match action { + "gateway_status" => execute_gateway_status(&runtime, &context).await, + "recent_jobs" => execute_recent_jobs(&runtime).await, + "monitor_30s" => execute_monitor(&runtime, &context, 30).await, + "monitor_60s" => execute_monitor(&runtime, &context, 60).await, + "help" => execute_help(), + _ => PanelActionResult::failed(action, "操作失败", "不支持的面板操作。".to_string()), + }; + + // BUSY on a monitor means a different monitor guard already owns the + // desktop capture. We refuse to bump the generation here — the + // successful monitor's later success transition still owns the card. + if result.busy { + if store + .is_card_generation_current(&callback.out_track_id, generation) + .await + { + let _ = dingtalk_card::update_card( + &token, + &callback.out_track_id, + "BUSY", + "正在执行其他任务", + &result.card_summary, + panel_action_label(result.action), + ) + .await; + } else { + println!( + "[dingtalk-panel] card_update_skipped state=BUSY reason=stale_generation generation={generation}" + ); + } + println!( + "[dingtalk-panel] operation_completed generation={generation} state=busy" + ); + return; + } + + // Send detailed message. Failure here does NOT rerun the business + // handler later — only the final card update can be retried. + let message_sent = if let Some(message) = result.message_body.as_deref() { + println!("[dingtalk-panel] detailed_reply_send=true"); + match crate::gateway::dingtalk_worker::send_dingtalk_panel_reply( + &runtime, + &context, + message, + ) + .await + { + Ok(()) => { + println!("[dingtalk-panel] detailed_reply_ok=true"); + true + } + Err(error) => { + println!( + "[dingtalk-panel] detailed_reply_ok=false reason={}", + safe_error_kind(&error) + ); + false + } + } + } else { + true + }; + + let card_summary = card_summary_after_delivery(&result, message_sent); + let status_text = if result.success { + "Gateway 与 DingTalk 已连接" + } else { + "上次操作失败" + }; + // Terminal READY update must respect ownership: a fresher action that + // arrived while we were doing async work gets priority. + if !store + .is_card_generation_current(&callback.out_track_id, generation) + .await + { + println!( + "[dingtalk-panel] card_update_skipped state=READY reason=stale_generation generation={generation}" + ); + return; + } + match dingtalk_card::update_card( + &token, + &callback.out_track_id, + "READY", + status_text, + &card_summary, + panel_action_label(result.action), + ) + .await + { + Ok(()) => println!("[dingtalk-panel] card_state=ready card_restored=true"), + Err(error) => println!( + "[dingtalk-panel] card_state=ready card_restored=false reason={}", + safe_error_kind(&error) + ), + } + println!( + "[dingtalk-panel] operation_completed generation={generation} state=ready" + ); +} + +fn card_summary_after_delivery(result: &PanelActionResult, message_sent: bool) -> String { + if result.success && !message_sent { + "操作已完成,但结果消息发送失败".to_string() + } else { + result.card_summary.clone() + } +} + +fn panel_running_summary(action: &str) -> &'static str { + match action { + "gateway_status" => "正在读取 Gateway 状态...", + "recent_jobs" => "正在读取最近任务...", + "monitor_30s" => "正在监控桌面 · 30 秒", + "monitor_60s" => "正在监控桌面 · 60 秒", + "help" => "正在准备帮助说明...", + _ => "正在执行操作...", + } +} + +fn panel_action_label(action: &str) -> &'static str { + let panel = crate::gateway::agent_menu::build_agent_menu_panel(); + panel + .primary_actions + .iter() + .chain(panel.secondary_actions) + .find(|item| item.action == action) + .map(|item| item.label) + .unwrap_or("未知操作") +} + +async fn execute_gateway_status( + runtime: &GatewayRuntime, + context: &crate::gateway::dingtalk_store::DingtalkPanelContext, +) -> PanelActionResult { + println!("[dingtalk-panel] action=gateway_status"); + let worker_ready = runtime.dingtalk_worker_started().await; + let queue_len = runtime.dingtalk_queue_len().await; + let stream_ready = runtime.is_dingtalk_stream_registered(); + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + let app_ready = crate::gateway::resolve_dingtalk_app_key_for_worker(&config, entry).is_some() + && crate::gateway::resolve_dingtalk_secret_for_worker(&config, entry).is_some(); + let reply_target_ready = context.session_webhook.is_some() + || (context.conversation_id.is_some() && context.robot_code.is_some() && app_ready); + let body = format!( + "Gateway 状态\n\nGateway:运行正常\nDingTalk Stream:{}\n消息 Worker:{}\n待处理任务:{}\n消息发送:{}", + if stream_ready { "已连接" } else { "未连接" }, + if worker_ready { "就绪" } else { "未就绪" }, + queue_len, + if reply_target_ready { "正常" } else { "未就绪" } + ); + println!("[dingtalk-panel] business_result=success"); + PanelActionResult::success("gateway_status", "Gateway 状态读取完成", body) +} + +async fn execute_recent_jobs(runtime: &GatewayRuntime) -> PanelActionResult { + println!("[dingtalk-panel] action=recent_jobs"); + let Some(store) = runtime.dingtalk_store() else { + println!("[dingtalk-panel] business_result=failed reason=no_store"); + return PanelActionResult::failed( + "recent_jobs", + "最近任务读取失败", + "最近任务\n\n当前暂无最近任务。".to_string(), + ); + }; + let jobs = store.get_recent_jobs(5).await; + println!( + "[dingtalk-panel] action=recent_jobs jobs_count={}", + jobs.len() + ); + let body = format_recent_jobs_message(&jobs); + println!("[dingtalk-panel] business_result=success"); + PanelActionResult::success("recent_jobs", "最近任务已发送", body) +} + +pub(crate) fn format_recent_jobs_message( + jobs: &[crate::gateway::dingtalk_store::DingtalkJob], +) -> String { + if jobs.is_empty() { + return "最近任务\n\n当前暂无最近任务。".to_string(); + } + let mut lines = vec!["最近任务".to_string(), String::new()]; + for (index, job) in jobs.iter().take(5).enumerate() { + let command = crate::gateway::dingtalk_commands::to_normalized_for_match(&job.inbound.text); + let label = match crate::gateway::dingtalk_commands::parse_dingtalk_command(&command) { + Some(crate::gateway::dingtalk_commands::DingtalkCommand::Status) => "Gateway 状态", + Some(crate::gateway::dingtalk_commands::DingtalkCommand::Monitor) => "桌面监控", + Some(crate::gateway::dingtalk_commands::DingtalkCommand::Help) + | Some(crate::gateway::dingtalk_commands::DingtalkCommand::Menu) => "帮助菜单", + Some(crate::gateway::dingtalk_commands::DingtalkCommand::Ping) => "连接检查", + None => "DingTalk 任务", + }; + let status = match job.status { + crate::gateway::dingtalk_store::JobStatus::Received => "等待中", + crate::gateway::dingtalk_store::JobStatus::Processing => "执行中", + crate::gateway::dingtalk_store::JobStatus::Completed => "成功", + crate::gateway::dingtalk_store::JobStatus::Failed => "失败", + }; + let timestamp = format!("Unix {}", job.created_at); + lines.push(format!("{}. {}", index + 1, label)); + lines.push(format!(" 状态:{status}")); + lines.push(format!(" 时间:{timestamp}")); + lines.push(String::new()); + } + lines.join("\n").trim_end().to_string() +} + +fn execute_help() -> PanelActionResult { + println!("[dingtalk-panel] action=help"); + // Shared with Feishu: help content is the cross-channel help text. + let body = crate::gateway::feishu_worker::help_reply(); + println!("[dingtalk-panel] business_result=success"); + PanelActionResult::success("help", "帮助说明已发送", body) +} + +async fn execute_monitor( + runtime: &GatewayRuntime, + context: &crate::gateway::dingtalk_store::DingtalkPanelContext, + duration_secs: u64, +) -> PanelActionResult { + let action = if duration_secs == 30 { + "monitor_30s" + } else { + "monitor_60s" + }; + println!("[dingtalk-panel] action={action} state=running"); + + // Note: the primary admission check (DingtalkMonitorGuard) is done at the + // top of process_panel_action before this function is called. + // The DingtalkMonitorGuard lease (if acquired) is held until process_panel_action returns. + // This function receives a mutable reference but does NOT release the lease early. + + let captures_dir = directories::ProjectDirs::from("com", "omninova", "OmniNova") + .map(|dirs| dirs.config_dir().join("captures")) + .unwrap_or_else(|| std::env::temp_dir().join("omninova-captures")); + let result = crate::desktop_capture::monitor_desktop(&captures_dir, duration_secs).await; + println!("[dingtalk-panel] action={action} state=completed"); + + panel_monitor_result(action, duration_secs, &result) +} + +fn panel_monitor_result( + action: &'static str, + duration_secs: u64, + result: &crate::desktop_capture::MonitorResult, +) -> PanelActionResult { + if result.ok { + println!("[dingtalk-panel] action={action} business_result=success"); + let changed = result.changed.unwrap_or(false); + let detail = if changed { + "检测到桌面变化" + } else { + "未检测到明显变化" + }; + PanelActionResult::success( + action, + format!("桌面监控完成 · {duration_secs} 秒"), + format!( + "桌面监控完成\n\n监控时长:{duration_secs} 秒\n结果:{detail}" + ), + ) + } else { + println!("[dingtalk-panel] action={action} business_result=failed"); + let reason = result + .error_code + .as_deref() + .map(safe_error_kind) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "capture_failed".to_string()); + PanelActionResult::failed( + action, + "桌面监控失败", + format!("桌面监控失败\n\n错误:{reason}"), + ) + } +} + +pub fn parse_card_callback_envelope(envelope: &Value) -> Result { + let data = envelope + .get("data") + .ok_or_else(|| "missing_callback_data".to_string())?; + let request: Value = match data { + Value::String(data) => { + serde_json::from_str(data).map_err(|_| "invalid_callback_data_json".to_string())? + } + Value::Object(_) => data.clone(), + _ => return Err("invalid_callback_data_type".to_string()), + }; + let out_track_id = request + .get("outTrackId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "missing_out_track_id".to_string())? + .to_string(); + let user_id = request + .get("userId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let space_id = request + .get("spaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let content = request + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "missing_content".to_string())?; + let content: Value = + serde_json::from_str(content).map_err(|_| "invalid_content_json".to_string())?; + let params = content + .get("cardPrivateData") + .and_then(|value| value.get("params")) + .ok_or_else(|| "missing_card_private_params".to_string())?; + let action = params + .get("action") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "missing_action".to_string())? + .to_string(); + let callback_id = ["callbackId", "messageId", "eventId"] + .iter() + .find_map(|key| request.get(*key).and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + Ok(ParsedCardCallback { + out_track_id, + action, + callback_id, + user_id, + space_id, + }) +} + +pub fn callback_dedupe_key( + callback_id: Option<&str>, + out_track_id: &str, + action: &str, + callback_fingerprint: &str, +) -> String { + match callback_id.map(str::trim).filter(|value| !value.is_empty()) { + Some(callback_id) => format!("callback:{callback_id}:{action}"), + None => format!( + "fallback:{}:{}:{}", + opaque_short_hash(out_track_id), + action, + opaque_short_hash(callback_fingerprint) + ), + } +} + +pub fn build_stream_ack(envelope: &Value, data: Value) -> Value { + let message_id = envelope + .get("headers") + .and_then(|headers| headers.get("messageId")) + .and_then(Value::as_str) + .unwrap_or_default(); + serde_json::json!({ + "code": 200, + "headers": { + "contentType": "application/json", + "messageId": message_id + }, + "message": "OK", + "data": data.to_string() + }) +} + +pub fn is_allowed_action(action: &str) -> bool { + canonical_agent_menu_action(action).is_some() +} + +fn reconnect_delay(attempt: u32) -> Duration { + Duration::from_secs(2u64.saturating_pow(attempt.min(4)).min(30)) +} + +fn safe_action(action: &str) -> String { + action + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + .take(48) + .collect() +} + +fn safe_json_scalar(value: &Value) -> String { + safe_action(match value { + Value::String(value) => value, + _ => "unknown", + }) +} + +fn safe_error_kind(error: &str) -> String { + error + .split([':', '=']) + .next() + .unwrap_or("unknown") + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + .take(64) + .collect() +} + +/// Public wrapper for opaque_short_hash to be used in tests +pub fn public_opaque_short_hash(value: &str) -> String { + opaque_short_hash(value) +} + +fn opaque_short_hash(value: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..6]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_stream_worker_starts_only_for_stream_transport() { + assert!(!should_start_card_stream(true, DingtalkTransportMode::Http)); + assert!(should_start_card_stream( + true, + DingtalkTransportMode::Stream + )); + assert!(!should_start_card_stream( + false, + DingtalkTransportMode::Stream + )); + } + + fn callback_envelope(content: Value) -> Value { + serde_json::json!({ + "type": "CALLBACK", + "headers": { + "messageId": "message-secret", + "topic": DINGTALK_CARD_CALLBACK_TOPIC + }, + "data": serde_json::json!({ + "outTrackId": "track-secret", + "userId": "user-secret", + "spaceId": "space-secret", + "content": content.to_string() + }).to_string() + }) + } + + #[test] + fn parses_gateway_status_from_string_content() { + let parsed = parse_card_callback_envelope(&callback_envelope(serde_json::json!({ + "cardPrivateData": { "params": { "action": "gateway_status" } } + }))) + .unwrap(); + assert_eq!(parsed.action, "gateway_status"); + assert_eq!(parsed.out_track_id, "track-secret"); + assert_eq!(parsed.user_id.as_deref(), Some("user-secret")); + assert_eq!(parsed.space_id.as_deref(), Some("space-secret")); + } + + #[test] + fn missing_or_invalid_callback_fields_are_rejected() { + let cases = [ + serde_json::json!({}), + serde_json::json!({ "data": 1 }), + serde_json::json!({ "data": "not-json" }), + serde_json::json!({ "data": { "outTrackId": "track", "content": "{}" } }), + ]; + for value in cases { + assert!(parse_card_callback_envelope(&value).is_err()); + } + } + + #[test] + fn malformed_content_and_params_never_panic() { + let invalid_content = serde_json::json!({ + "data": serde_json::json!({ + "outTrackId": "track", + "content": "not-json" + }).to_string() + }); + assert_eq!( + parse_card_callback_envelope(&invalid_content).unwrap_err(), + "invalid_content_json" + ); + + let params_not_object = callback_envelope(serde_json::json!({ + "cardPrivateData": { "params": "not-an-object" } + })); + assert_eq!( + parse_card_callback_envelope(¶ms_not_object).unwrap_err(), + "missing_action" + ); + } + + #[test] + fn allowlist_accepts_all_canonical_actions() { + // All canonical actions should be allowed + for action in [ + "gateway_status", + "monitor_30s", + "monitor_60s", + "recent_jobs", + "help", + ] { + assert!(is_allowed_action(action), "action {} should be allowed", action); + } + } + + #[test] + fn allowlist_rejects_unknown_actions() { + for action in [ + "file_delete", + "exec", + "rm_rf", + "evil", + "", + ] { + assert!(!is_allowed_action(action), "action {} should be rejected", action); + } + } + + #[test] + fn ack_correlates_message_without_exposing_it_as_business_data() { + let envelope = callback_envelope(serde_json::json!({})); + let ack = build_stream_ack(&envelope, serde_json::json!({ "response": {} })); + assert_eq!(ack["code"], 200); + assert_eq!(ack["headers"]["messageId"], "message-secret"); + assert!(ack["data"].as_str().unwrap().contains("response")); + } + + #[test] + fn reconnect_backoff_is_bounded() { + assert_eq!(reconnect_delay(0), Duration::from_secs(1)); + assert_eq!(reconnect_delay(1), Duration::from_secs(2)); + assert_eq!(reconnect_delay(99), Duration::from_secs(16)); + } + + #[test] + fn dedupe_cache_allows_first_insert_sync() { + let cache = CallbackDedupeCache::new(100); + let key = "test-track:gateway_status"; + assert!(cache.try_insert(key)); + } + + #[test] + fn dedupe_cache_rejects_duplicate_sync() { + let cache = CallbackDedupeCache::new(100); + let key = "test-track:gateway_status"; + assert!(cache.try_insert(key)); + assert!(!cache.try_insert(key)); + } + + #[test] + fn dedupe_cache_different_keys_allowed_sync() { + let cache = CallbackDedupeCache::new(100); + assert!(cache.try_insert("track1:gateway_status")); + assert!(cache.try_insert("track2:gateway_status")); + assert!(cache.try_insert("track1:recent_jobs")); + assert!(cache.try_insert("track3:monitor_30s")); + } + + #[test] + fn callback_id_dedupes_retry_but_allows_a_later_click() { + let first = callback_dedupe_key( + Some("callback-1"), + "same-track", + "gateway_status", + "ignored", + ); + let retry = callback_dedupe_key( + Some("callback-1"), + "same-track", + "gateway_status", + "ignored-again", + ); + let later_click = callback_dedupe_key( + Some("callback-2"), + "same-track", + "gateway_status", + "ignored", + ); + assert_eq!(first, retry); + assert_ne!(first, later_click); + } + + #[test] + fn opaque_short_hash_produces_short_deterministic_output() { + let hash1 = opaque_short_hash("secret-outtrack:gateway_status"); + let hash2 = opaque_short_hash("secret-outtrack:gateway_status"); + let hash3 = opaque_short_hash("different:action"); + assert_eq!(hash1, hash2); + assert_ne!(hash1, hash3); + assert_eq!(hash1.len(), 12); + assert!(!hash1.contains("secret")); + } + + #[test] + fn all_canonical_actions_are_allowed() { + let allowed = ["gateway_status", "monitor_30s", "monitor_60s", "recent_jobs", "help"]; + for action in allowed { + assert!(is_allowed_action(action), "action {} should be allowed", action); + } + } + + #[test] + fn gateway_status_result_separates_card_summary_from_message() { + let result = PanelActionResult::success( + "gateway_status", + "Gateway 状态读取完成", + "Gateway 状态\n\nGateway:运行正常".to_string(), + ); + assert_eq!(result.card_summary, "Gateway 状态读取完成"); + assert!(result.message_body.as_deref().unwrap().contains("Gateway:运行正常")); + assert!(!result.card_summary.contains("运行正常")); + } + + #[test] + fn recent_jobs_empty_message_is_explicit() { + assert_eq!( + format_recent_jobs_message(&[]), + "最近任务\n\n当前暂无最近任务。" + ); + } + + #[test] + fn recent_jobs_message_is_limited_and_redacted() { + let jobs = (0..6) + .map(|index| crate::gateway::dingtalk_store::DingtalkJob { + job_id: format!("secret-job-{index}"), + inbound: crate::channels::InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("secret-user".to_string()), + session_id: Some("secret-conversation".to_string()), + text: "status".to_string(), + metadata: Default::default(), + }, + status: crate::gateway::dingtalk_store::JobStatus::Completed, + created_at: index, + updated_at: index, + error_message: None, + }) + .collect::>(); + let message = format_recent_jobs_message(&jobs); + assert_eq!(message.matches("Gateway 状态").count(), 5); + for secret in ["secret-job", "secret-user", "secret-conversation"] { + assert!(!message.contains(secret)); + } + } + + #[test] + fn dingtalk_help_action_executes_with_feishu_content() { + let result = execute_help(); + assert_eq!(result.card_summary, "帮助说明已发送"); + let body = result.message_body.unwrap(); + assert_eq!(body, crate::gateway::feishu_worker::help_reply()); + assert!(body.contains("OmniNova Agent 使用帮助")); + assert!(body.contains("/monitor")); + } + + fn panel_test_context() -> crate::gateway::dingtalk_store::DingtalkPanelContext { + crate::gateway::dingtalk_store::DingtalkPanelContext::new( + "test-track".to_string(), + Some("test-conversation".to_string()), + Some("test-robot".to_string()), + Some("https://example.invalid/session-webhook".to_string()), + Some("test-user".to_string()), + Some("test-space".to_string()), + crate::gateway::dingtalk_store::now_for_tests(), + ) + } + + #[tokio::test] + async fn dingtalk_gateway_status_action_executes() { + let runtime = GatewayRuntime::new(crate::config::Config::default()); + let result = execute_gateway_status(&runtime, &panel_test_context()).await; + assert!(result.success); + assert_eq!(result.action, "gateway_status"); + assert!(result.message_body.as_deref().is_some_and(|body| body.contains("Gateway"))); + } + + #[tokio::test] + async fn dingtalk_recent_jobs_empty_is_not_error() { + let runtime = GatewayRuntime::new(crate::config::Config::default()); + assert!(runtime + .dingtalk_store + .set(Arc::new(crate::gateway::dingtalk_store::DingtalkStore::new())) + .is_ok()); + let result = execute_recent_jobs(&runtime).await; + assert!(result.success); + assert_eq!(result.action, "recent_jobs"); + assert!(result + .message_body + .as_deref() + .is_some_and(|body| body.contains("暂无最近任务"))); + } + + #[tokio::test] + async fn dingtalk_recent_jobs_action_executes_with_real_store_data() { + let runtime = GatewayRuntime::new(crate::config::Config::default()); + let store = Arc::new(crate::gateway::dingtalk_store::DingtalkStore::new()); + assert!(runtime.dingtalk_store.set(store.clone()).is_ok()); + store + .store_inbound(crate::gateway::dingtalk_store::DingtalkJob { + job_id: "private-job-id".to_string(), + inbound: crate::channels::InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("private-user-id".to_string()), + session_id: Some("private-conversation-id".to_string()), + text: "status".to_string(), + metadata: Default::default(), + }, + status: crate::gateway::dingtalk_store::JobStatus::Completed, + created_at: 100, + updated_at: 100, + error_message: None, + }) + .await; + + let result = execute_recent_jobs(&runtime).await; + let body = result.message_body.expect("recent jobs detail"); + assert!(result.success); + assert!(body.contains("Gateway 状态")); + for secret in ["private-job-id", "private-user-id", "private-conversation-id"] { + assert!(!body.contains(secret)); + } + } + + #[test] + fn dingtalk_monitor_30s_action_executes_with_shared_monitor_result() { + let monitor = crate::desktop_capture::MonitorResult::success_no_detection( + 30, + 30_000, + crate::desktop_capture::CaptureResult::success( + "start.png".to_string(), + 1, + 1, + 1, + "start".to_string(), + ), + crate::desktop_capture::CaptureResult::success( + "end.png".to_string(), + 1, + 1, + 1, + "end".to_string(), + ), + ); + let result = panel_monitor_result("monitor_30s", 30, &monitor); + assert_eq!(result.card_summary, "桌面监控完成 · 30 秒"); + assert!(result.message_body.as_deref().unwrap().contains("监控时长:30 秒")); + assert!(!result.card_summary.contains("监控时长")); + } + + #[test] + fn dingtalk_monitor_60s_action_executes_with_shared_monitor_result() { + let monitor = crate::desktop_capture::MonitorResult::success_no_detection( + 60, + 60_000, + crate::desktop_capture::CaptureResult::success( + "start.png".to_string(), + 1, + 1, + 1, + "start".to_string(), + ), + crate::desktop_capture::CaptureResult::success( + "end.png".to_string(), + 1, + 1, + 1, + "end".to_string(), + ), + ); + let result = panel_monitor_result("monitor_60s", 60, &monitor); + assert!(result.success); + assert_eq!(result.action, "monitor_60s"); + assert!(result.card_summary.contains("60 秒")); + } + + #[test] + fn dingtalk_monitor_failure_result_is_safe() { + let monitor = crate::desktop_capture::MonitorResult::failure( + 30, + 12, + "capture_failed", + "sensitive platform detail", + ); + let result = panel_monitor_result("monitor_30s", 30, &monitor); + assert!(!result.success); + assert!(!result.busy); + assert_eq!(result.action, "monitor_30s"); + let body = result.message_body.expect("failure detail"); + assert!(body.contains("capture_failed")); + assert!(!body.contains("sensitive platform detail")); + } + + async fn wait_for_monitor_reacquire( + guard: &Arc, + track: &str, + ) -> String { + for _ in 0..50 { + if let Some(owner) = guard.try_acquire(track).await { + return owner; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("monitor guard was not released for {track}"); + } + + #[tokio::test] + async fn dingtalk_monitor_guard_releases_after_failure() { + let guard = Arc::new(crate::gateway::DingtalkMonitorGuard::new()); + let owner_id = guard.try_acquire("failure-track").await.expect("first lease"); + let lease = DingtalkMonitorLease { + guard: guard.clone(), + out_track_id: "failure-track".to_string(), + owner_id, + }; + + let failed = crate::desktop_capture::MonitorResult::failure( + 30, + 1, + "capture_failed", + "capture failed", + ); + assert!(!panel_monitor_result("monitor_30s", 30, &failed).success); + drop(lease); + + let replacement = wait_for_monitor_reacquire(&guard, "failure-track").await; + assert!(guard.release("failure-track", &replacement).await); + } + + #[tokio::test] + async fn dingtalk_monitor_guard_releases_after_cancellation() { + let guard = Arc::new(crate::gateway::DingtalkMonitorGuard::new()); + let guard_for_task = guard.clone(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let owner_id = guard_for_task + .try_acquire("cancel-track") + .await + .expect("first lease"); + let _lease = DingtalkMonitorLease { + guard: guard_for_task, + out_track_id: "cancel-track".to_string(), + owner_id, + }; + let _ = ready_tx.send(()); + std::future::pending::<()>().await; + }); + ready_rx.await.expect("lease acquired"); + task.abort(); + let _ = task.await; + + let replacement = wait_for_monitor_reacquire(&guard, "cancel-track").await; + assert!(guard.release("cancel-track", &replacement).await); + } + + #[test] + fn busy_monitor_never_has_a_detailed_message() { + let result = PanelActionResult::busy("monitor_60s"); + assert!(result.busy); + assert!(result.message_body.is_none()); + } + + #[test] + fn completed_business_with_send_failure_is_not_marked_business_failed() { + let result = PanelActionResult::success( + "help", + "帮助说明已发送", + "detail".to_string(), + ); + assert!(result.success); + assert_eq!( + card_summary_after_delivery(&result, false), + "操作已完成,但结果消息发送失败" + ); + } +} diff --git a/crates/omninova-core/src/gateway/dingtalk_commands.rs b/crates/omninova-core/src/gateway/dingtalk_commands.rs new file mode 100644 index 00000000..4c479ae6 --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_commands.rs @@ -0,0 +1,357 @@ +//! DingTalk text command router (Phase 2). +//! +//! Commands are short, deterministic phrases that the DingTalk worker +//! handles directly without going through the Agent. Non-command text +//! continues to flow into the Agent exactly like Phase 1. +//! +//! Supported (case-insensitive, after `@bot` mention stripping): +//! - `help`, `帮助` -> shared Agent menu +//! - `menu`, `菜单`, `panel`, `面板` -> shared Agent menu +//! - `status`, `状态` -> Status (redacted: no secrets/tokens/IDs) +//! - `ping` -> Ping +//! - `monitor` -> explicitly unsupported in Phase 2 +//! +//! All command variants also accept a leading `/` (e.g. `/help`). + +use crate::config::Config; +use crate::gateway::agent_menu::render_agent_menu_as_dingtalk_text; + +/// All commands the DingTalk worker understands in Phase 2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DingtalkCommand { + Help, + Menu, + Status, + Ping, + /// `monitor` and variants. Always replies with a not-available notice + /// rather than calling half-baked internals. + Monitor, +} + +impl DingtalkCommand { + pub fn name(self) -> &'static str { + match self { + DingtalkCommand::Help => "help", + DingtalkCommand::Menu => "menu", + DingtalkCommand::Status => "status", + DingtalkCommand::Ping => "ping", + DingtalkCommand::Monitor => "monitor", + } + } + + /// Menu/help commands prefer the DingTalk ActionCard delivery path. The + /// worker retains the structured text reply as a fallback. + pub fn prefers_menu_card(self) -> bool { + matches!(self, DingtalkCommand::Help | DingtalkCommand::Menu) + } +} + +/// Strip a leading `@bot` mention from a DingTalk text message so that +/// commands work whether or not the user invoked the bot explicitly. +/// +/// DingTalk group messages look like `"@机器人名 hello"` (the bot-name +/// token may contain spaces / Chinese characters). For Phase 2 we strip +/// a single leading `@…` token followed by optional whitespace. +/// +/// `raw_text` : the `text` field from the inbound payload as it is +/// currently set on `InboundMessage.text`. +/// `payload` : the parsed JSON payload; we look at +/// `text.content` (the real DingTalk shape) if present and +/// fall back to `raw_text`. +pub fn normalize_dingtalk_command_text( + raw_text: &str, + payload: Option<&serde_json::Value>, +) -> String { + // Prefer the real DingTalk `text.content` field if we have the raw + // payload. Phase 1 already copies the raw_payload into metadata, so + // tests can pass it through without depending on the optional fix. + let text_owned; + let text = if let Some(p) = payload { + if let Some(content) = p + .get("text") + .and_then(|v| v.get("content")) + .and_then(|v| v.as_str()) + { + text_owned = content.to_string(); + &text_owned + } else if let Some(s) = p.get("text").and_then(|v| v.as_str()) { + text_owned = s.to_string(); + &text_owned + } else { + raw_text + } + } else { + raw_text + }; + + strip_bot_mention(text).trim().to_string() +} + +/// Strip a leading `@ ` from the input. This is deliberately +/// conservative: only one mention is removed, only at the start, and +/// the mention token may include Chinese characters. +/// +/// Rules: +/// - If the string does not start with `@`, return it unchanged (after +/// trim). +/// - If the string is exactly `@` or `@`, return empty. +/// - If the string is `@` with no whitespace after, return empty +/// (the user typed only the mention). +/// - If the string is `@ ` (token followed by whitespace), +/// return `` trimmed. +pub fn strip_bot_mention(text: &str) -> String { + let trimmed = text.trim(); + let rest = match trimmed.strip_prefix('@') { + Some(r) => r, + None => return trimmed.to_string(), + }; + + // Everything up to (but not including) the first whitespace char is + // the mention token. If there is no whitespace at all, the user + // typed only the mention — return empty. + match rest.find(char::is_whitespace) { + Some(idx) => rest[idx..].trim().to_string(), + None => String::new(), + } +} + +/// Normalize the input text for command matching: case-fold the ASCII +/// portion (Chinese characters are unaffected), trim, and drop a single +/// leading `/`. We do **not** mutate the user's text for echoing — +/// `parse_dingtalk_command` operates on this normalized view only. +pub fn to_normalized_for_match(text: &str) -> String { + let mut s = text.trim().to_string(); + if let Some(rest) = s.strip_prefix('/') { + s = rest.trim().to_string(); + } + // ASCII case-fold; non-ASCII stays as-is. + s.to_ascii_lowercase() +} + +/// Try to interpret `normalized` text as a DingTalk command. Returns +/// `None` for anything we do not recognize — that means "let the Agent +/// handle it." +pub fn parse_dingtalk_command(normalized: &str) -> Option { + match normalized { + "help" | "帮助" | "?" => Some(DingtalkCommand::Help), + "menu" | "菜单" | "panel" | "面板" => Some(DingtalkCommand::Menu), + "status" | "状态" => Some(DingtalkCommand::Status), + "ping" | "pong" => Some(DingtalkCommand::Ping), + "monitor" => Some(DingtalkCommand::Monitor), + _ => None, + } +} + +/// Reply text for `/help`. Public so tests can assert on the exact +/// wording and so the integration path doesn't depend on a hidden +/// format. +pub fn build_dingtalk_help_text() -> String { + render_agent_menu_as_dingtalk_text() +} + +/// Reply text for `/menu`. The menu mirrors the help list so users on +/// either entry point see the same set of commands. +pub fn build_dingtalk_menu_text() -> String { + render_agent_menu_as_dingtalk_text() +} + +/// Inputs needed to render status without leaking secrets. +#[derive(Debug, Clone, Copy)] +pub struct DingtalkStatusInputs<'a> { + pub config: &'a Config, + /// Whether the async DingTalk worker has been initialized. + pub worker_initialized: bool, + /// Current backlog of the async job queue (0 when worker is off). + pub queue_len: usize, +} + +/// Render the `/status` reply. **Must not** contain any of: +/// app_secret, app_key, robot_code, access_token, sessionWebhook, +/// senderStaffId, conversationId, messageId, msgId. +/// +/// Allowed tokens: `enabled`, `initialized`, `configured`, `present`, +/// `count`, `true`, `false`, ``. +pub fn build_dingtalk_status_text(inputs: DingtalkStatusInputs<'_>) -> String { + let cfg = inputs.config; + let dt = &cfg.gateway.dingtalk; + + let gateway_enabled = dt.enabled; + let legacy_channel_enabled = cfg + .channels_config + .dingtalk + .as_ref() + .map(|e| e.enabled) + .unwrap_or(false); + + let app_key_present = !dt.app_key.trim().is_empty(); + let app_secret_present = !dt.app_secret.trim().is_empty(); + let robot_code_present = !dt.robot_code.trim().is_empty(); + + let status = if gateway_enabled { + "enabled" + } else { + "disabled" + }; + + format!( + "DingTalk bot status\n\ + \n\ + gateway.dingtalk.enabled: {}\n\ + channels.dingtalk.enabled: {}\n\ + app_key: {}\n\ + app_secret: {}\n\ + robot_code: {}\n\ + worker_initialized: {}\n\ + queue_count: {}\n\ + outbound_mode: {}\n\ + redact_sensitive_logs: {}\n\ + \n\ + (no secrets, tokens, ids, or webhook URLs are shown)", + status, + if legacy_channel_enabled { + "true" + } else { + "false" + }, + if app_key_present { "present" } else { "absent" }, + if app_secret_present { + "present" + } else { + "absent" + }, + if robot_code_present { + "present" + } else { + "absent" + }, + if inputs.worker_initialized { + "true" + } else { + "false" + }, + inputs.queue_len, + dt.outbound_mode, + if dt.redact_sensitive_logs { + "true" + } else { + "false" + }, + ) +} + +/// Reply text for `/ping`. +pub fn build_dingtalk_ping_text() -> String { + "pong".to_string() +} + +/// Reply text for `/monitor`. Phase 2 deliberately returns a +/// not-available notice rather than half-implementing the feature. +pub fn build_dingtalk_monitor_text() -> String { + "DingTalk monitor is not available in this phase.".to_string() +} + +/// One-stop helper used by both the async worker path and the sync +/// fallback: parse the command and return the reply text, or +/// `None` if the message is not a command (must be forwarded to the +/// agent unchanged). +/// +/// `raw_text` is the `InboundMessage.text` value as set by Phase 1. +/// `payload` is the parsed JSON body of the callback (the helper +/// prefers the real `text.content` field when present). `inputs` +/// supplies the live Config + worker state for `/status` rendering. +pub fn evaluate_dingtalk_command( + raw_text: &str, + payload: Option<&serde_json::Value>, + inputs: DingtalkStatusInputs<'_>, +) -> Option<(DingtalkCommand, String)> { + let text_for_match = normalize_dingtalk_command_text(raw_text, payload); + let normalized = to_normalized_for_match(&text_for_match); + let cmd = parse_dingtalk_command(&normalized)?; + let reply = match cmd { + DingtalkCommand::Help => build_dingtalk_help_text(), + DingtalkCommand::Menu => build_dingtalk_menu_text(), + DingtalkCommand::Status => build_dingtalk_status_text(inputs), + DingtalkCommand::Ping => build_dingtalk_ping_text(), + DingtalkCommand::Monitor => build_dingtalk_monitor_text(), + }; + Some((cmd, reply)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn empty_inputs<'a>(cfg: &'a Config) -> DingtalkStatusInputs<'a> { + DingtalkStatusInputs { + config: cfg, + worker_initialized: false, + queue_len: 0, + } + } + + #[test] + fn strip_bot_mention_basic() { + assert_eq!(strip_bot_mention("@bot help"), "help"); + assert_eq!(strip_bot_mention("@机器人菜单"), ""); + assert_eq!(strip_bot_mention(" @bot menu "), "menu"); + } + + #[test] + fn strip_bot_mention_no_mention() { + assert_eq!(strip_bot_mention("hello"), "hello"); + assert_eq!(strip_bot_mention(" no mention "), "no mention"); + } + + #[test] + fn to_normalized_for_match_lowercases_and_strips_slash() { + assert_eq!(to_normalized_for_match("HELP"), "help"); + assert_eq!(to_normalized_for_match("/help"), "help"); + assert_eq!(to_normalized_for_match(" /Ping "), "ping"); + } + + #[test] + fn parse_dingtalk_command_matches_known_aliases() { + assert_eq!(parse_dingtalk_command("help"), Some(DingtalkCommand::Help)); + assert_eq!(parse_dingtalk_command("菜单"), Some(DingtalkCommand::Menu)); + assert_eq!(parse_dingtalk_command("panel"), Some(DingtalkCommand::Menu)); + assert_eq!(parse_dingtalk_command("面板"), Some(DingtalkCommand::Menu)); + assert_eq!( + parse_dingtalk_command("status"), + Some(DingtalkCommand::Status) + ); + assert_eq!( + parse_dingtalk_command("状态"), + Some(DingtalkCommand::Status) + ); + assert_eq!(parse_dingtalk_command("ping"), Some(DingtalkCommand::Ping)); + assert_eq!( + parse_dingtalk_command("monitor"), + Some(DingtalkCommand::Monitor) + ); + assert_eq!(parse_dingtalk_command("anything else"), None); + } + + #[test] + fn command_normalization_handles_mentions_spaces_and_newlines() { + let payload = serde_json::json!({ + "text": { "content": " @OmniNova\n /STATUS \r\n" } + }); + let normalized = normalize_dingtalk_command_text("ignored", Some(&payload)); + assert_eq!(to_normalized_for_match(&normalized), "status"); + + let chinese = serde_json::json!({ + "text": { "content": "@机器人\n帮助" } + }); + let normalized = normalize_dingtalk_command_text("ignored", Some(&chinese)); + assert_eq!( + parse_dingtalk_command(&to_normalized_for_match(&normalized)), + Some(DingtalkCommand::Help) + ); + + assert_eq!( + parse_dingtalk_command(&to_normalized_for_match(" \n /ping \r\n")), + Some(DingtalkCommand::Ping) + ); + } +} diff --git a/crates/omninova-core/src/gateway/dingtalk_store.rs b/crates/omninova-core/src/gateway/dingtalk_store.rs new file mode 100644 index 00000000..a46b9424 --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_store.rs @@ -0,0 +1,1021 @@ +//! DingTalk job store for tracking inbound events and responses. +//! +//! Job records remain in memory. Advanced-card panel routing context is persisted +//! in the existing state database so callbacks can safely find their conversation. + +use crate::channels::InboundMessage; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use tokio::sync::RwLock; + +const PANEL_CONTEXT_TTL_SECS: u64 = 24 * 60 * 60; + +/// Job status +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum JobStatus { + Received, + Processing, + Completed, + Failed, +} + +/// A tracked DingTalk job +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DingtalkJob { + pub job_id: String, + pub inbound: InboundMessage, + pub status: JobStatus, + pub created_at: u64, + pub updated_at: u64, + pub error_message: Option, +} + +/// Reply context retained for an Advanced Card panel. +/// +/// The webhook and platform identifiers are intentionally excluded from the +/// custom `Debug` output. They are only used to route a later card action back +/// to the conversation that created the panel. +/// +/// `session_webhook` is a sensitive routing credential persisted in plaintext +/// for SQLite. Introducing Windows Credential Manager / encryption is a +/// separate Security Phase; the field is intentionally labelled so any future +/// leak detector immediately spots it. +#[derive(Clone)] +pub struct DingtalkPanelContext { + pub out_track_id: String, + pub conversation_id: Option, + pub robot_code: Option, + pub session_webhook: Option, + pub user_id: Option, + pub space_id: Option, + pub created_at: u64, + /// Sliding-TTL anchor: every successful lookup / action refreshes this to + /// `now`, so an actively used panel never expires unexpectedly. + pub last_touched_at: u64, +} + +impl DingtalkPanelContext { + /// Construct a new context with `created_at == last_touched_at`. + pub fn new( + out_track_id: String, + conversation_id: Option, + robot_code: Option, + session_webhook: Option, + user_id: Option, + space_id: Option, + created_at: u64, + ) -> Self { + Self { + out_track_id, + conversation_id, + robot_code, + session_webhook, + user_id, + space_id, + created_at, + last_touched_at: created_at, + } + } +} + +impl std::fmt::Debug for DingtalkPanelContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DingtalkPanelContext") + .field("out_track_id_present", &!self.out_track_id.is_empty()) + .field("conversation_id_present", &self.conversation_id.is_some()) + .field("robot_code_present", &self.robot_code.is_some()) + .field("session_webhook_present", &self.session_webhook.is_some()) + .field("user_id_present", &self.user_id.is_some()) + .field("space_id_present", &self.space_id.is_some()) + .field("created_at", &self.created_at) + .field("last_touched_at", &self.last_touched_at) + .finish() + } +} + +/// Reason a panel lookup failed — distinguishes "never seen" from "TTL elapsed". +/// Used to emit distinct logs and to provide a stable UX message. +#[derive(Debug, Clone)] +pub enum PanelContextLookup { + Hit(DingtalkPanelContext), + Missing, + Expired, +} + +impl PanelContextLookup { + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit(_)) + } + pub fn is_expired(&self) -> bool { + matches!(self, Self::Expired) + } + pub fn is_missing(&self) -> bool { + matches!(self, Self::Missing) + } +} + +/// Outcome of attempting to claim the Card-state generation for an `out_track_id`. +/// +/// The generation lives in store memory and is rebuilt from the action log on +/// restart; the first call after restart always returns Generation(1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelOperationClaim { + /// Caller owns this generation; updates may proceed until `current_generation` + /// changes. + Owner { generation: u64 }, + /// A newer action has already taken ownership. `current` is the generation + /// the caller would need to wait for. Caller MUST NOT issue card updates. + Stale { current: u64 }, +} + +/// In-memory DingTalk job store +#[derive(Clone)] +pub struct DingtalkStore { + jobs: Arc>>, + panel_contexts: Arc>>, + panel_db: Option>>, + /// Per-`out_track_id` monotonically increasing ownership generation. + /// Lets concurrent card actions detect stale completions and refuse to + /// clobber an in-flight owner with a newer action's terminal state. + card_generations: Arc>>, +} + +impl Default for DingtalkStore { + fn default() -> Self { + Self::new() + } +} + +impl DingtalkStore { + pub fn new() -> Self { + Self { + jobs: Arc::new(RwLock::new(HashMap::new())), + panel_contexts: Arc::new(RwLock::new(HashMap::new())), + panel_db: None, + card_generations: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Open the runtime-scoped DingTalk store in the existing `state.sqlite`. + /// The table is deliberately isolated from Feishu's schema. + pub fn open(config_dir: &Path) -> Result { + std::fs::create_dir_all(config_dir) + .map_err(|_| "dingtalk_store_create_dir_failed".to_string())?; + let connection = Connection::open(config_dir.join("state.sqlite")) + .map_err(|_| "dingtalk_store_open_failed".to_string())?; + // Schema migrations are deliberately split into individual + // statements so that an existing on-disk database with the + // pre-S1 schema (no `last_touched_at` column) can still be + // upgraded without rolling back the entire `open()` call. + connection + .execute( + "CREATE TABLE IF NOT EXISTS dingtalk_panel_contexts ( + out_track_id TEXT PRIMARY KEY, + conversation_id TEXT, + robot_code TEXT, + session_webhook TEXT, + user_id TEXT, + space_id TEXT, + created_at INTEGER NOT NULL, + last_touched_at INTEGER NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|_| "dingtalk_store_migration_failed".to_string())?; + + // `ALTER TABLE ... ADD COLUMN` errors if the column already exists. + // Use `pragma_table_info` to detect whether the upgrade has run. + let column_present: bool = connection + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('dingtalk_panel_contexts') + WHERE name = 'last_touched_at'", + [], + |row| { + let count: i64 = row.get(0)?; + Ok(count > 0) + }, + ) + .unwrap_or(false); + if !column_present { + // Tolerate "duplicate column name" in case the pragma check + // raced with another writer. + let _ = connection.execute( + "ALTER TABLE dingtalk_panel_contexts + ADD COLUMN last_touched_at INTEGER NOT NULL DEFAULT 0", + [], + ); + } + + // Index creation may fail when the column was just added by a + // concurrent writer; ignore failures and rely on a follow-up open. + let _ = connection.execute( + "CREATE INDEX IF NOT EXISTS idx_dingtalk_panel_context_created_at + ON dingtalk_panel_contexts(created_at)", + [], + ); + let _ = connection.execute( + "CREATE INDEX IF NOT EXISTS idx_dingtalk_panel_context_last_touched + ON dingtalk_panel_contexts(last_touched_at)", + [], + ); + + // Idempotent migration: existing rows created before + // `last_touched_at` was added may have `0` here. Back-fill from + // `created_at` so sliding TTL behaves correctly post-upgrade. + let _ = connection.execute( + "UPDATE dingtalk_panel_contexts + SET last_touched_at = created_at + WHERE last_touched_at = 0", + [], + ); + + Ok(Self { + jobs: Arc::new(RwLock::new(HashMap::new())), + panel_contexts: Arc::new(RwLock::new(HashMap::new())), + panel_db: Some(Arc::new(Mutex::new(connection))), + card_generations: Arc::new(RwLock::new(HashMap::new())), + }) + } + + /// Store a new inbound job + pub async fn store_inbound(&self, job: DingtalkJob) { + let mut jobs = self.jobs.write().await; + jobs.insert(job.job_id.clone(), job); + } + + /// Update job status + pub async fn update_status(&self, job_id: &str, status: JobStatus) { + let mut jobs = self.jobs.write().await; + if let Some(job) = jobs.get_mut(job_id) { + job.status = status; + job.updated_at = now_secs(); + } + } + + /// Mark job as failed with error message + pub async fn mark_failed(&self, job_id: &str, error: String) { + let mut jobs = self.jobs.write().await; + if let Some(job) = jobs.get_mut(job_id) { + job.status = JobStatus::Failed; + job.error_message = Some(error); + job.updated_at = now_secs(); + } + } + + /// Get recent jobs (last N, sorted by created_at desc) + pub async fn get_recent_jobs(&self, limit: usize) -> Vec { + let jobs = self.jobs.read().await; + let mut all: Vec<_> = jobs.values().cloned().collect(); + all.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + all.into_iter().take(limit).collect() + } + + /// Save the reply target for a newly delivered panel and prune expired + /// entries so a long-running Gateway cannot grow the context store without + /// bound. + pub async fn save_panel_context(&self, context: DingtalkPanelContext) { + let now = now_secs(); + let context = DingtalkPanelContext { + last_touched_at: now, + ..context + }; + { + let mut contexts = self.panel_contexts.write().await; + contexts.retain(|_, value| { + now.saturating_sub(value.last_touched_at) < PANEL_CONTEXT_TTL_SECS + }); + contexts.insert(context.out_track_id.clone(), context.clone()); + } + self.persist_panel_context(&context, now); + } + + /// Refresh the sliding TTL anchor for an existing panel without rewriting + /// any other field. Idempotent: if the panel is unknown, this is a no-op. + pub async fn touch_panel_context(&self, out_track_id: &str) -> bool { + let now = now_secs(); + let updated = { + let mut contexts = self.panel_contexts.write().await; + match contexts.get_mut(out_track_id) { + Some(context) => { + context.last_touched_at = now; + Some(context.clone()) + } + None => None, + } + }; + let Some(updated) = updated else { + return false; + }; + // Memory hit => persist via the same path so restart-recovery stays + // consistent. SQLite-only restarts (memory empty) rely on + // `get_panel_context` to relaunch the TTL clock. + self.persist_panel_context(&updated, now); + true + } + + fn persist_panel_context(&self, context: &DingtalkPanelContext, now: u64) { + let Some(database) = self.panel_db.as_ref() else { + return; + }; + let Ok(connection) = database.lock() else { + println!("[dingtalk-panel] context_persisted=false reason=store_lock"); + return; + }; + let cutoff = now.saturating_sub(PANEL_CONTEXT_TTL_SECS) as i64; + let _ = connection.execute( + "DELETE FROM dingtalk_panel_contexts WHERE last_touched_at < ?1", + params![cutoff], + ); + let persisted = connection.execute( + r#" + INSERT INTO dingtalk_panel_contexts ( + out_track_id, conversation_id, robot_code, session_webhook, + user_id, space_id, created_at, last_touched_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(out_track_id) DO UPDATE SET + conversation_id = excluded.conversation_id, + robot_code = excluded.robot_code, + session_webhook = excluded.session_webhook, + user_id = excluded.user_id, + space_id = excluded.space_id, + last_touched_at = excluded.last_touched_at + "#, + params![ + context.out_track_id, + context.conversation_id, + context.robot_code, + context.session_webhook, + context.user_id, + context.space_id, + context.created_at as i64, + context.last_touched_at as i64, + ], + ); + if persisted.is_err() { + println!("[dingtalk-panel] context_persisted=false reason=store_write"); + } + } + + /// Look up a live panel context by `outTrackId`. Sliding TTL: expiry is + /// derived from `last_touched_at + PANEL_CONTEXT_TTL_SECS`, not from + /// `created_at`. + pub async fn get_panel_context(&self, out_track_id: &str) -> PanelContextLookup { + self.get_panel_context_with_reason(out_track_id).await.0 + } + + /// Same as [`get_panel_context`] but returns a `(PanelContextLookup, + /// Option)` with the cache miss reason: `None` for `Hit`/`Missing`, + /// `Some(now)` for `Expired` and `Missing` to keep callers decoupled from + /// `now_secs()` plumbing if they need it. Most callers just want + /// [`get_panel_context`]. + pub async fn get_panel_context_with_reason( + &self, + out_track_id: &str, + ) -> (PanelContextLookup, Option) { + let now = now_secs(); + let mut contexts = self.panel_contexts.write().await; + contexts.retain(|_, value| { + now.saturating_sub(value.last_touched_at) < PANEL_CONTEXT_TTL_SECS + }); + if let Some(context) = contexts.get(out_track_id).cloned() { + return (PanelContextLookup::Hit(context), Some(now)); + } + drop(contexts); + + let Some(database) = self.panel_db.as_ref() else { + return (PanelContextLookup::Missing, Some(now)); + }; + let context = { + let connection = match database.lock().ok() { + Some(c) => c, + None => return (PanelContextLookup::Missing, Some(now)), + }; + let cutoff = now.saturating_sub(PANEL_CONTEXT_TTL_SECS) as i64; + let _ = connection.execute( + "DELETE FROM dingtalk_panel_contexts WHERE last_touched_at < ?1", + params![cutoff], + ); + connection + .query_row( + r#" + SELECT out_track_id, conversation_id, robot_code, session_webhook, + user_id, space_id, created_at, last_touched_at + FROM dingtalk_panel_contexts + WHERE out_track_id = ?1 + "#, + params![out_track_id], + |row| { + Ok(DingtalkPanelContext { + out_track_id: row.get(0)?, + conversation_id: row.get(1)?, + robot_code: row.get(2)?, + session_webhook: row.get(3)?, + user_id: row.get(4)?, + space_id: row.get(5)?, + created_at: row.get::<_, i64>(6)?.max(0) as u64, + last_touched_at: row + .get::<_, Option>(7)? + .filter(|value| *value > 0) + .map(|value| value as u64) + .unwrap_or_else(|| { + // Back-fill from created_at if migration + // hasn't run for some reason. + row.get::<_, i64>(6).ok().unwrap_or(0).max(0) as u64 + }), + }) + }, + ) + .optional() + .ok() + .flatten() + }; + let Some(context) = context else { + return (PanelContextLookup::Missing, Some(now)); + }; + + // Distinguish "row exists but TTL elapsed (would be deleted any moment)" + // from "row never existed". We re-check the cutoff here against the + // row's `last_touched_at` so the caller can log distinct reasons. + if now.saturating_sub(context.last_touched_at) >= PANEL_CONTEXT_TTL_SECS { + return (PanelContextLookup::Expired, Some(now)); + } + + // Reload into the in-memory cache so subsequent lookups skip SQLite. + self.panel_contexts + .write() + .await + .insert(out_track_id.to_string(), context.clone()); + (PanelContextLookup::Hit(context), Some(now)) + } + + /// Same as `get_panel_context` but also refreshes the sliding TTL anchor + /// as a side-effect. Use this from every canonical callback handler so a + /// live panel never expires while the user keeps clicking. + pub async fn lookup_and_touch(&self, out_track_id: &str) -> PanelContextLookup { + let lookup = self.get_panel_context(out_track_id).await; + if lookup.is_hit() { + self.touch_panel_context(out_track_id).await; + } + lookup + } + + pub async fn delete_panel_context(&self, out_track_id: &str) -> bool { + let removed_from_memory = self + .panel_contexts + .write() + .await + .remove(out_track_id) + .is_some(); + let removed_from_database = self + .panel_db + .as_ref() + .and_then(|database| database.lock().ok()) + .and_then(|connection| { + connection + .execute( + "DELETE FROM dingtalk_panel_contexts WHERE out_track_id = ?1", + params![out_track_id], + ) + .ok() + }) + .is_some_and(|count| count > 0); + removed_from_memory || removed_from_database + } + + /// Claim a brand-new ownership generation for `out_track_id`. The returned + /// generation must be passed back into [`is_card_generation_current`] when + /// the action attempts terminal card updates. This intentionally races + /// intentionally: only one action per generation wins; concurrent actions + /// get different generations and only the currently-equal one may update. + pub async fn claim_card_generation(&self, out_track_id: &str) -> u64 { + let mut generations = self.card_generations.write().await; + let generation = generations + .entry(out_track_id.to_string()) + .and_modify(|value| *value += 1) + .or_insert(1); + *generation + } + + /// Returns `true` only when the supplied generation still owns the card. + /// Callers MUST refuse to overwrite the card state when this returns + /// `false`; their work has been superseded. + pub async fn is_card_generation_current( + &self, + out_track_id: &str, + generation: u64, + ) -> bool { + let generations = self.card_generations.read().await; + match generations.get(out_track_id) { + Some(current) => *current == generation, + None => false, + } + } + + /// Read-only peek at the current generation. Useful for tests. + pub async fn current_card_generation(&self, out_track_id: &str) -> Option { + self.card_generations + .read() + .await + .get(out_track_id) + .copied() + } + + /// Attempt to claim ownership. Returns `Owner { generation }` if this + /// caller owns the card now, otherwise `Stale { current }` indicating + /// newer actions exist. + pub async fn try_claim_card_operation( + &self, + out_track_id: &str, + ) -> PanelOperationClaim { + let generation = self.claim_card_generation(out_track_id).await; + PanelOperationClaim::Owner { generation } + } +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Test-only helpers. Outside `#[cfg(test)]` the module is `pub(crate)` so +/// integration tests in other files can use the deterministic clock. +#[cfg(test)] +pub(crate) fn now_for_tests() -> u64 { + now_secs() +} + +#[cfg(test)] +pub(crate) fn dingtalk_store_test_path(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("omninova-dingtalk-panel-{label}-{}", uuid::Uuid::new_v4())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_store_and_retrieve_job() { + let store = DingtalkStore::new(); + let job = DingtalkJob { + job_id: "test-job-1".to_string(), + inbound: InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("user123".to_string()), + session_id: Some("session456".to_string()), + text: "hello".to_string(), + metadata: Default::default(), + }, + status: JobStatus::Received, + created_at: 1000, + updated_at: 1000, + error_message: None, + }; + + store.store_inbound(job.clone()).await; + let recent = store.get_recent_jobs(10).await; + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].job_id, "test-job-1"); + } + + #[tokio::test] + async fn test_update_status() { + let store = DingtalkStore::new(); + let job = DingtalkJob { + job_id: "test-job-2".to_string(), + inbound: InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: None, + session_id: Some("sess".to_string()), + text: "hi".to_string(), + metadata: Default::default(), + }, + status: JobStatus::Received, + created_at: 2000, + updated_at: 2000, + error_message: None, + }; + + store.store_inbound(job).await; + store.update_status("test-job-2", JobStatus::Processing).await; + + let recent = store.get_recent_jobs(10).await; + assert_eq!(recent[0].status, JobStatus::Processing); + } + + #[tokio::test] + async fn test_mark_failed() { + let store = DingtalkStore::new(); + let job = DingtalkJob { + job_id: "test-job-3".to_string(), + inbound: InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: None, + session_id: None, + text: "test".to_string(), + metadata: Default::default(), + }, + status: JobStatus::Processing, + created_at: 3000, + updated_at: 3000, + error_message: None, + }; + + store.store_inbound(job).await; + store.mark_failed("test-job-3", "network error".to_string()).await; + + let recent = store.get_recent_jobs(10).await; + assert_eq!(recent[0].status, JobStatus::Failed); + assert_eq!(recent[0].error_message.as_deref(), Some("network error")); + } + + #[tokio::test] + async fn test_get_recent_jobs_limit() { + let store = DingtalkStore::new(); + for i in 0..5 { + let job = DingtalkJob { + job_id: format!("job-{i}"), + inbound: InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: None, + session_id: None, + text: format!("text-{i}"), + metadata: Default::default(), + }, + status: JobStatus::Completed, + created_at: i, + updated_at: i, + error_message: None, + }; + store.store_inbound(job).await; + } + + let recent = store.get_recent_jobs(3).await; + assert_eq!(recent.len(), 3); + // Most recent first + assert!(recent[0].created_at > recent[1].created_at); + } + + #[tokio::test] + async fn panel_context_round_trip_uses_out_track_id() { + let store = DingtalkStore::new(); + store + .save_panel_context(DingtalkPanelContext { + out_track_id: "panel-track".to_string(), + conversation_id: Some("conversation-secret".to_string()), + robot_code: Some("robot-secret".to_string()), + session_webhook: Some("https://oapi.dingtalk.com/secret".to_string()), + user_id: Some("user-secret".to_string()), + space_id: None, + created_at: now_secs(), + last_touched_at: now_secs(), + }) + .await; + + let lookup = store.get_panel_context("panel-track").await; + let context = lookup.expect_hit("panel context should be found"); + assert_eq!(context.out_track_id, "panel-track"); + assert!(context.session_webhook.is_some()); + } + + #[tokio::test] + async fn expired_panel_context_is_removed() { + // Insert an already-expired row directly via SQL so neither the + // memory cache nor `save_panel_context`'s TTL reset can mask the + // condition. This validates the housekeeping DELETE + the + // Expired/Missing distinction in `get_panel_context`. + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-panel-expired-{}", + uuid::Uuid::new_v4() + )); + let store = DingtalkStore::open(&directory).unwrap(); + { + let connection = store + .panel_db + .as_ref() + .and_then(|db| db.lock().ok()) + .expect("db should be open"); + let past = now_secs().saturating_sub(PANEL_CONTEXT_TTL_SECS + 5); + connection + .execute( + "INSERT INTO dingtalk_panel_contexts + (out_track_id, conversation_id, robot_code, session_webhook, + user_id, space_id, created_at, last_touched_at) + VALUES (?1, NULL, NULL, NULL, NULL, NULL, ?2, ?2)", + rusqlite::params!["expired-track", past as i64], + ) + .unwrap(); + } + + let lookup = store.get_panel_context("expired-track").await; + assert!(lookup.is_expired() || lookup.is_missing()); + let _ = std::fs::remove_dir_all(directory); + } + + #[test] + fn panel_context_debug_never_contains_sensitive_values() { + let context = DingtalkPanelContext { + out_track_id: "track-secret".to_string(), + conversation_id: Some("conversation-secret".to_string()), + robot_code: Some("robot-secret".to_string()), + session_webhook: Some("session-webhook-secret".to_string()), + user_id: Some("user-secret".to_string()), + space_id: Some("space-secret".to_string()), + created_at: 1, + last_touched_at: 1, + }; + let debug = format!("{context:?}"); + for secret in [ + "track-secret", + "conversation-secret", + "robot-secret", + "session-webhook-secret", + "user-secret", + "space-secret", + ] { + assert!(!debug.contains(secret)); + } + } + + #[tokio::test] + async fn panel_context_survives_store_reopen() { + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-panel-{}", + uuid::Uuid::new_v4() + )); + let store = DingtalkStore::open(&directory).unwrap(); + store + .save_panel_context(DingtalkPanelContext { + out_track_id: "persistent-track".to_string(), + conversation_id: Some("conversation-secret".to_string()), + robot_code: Some("robot-secret".to_string()), + session_webhook: Some("session-webhook-secret".to_string()), + user_id: Some("user-secret".to_string()), + space_id: Some("space-secret".to_string()), + created_at: now_secs(), + last_touched_at: now_secs(), + }) + .await; + drop(store); + + let reopened = DingtalkStore::open(&directory).unwrap(); + let lookup = reopened.get_panel_context("persistent-track").await; + let context = lookup + .expect_hit("panel context should be loaded from state.sqlite"); + assert_eq!(context.out_track_id, "persistent-track"); + assert!(context.session_webhook.is_some()); + // session_webhook must NOT contain the original redacted string when + // serialized through Debug (regression guard for the saved struct). + let debug = format!("{context:?}"); + assert!(!debug.contains("session-webhook-secret")); + drop(reopened); + let _ = std::fs::remove_dir_all(directory); + } + + // ----------------------------------------------------------------- + // S1 / Stability tests + // ----------------------------------------------------------------- + + impl PanelContextLookup { + #[track_caller] + pub fn expect_hit(self, label: &str) -> DingtalkPanelContext { + match self { + PanelContextLookup::Hit(context) => context, + _ => panic!("{label}: expected Hit, got {self:?}"), + } + } + } + + #[tokio::test] + async fn panel_context_lookup_distinguishes_missing_from_expired() { + // Fully cold store: never seen -> Missing + let store = DingtalkStore::new(); + let cold = store.get_panel_context("never-seen").await; + assert!(cold.is_missing()); + assert!(!cold.is_expired()); + + // SQLite-backed store with a row that's already outside the + // sliding TTL: lookup must report Expired rather than silently + // returning a hit. We insert via raw SQL because the public + // `save_panel_context` resets the TTL anchor to "now". + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-panel-distinct-{}", + uuid::Uuid::new_v4() + )); + let persistent = DingtalkStore::open(&directory).unwrap(); + { + let connection = persistent + .panel_db + .as_ref() + .and_then(|db| db.lock().ok()) + .expect("db open"); + let past_created = now_secs().saturating_sub(PANEL_CONTEXT_TTL_SECS * 2); + connection + .execute( + "INSERT INTO dingtalk_panel_contexts + (out_track_id, conversation_id, robot_code, session_webhook, + user_id, space_id, created_at, last_touched_at) + VALUES (?1, NULL, NULL, NULL, NULL, NULL, ?2, ?2)", + rusqlite::params!["ghost-track", past_created as i64], + ) + .unwrap(); + } + let lookup = persistent.get_panel_context("ghost-track").await; + // Either Expired (row found past TTL) or Missing (row already pruned + // by the housekeeping DELETE) — both are correct user-facing signals. + assert!( + lookup.is_expired() || lookup.is_missing(), + "expected Expired/Missing, got {lookup:?}" + ); + let _ = std::fs::remove_dir_all(directory); + } + + #[tokio::test] + async fn panel_context_sliding_ttl_refreshes_on_lookup_and_touch() { + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-panel-slide-{}", + uuid::Uuid::new_v4() + )); + let store = DingtalkStore::open(&directory).unwrap(); + // Create a context with last_touched_at = now. + store + .save_panel_context(DingtalkPanelContext { + out_track_id: "slide-track".to_string(), + conversation_id: None, + robot_code: None, + session_webhook: None, + user_id: None, + space_id: None, + created_at: now_secs(), + last_touched_at: now_secs(), + }) + .await; + + // First hit returns Hit and refreshes the clock. + let first = store.lookup_and_touch("slide-track").await; + assert!(first.is_hit()); + let first_touched = first + .clone() + .expect_hit("hit a") + .last_touched_at; + + // Sleep just a moment so the touch stamp is observably newer. + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let touched = store.touch_panel_context("slide-track").await; + assert!(touched, "touch should succeed for known panel"); + let refreshed = store + .get_panel_context("slide-track") + .await + .expect_hit("hit b"); + assert!( + refreshed.last_touched_at > first_touched, + "touch must advance last_touched_at" + ); + let _ = std::fs::remove_dir_all(directory); + } + + #[tokio::test] + async fn panel_context_lookup_and_touch_is_noop_for_missing() { + let store = DingtalkStore::new(); + let lookup = store.lookup_and_touch("absent").await; + assert!(lookup.is_missing()); + } + + #[tokio::test] + async fn panel_context_time_units_are_consistently_seconds() { + // Save with crafted `created_at` to verify round-trip preserves + // seconds (not millis, not nanos). + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-panel-units-{}", + uuid::Uuid::new_v4() + )); + let store = DingtalkStore::open(&directory).unwrap(); + store + .save_panel_context(DingtalkPanelContext { + out_track_id: "units-track".to_string(), + conversation_id: None, + robot_code: None, + session_webhook: None, + user_id: None, + space_id: None, + created_at: 1_000_000_000, + last_touched_at: 1_000_000_000, + }) + .await; + let context = store + .get_panel_context("units-track") + .await + .expect_hit("hit"); + assert_eq!(context.created_at, 1_000_000_000); + // `last_touched_at` was rewritten to "now" by save_panel_context; + // the store contract guarantees a unix-seconds resolution. + assert!(context.last_touched_at >= 1_000_000_000); + assert!( + context.last_touched_at - now_secs() < 60, + "last_touched_at must be in seconds, got {} (now {})", + context.last_touched_at, + now_secs() + ); + let _ = std::fs::remove_dir_all(directory); + } + + #[tokio::test] + async fn card_generation_increments_per_claim() { + let store = DingtalkStore::new(); + let g1 = store.claim_card_generation("card-a").await; + let g2 = store.claim_card_generation("card-a").await; + let g3 = store.claim_card_generation("card-a").await; + assert_eq!(g1, 1); + assert_eq!(g2, 2); + assert_eq!(g3, 3); + + // Different card has independent counter (starts at 1). + let other = store.claim_card_generation("card-b").await; + assert_eq!(other, 1); + } + + #[tokio::test] + async fn card_generation_current_returns_false_for_stale_owners() { + let store = DingtalkStore::new(); + let first = store.claim_card_generation("card-x").await; + assert!(store.is_card_generation_current("card-x", first).await); + // A new action arrives and supersedes. + let _second = store.claim_card_generation("card-x").await; + // First action tries to update the card — must be refused. + assert!(!store.is_card_generation_current("card-x", first).await); + } + + #[tokio::test] + async fn different_out_tracks_do_not_share_generation() { + let store = DingtalkStore::new(); + let g_a = store.claim_card_generation("card-a").await; + let g_b = store.claim_card_generation("card-b").await; + assert!(store.is_card_generation_current("card-a", g_a).await); + assert!(store.is_card_generation_current("card-b", g_b).await); + } + + /// Test: the S1 migration is safe for databases that pre-date the + /// `last_touched_at` column. Simulates an existing on-disk schema + /// (created before this phase), opens the store, and asserts that the + /// upgrade path adds the column without rolling back the open call. + #[tokio::test] + async fn migration_adds_last_touched_at_to_existing_schema() { + use rusqlite::Connection; + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-panel-migration-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&directory).unwrap(); + + // Lay down a pre-S1 schema: no `last_touched_at` column. + let legacy_db_path = directory.join("state.sqlite"); + // Use a timestamp within the TTL window so the row survives the + // housekeeping DELETE after upgrade. + let recent_created = now_secs().saturating_sub(60); + { + let conn = Connection::open(&legacy_db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE dingtalk_panel_contexts ( + out_track_id TEXT PRIMARY KEY, + conversation_id TEXT, + robot_code TEXT, + session_webhook TEXT, + user_id TEXT, + space_id TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX idx_dingtalk_panel_context_created_at + ON dingtalk_panel_contexts(created_at);", + ) + .unwrap(); + conn.execute( + "INSERT INTO dingtalk_panel_contexts + (out_track_id, conversation_id, robot_code, session_webhook, + user_id, space_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + "legacy-track", + Some("conv"), + Some("robot"), + Some("webhook"), + Some("user"), + Some("space"), + recent_created as i64 + ], + ) + .unwrap(); + } + + let store = DingtalkStore::open(&directory).expect("open must succeed"); + let lookup = store.get_panel_context("legacy-track").await; + let context = lookup.expect_hit("legacy row must reload"); + assert_eq!(context.out_track_id, "legacy-track"); + assert_eq!(context.last_touched_at, recent_created); + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/crates/omninova-core/src/gateway/dingtalk_stream.rs b/crates/omninova-core/src/gateway/dingtalk_stream.rs new file mode 100644 index 00000000..c59c953f --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_stream.rs @@ -0,0 +1,2319 @@ +//! Unified DingTalk Stream transport for robot messages and interactive card callbacks. +//! +//! This module provides a single WebSocket connection that subscribes to both: +//! - `/v1.0/im/bot/messages/get` (robot inbound messages) +//! - `/v1.0/card/instances/callback` (interactive card action callbacks) +//! +//! Key design principles: +//! - ACK immediately, process asynchronously (bounded queue) +//! - Registered state required for card availability +//! - Exponential backoff with max cap for reconnection +//! - Single reconnect loop to prevent storm +//! - Bounded message deduplication + +use crate::channels::ChannelKind; +use crate::channels::InboundMessage; +use crate::config::schema::DingtalkTransportMode; +use crate::gateway::dingtalk_card_stream::ParsedCardCallback; +use crate::gateway::dingtalk_worker::DingtalkAsyncJob; +use crate::gateway::{DingTalkStreamState, GatewayRuntime}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, watch}; +use tokio_tungstenite::tungstenite::Message; + +/// DingTalk Stream gateway endpoint +const DINGTALK_STREAM_GATEWAY_URL: &str = "https://api.dingtalk.com/v1.0/gateway/connections/open"; + +/// Robot messages topic +pub const TOPIC_ROBOT: &str = "/v1.0/im/bot/messages/get"; + +/// Card callback topic +pub const TOPIC_CARD: &str = "/v1.0/card/instances/callback"; + +/// Backpressure queue capacity +const QUEUE_CAPACITY: usize = 100; + +/// Max reconnection attempts before capping backoff +const MAX_BACKOFF_ATTEMPTS: u32 = 6; + +/// Guard to manage the Stream lifecycle. Holds owner generation token that +/// prevents old owners from clearing new owners (ABA-safe). +/// +/// Drop semantics: +/// - Cooperative shutdown (normal serve_http exit): Drop called → release_owner(gen) +/// - Abort shutdown (task.abort()): Drop called after JoinHandle await → release_owner(gen) +/// Both paths correctly release because tokio ensures future completion before JoinHandle returns. +pub struct DingtalkStreamGuard { + /// Owner generation: only this owner may release. + owner_gen: u64, + /// Runtime reference for owner-scoped cleanup calls. + runtime: Arc, + shutdown_complete: bool, +} + +impl DingtalkStreamGuard { + pub async fn shutdown(&mut self) { + if self.shutdown_complete { + return; + } + let outcome = self + .runtime + .shutdown_dingtalk_stream_generation(self.owner_gen, Duration::from_secs(5)) + .await; + println!("[dingtalk-stream] lifecycle_join outcome={outcome:?}"); + self.shutdown_complete = true; + } +} + +impl Drop for DingtalkStreamGuard { + fn drop(&mut self) { + if !self.shutdown_complete { + let _ = self + .runtime + .signal_dingtalk_stream_shutdown(self.owner_gen); + } + } +} + +/// Start the unified DingTalk Stream transport. +/// Returns None if Stream mode is not enabled, configuration is incomplete, +/// or another reconnect loop is already running on this runtime. +pub async fn start(runtime: Arc) -> Option { + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + + let enabled = config.gateway.dingtalk.enabled || entry.map(|e| e.enabled).unwrap_or(false); + if !enabled { + println!("[dingtalk-stream] start_skipped reason=channel_disabled"); + return None; + } + + let transport_mode = crate::gateway::resolve_dingtalk_transport_mode_for_worker(&config, entry); + if transport_mode != DingtalkTransportMode::Stream { + println!("[dingtalk-stream] start_skipped reason=transport_mode=http"); + return None; + } + + let app_key = crate::gateway::resolve_dingtalk_app_key_for_worker(&config, entry); + let app_secret = crate::gateway::resolve_dingtalk_secret_for_worker(&config, entry); + + if app_key.is_none() || app_secret.is_none() { + println!("[dingtalk-stream] start_skipped reason=incomplete_credentials"); + return None; + } + + // Acquire ownership and install the physical JoinHandle atomically. + let ak = app_key.unwrap(); + let asec = app_secret.unwrap(); + let rt = runtime.clone(); + let owner_gen = runtime.try_start_dingtalk_stream_loop(move |owner_gen, rx| async move { + run_reconnect_loop_internal(rt, owner_gen, ak, asec, rx).await; + }); + let Some(owner_gen) = owner_gen else { + println!("[dingtalk-stream] start_skipped reason=already_owned"); + return None; + }; + + println!("[dingtalk-stream] owner_acquired gen={owner_gen}"); + Some(DingtalkStreamGuard { + owner_gen, + runtime, + shutdown_complete: false, + }) +} + +// --------------------------------------------------------------------------- +// Reconnect loop - single owner to prevent storm +// --------------------------------------------------------------------------- + +/// Owner-scoped cleanup: only releases if the current owner matches. +/// This is a belt-and-suspenders safety net — the primary cleanup path +/// is Guard::Drop. The loop calls this as it exits so that both cooperative +/// and abort shutdown paths correctly release. +fn stream_cleanup_on_exit(runtime: &Arc, owner_gen: u64) { + runtime.set_dingtalk_stream_connected(owner_gen, false); +} + +async fn run_reconnect_loop_internal( + runtime: Arc, + owner_gen: u64, + app_key: String, + app_secret: String, + mut shutdown: watch::Receiver, +) { + let mut attempt = 0u32; + let mut throttle_until: Option = None; + + loop { + if *shutdown.borrow() { + break; + } + + // Respect a 429 throttle: hold off until the retry-after window + // elapses instead of hammering the gateway endpoint. + if let Some(until) = throttle_until { + if std::time::Instant::now() < until { + let remaining = until.saturating_duration_since(std::time::Instant::now()); + println!( + "[dingtalk-stream] throttled=true remaining_ms={} owner_gen={}", + remaining.as_millis(), + owner_gen + ); + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + break; + } + } + _ = tokio::time::sleep(remaining) => {} + } + } + throttle_until = None; + } + + println!("[dingtalk-stream] state=connecting"); + + match connect_and_run(&runtime, owner_gen, &app_key, &app_secret, shutdown.clone(), attempt).await { + Ok(()) => { + attempt = 0; + } + Err(e) => { + let kind = stream_error_kind(&e); + attempt = attempt.saturating_add(1); + let delay = reconnect_delay(attempt); + if e.contains("websocket_connect_error:http:429") { + throttle_until = Some( + std::time::Instant::now() + + delay.max(Duration::from_secs(30)), + ); + } + println!( + "[dingtalk-stream] disconnected reason={} reconnect_attempt={} reconnect_delay_ms={}", + kind, + attempt, + delay.as_millis() + ); + } + } + + runtime.set_dingtalk_stream_connected(owner_gen, false); + + let delay = reconnect_delay(attempt); + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + break; + } + } + _ = tokio::time::sleep(delay) => {} + } + } + + // Owner-scoped cleanup: releases only if gen still matches. + stream_cleanup_on_exit(&runtime, owner_gen); + println!("[dingtalk-stream] shutdown=true owner_gen={owner_gen}"); +} + +// --------------------------------------------------------------------------- +// TLS/ rustls CryptoProvider initialization +// --------------------------------------------------------------------------- + +/// Initialize the rustls crypto provider (ring). +/// This must be called before any TLS connection is made. +#[allow(dead_code)] +pub(crate) fn ensure_rustls_crypto_provider() { + use rustls::crypto::CryptoProvider; + use std::sync::OnceLock; + + static INITIALIZED: OnceLock<()> = OnceLock::new(); + + INITIALIZED.get_or_init(|| { + if CryptoProvider::get_default().is_some() { + println!("[dingtalk-stream] rustls_provider=preinstalled"); + return; + } + + match rustls::crypto::ring::default_provider().install_default() { + Ok(()) => { + println!("[dingtalk-stream] rustls_provider=ring"); + } + Err(_) => { + // 可能是并发情况下另一个线程先完成了安装。 + if CryptoProvider::get_default().is_some() { + println!("[dingtalk-stream] rustls_provider=preinstalled"); + } else { + eprintln!("[dingtalk-stream] rustls_provider_init_failed=true"); + } + } + } + }); +} + +// --------------------------------------------------------------------------- +// Single connection lifecycle +// --------------------------------------------------------------------------- + +async fn connect_and_run( + runtime: &Arc, + owner_gen: u64, + app_key: &str, + app_secret: &str, + mut shutdown: watch::Receiver, + attempt: u32, +) -> Result<(), String> { + // Ensure TLS provider is initialized before connecting + ensure_rustls_crypto_provider(); + + // 1. Request endpoint/ticket + let (endpoint, ticket) = request_stream_connection(app_key, app_secret).await?; + + // 2. Connect WebSocket + let mut url = reqwest::Url::parse(&endpoint).map_err(|_| "invalid_endpoint".to_string())?; + url.query_pairs_mut().append_pair("ticket", &ticket); + + let (mut socket, _) = tokio_tungstenite::connect_async(url.as_str()) + .await + .map_err(|error| classify_ws_connect_error(&error, owner_gen, attempt))?; + + println!("[dingtalk-stream] websocket_open=true"); + runtime.set_dingtalk_stream_connected(owner_gen, true); + + // 3. Run socket loop until failure + run_socket_loop(runtime, owner_gen, &mut socket, shutdown).await +} + +async fn run_socket_loop( + runtime: &Arc, + owner_gen: u64, + socket: &mut S, + mut shutdown: watch::Receiver, +) -> Result<(), String> +where + S: futures_util::Sink + + futures_util::StreamExt> + + Unpin, +{ + runtime.set_dingtalk_stream_connected(owner_gen, true); + println!("[dingtalk-stream] state=connected"); + println!("[dingtalk-stream] read_loop_started=true"); + + loop { + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + // Close the socket gracefully by dropping + return Ok(()); + } + } + msg = socket.next() => { + let Some(msg) = msg else { + return Err("socket_closed".to_string()); + }; + let msg = msg.map_err(|e| format!("socket_read_error:{}", e))?; + + match msg { + Message::Text(text) => { + let bytes = text.len(); + println!("[dingtalk-stream] frame_received kind=text bytes={}", bytes); + if let Err(e) = handle_downstream_json(socket, runtime, owner_gen, text.as_ref()).await { + return Err(e); + } + } + Message::Binary(bytes) => { + let byte_count = bytes.len(); + println!("[dingtalk-stream] frame_received kind=binary bytes={}", byte_count); + match std::str::from_utf8(&bytes) { + Ok(text) => { + if let Err(e) = handle_downstream_json(socket, runtime, owner_gen, text).await { + return Err(e); + } + } + Err(_) => { + println!("[dingtalk-stream] binary_decode_failed=true bytes={}", byte_count); + } + } + } + Message::Ping(payload) => { + let bytes = payload.len(); + println!("[dingtalk-stream] frame_received kind=ping bytes={}", bytes); + let _ = socket.send(Message::Pong(payload)).await; + } + Message::Pong(payload) => { + let bytes = payload.len(); + println!("[dingtalk-stream] frame_received kind=pong bytes={}", bytes); + } + Message::Close(frame) => { + println!("[dingtalk-stream] frame_received kind=close"); + return Err("socket_closed_by_server".to_string()); + } + Message::Frame(_) => { + // Internal frame - ignore + } + } + } + } + } +} + +/// Shared downstream JSON frame handler for both Text and Binary frames +async fn handle_downstream_json( + socket: &mut S, + runtime: &Arc, + owner_gen: u64, + text: &str, +) -> Result<(), String> +where + S: futures_util::Sink + Unpin, +{ + let envelope: StreamEnvelope = serde_json::from_str(text).map_err(|_| { + println!("[dingtalk-stream] downstream_parse_failed=true frame_kind=text"); + "invalid_json".to_string() + })?; + + let frame_type = envelope.frame_type.as_str(); + let topic = &envelope.headers.topic; + let data_len = envelope.data.len(); + + println!( + "[dingtalk-stream] downstream_meta type={} topic={} data_bytes={}", + safe_action(frame_type), + safe_action(topic), + data_len + ); + + match frame_type { + "SYSTEM" => { + handle_system_frame(socket, runtime, owner_gen, &envelope).await?; + } + _ if !should_dispatch_business_frame(runtime, owner_gen) => { + println!("[dingtalk-stream] frame_skipped reason=stale_owner"); + } + "EVENT" => { + println!( + "[dingtalk-stream] downstream_meta event_received=true topic={}", + topic + ); + } + "CALLBACK" => match topic.as_str() { + TOPIC_ROBOT => { + handle_robot_callback(socket, runtime, &envelope).await?; + } + TOPIC_CARD => { + handle_card_callback(socket, runtime, &envelope).await?; + } + _ => { + println!( + "[dingtalk-stream] topic=unknown callback_received=true topic={}", + topic + ); + } + }, + _ => { + println!("[dingtalk-stream] frame_type=unknown type={}", frame_type); + } + } + + Ok(()) +} + +pub(crate) fn should_dispatch_business_frame( + runtime: &GatewayRuntime, + owner_gen: u64, +) -> bool { + runtime.is_current_stream_owner(owner_gen) +} + +// --------------------------------------------------------------------------- +// Frame handling +// --------------------------------------------------------------------------- + +async fn handle_system_frame( + socket: &mut S, + runtime: &Arc, + owner_gen: u64, + envelope: &StreamEnvelope, +) -> Result<(), String> +where + S: futures_util::Sink + Unpin, +{ + // SYSTEM type is determined by headers.topic + let topic = &envelope.headers.topic; + + match topic.as_str() { + "CONNECTED" => { + println!("[dingtalk-stream] system=CONNECTED"); + // TCP open ≠ ready - wait for REGISTERED + } + "REGISTERED" => { + println!("[dingtalk-stream] system=REGISTERED"); + runtime.set_dingtalk_stream_connected(owner_gen, true); + } + "disconnect" => { + println!("[dingtalk-stream] system=disconnect"); + runtime.set_dingtalk_stream_connected(owner_gen, false); + // Ack and return error to trigger reconnect with new ticket + // Use SYSTEM ACK to preserve original headers and data + println!("[dingtalk-stream] system_ack topic=disconnect send_attempt=true"); + match socket + .send(Message::Text(build_system_ack(envelope).to_string().into())) + .await + { + Ok(()) => { + println!("[dingtalk-stream] system_ack topic=disconnect send_ok=true"); + } + Err(e) => { + println!("[dingtalk-stream] system_ack topic=disconnect send_ok=false reason=websocket_write"); + } + } + return Err("server_disconnect".to_string()); + } + "ping" => { + // Respond with pong per DingTalk Stream protocol + println!("[dingtalk-stream] system=ping"); + match socket + .send(Message::Text(build_system_ack(envelope).to_string().into())) + .await + { + Ok(()) => { + println!("[dingtalk-stream] system_ack topic=ping send_ok=true"); + } + Err(e) => { + println!("[dingtalk-stream] system_ack topic=ping send_ok=false reason=websocket_write"); + return Err(format!("websocket_write_error:{}", e)); + } + } + } + "KEEPALIVE" => { + println!("[dingtalk-stream] system=KEEPALIVE"); + } + _ => { + println!("[dingtalk-stream] system=unknown topic={}", topic); + } + } + + Ok(()) +} + +async fn handle_robot_callback( + socket: &mut S, + runtime: &Arc, + envelope: &StreamEnvelope, +) -> Result<(), String> +where + S: futures_util::Sink + Unpin, +{ + // Parse robot message + let payload = match parse_robot_payload(envelope) { + Ok(p) => p, + Err(e) => { + log_robot_payload_field_types(&envelope.data); + println!( + "[dingtalk-stream] topic=robot parse_failed=true reason={}", + e + ); + // Do NOT ACK as success: an unparsed message must not be marked + // as processed. Leaving it unacked lets DingTalk retry delivery. + return Ok(()); + } + }; + println!( + "[dingtalk-stream] topic=robot parse_ok=true msg_id_hash={}", + short_hash(&payload.msg_id) + ); + + // Dedupe check + let msg_id = payload.msg_id.clone(); + if !runtime.try_dingtalk_stream_dedupe(&msg_id).await { + println!( + "[dingtalk-stream] topic=robot dedupe_skipped=true msg_id_hash={}", + short_hash(&msg_id) + ); + let ack = build_stream_ack(envelope, serde_json::json!({})); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + + // Bounded enqueue + match runtime.try_enqueue_dingtalk_stream_job(payload).await { + Ok(()) => { + println!( + "[dingtalk-stream] topic=robot enqueue=true msg_id_hash={}", + short_hash(&msg_id) + ); + let ack = build_stream_ack(envelope, serde_json::json!({})); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + println!("[dingtalk-stream] topic=robot ack=true"); + } + Err(_) => { + // Rollback dedupe reservation so DingTalk retry can succeed. + runtime.dedup_cache().remove(&format!("dt_stream:{}", msg_id)).await; + println!( + "[dingtalk-stream] topic=robot queue_full=true msg_id_hash={} dedupe_rollback=true", + short_hash(&msg_id) + ); + // DO NOT ACK - let DingTalk retry later + return Err("queue_full".to_string()); + } + } + + Ok(()) +} + +async fn handle_card_callback( + socket: &mut S, + runtime: &Arc, + envelope: &StreamEnvelope, +) -> Result<(), String> +where + S: futures_util::Sink + Unpin, +{ + // Parse card callback - handle both StreamEnvelope and Value + let callback = match parse_card_callback_from_stream_envelope(envelope) { + Ok(c) => c, + Err(e) => { + println!( + "[dingtalk-stream] topic=card parse_failed=true reason={}", + e + ); + // Still ACK malformed callbacks + let ack = build_stream_ack(envelope, serde_json::json!({})); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + }; + + println!( + "[dingtalk-stream] topic=card callback_received=true action={}", + safe_action(&callback.action) + ); + + // Check against canonical allow-list + if !crate::gateway::dingtalk_card_stream::is_allowed_action(&callback.action) { + println!( + "[dingtalk-stream] topic=card action_rejected action={}", + safe_action(&callback.action) + ); + let ack = build_stream_ack(envelope, serde_json::json!({ "response": {} })); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + + let dedupe_key = crate::gateway::dingtalk_card_stream::callback_dedupe_key( + callback.callback_id.as_deref(), + &callback.out_track_id, + &callback.action, + &envelope.data, + ); + if !runtime.try_dingtalk_stream_dedupe(&dedupe_key).await { + println!("[dingtalk-stream] topic=card callback_duplicated=true"); + let ack = build_stream_ack(envelope, serde_json::json!({ "response": {} })); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + return Ok(()); + } + + // ACK immediately + let ack = build_stream_ack(envelope, serde_json::json!({ "response": {} })); + let _ = socket.send(Message::Text(ack.to_string().into())).await; + println!("[dingtalk-stream] topic=card ack=true"); + + // Process in background + let runtime_clone = runtime.clone(); + tokio::spawn(async move { + crate::gateway::dingtalk_card_stream::process_panel_action(runtime_clone, callback).await; + }); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Card callback parsing from StreamEnvelope +// --------------------------------------------------------------------------- + +/// Parse a card callback from a StreamEnvelope by extracting the data field. +pub fn parse_card_callback_from_stream_envelope( + envelope: &StreamEnvelope, +) -> Result { + use crate::gateway::dingtalk_card_stream::ParsedCardCallback; + + // Parse the data string as JSON + let request: serde_json::Value = + serde_json::from_str(&envelope.data).map_err(|_| "invalid_data_json".to_string())?; + + let out_track_id = request + .get("outTrackId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or("missing_out_track_id")? + .to_string(); + + let user_id = request + .get("userId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToString::to_string); + + let space_id = request + .get("spaceId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToString::to_string); + + // Parse content to get action + let content_str = request + .get("content") + .and_then(|v| v.as_str()) + .ok_or("missing_content")?; + + let content: serde_json::Value = + serde_json::from_str(content_str).map_err(|_| "invalid_content_json".to_string())?; + + let params = content + .get("cardPrivateData") + .and_then(|v| v.get("params")) + .ok_or("missing_card_private_params")?; + + let action = params + .get("action") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or("missing_action")? + .to_string(); + + let callback_id = ["callbackId", "messageId", "eventId"] + .iter() + .find_map(|key| request.get(*key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| envelope.headers.message_id.clone()) + .or_else(|| envelope.headers.time.clone()); + + Ok(ParsedCardCallback { + out_track_id, + action, + callback_id, + user_id, + space_id, + }) +} + +// --------------------------------------------------------------------------- +// Stream envelope types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize)] +struct StreamEnvelope { + #[serde(rename = "specVersion")] + spec_version: Option, + #[serde(rename = "type")] + frame_type: String, + headers: StreamHeaders, + #[serde(default)] + data: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct StreamHeaders { + #[serde(rename = "appId", default)] + app_id: Option, + #[serde(rename = "connectionId", default)] + connection_id: Option, + #[serde(rename = "contentType", default)] + content_type: Option, + #[serde(rename = "messageId", default)] + message_id: Option, + #[serde(rename = "time", default)] + time: Option, + topic: String, +} + +#[derive(Clone, Deserialize, Serialize)] +struct RobotPayload { + #[serde(rename = "msgId")] + msg_id: String, + #[serde( + rename = "conversationType", + deserialize_with = "deserialize_string_or_number" + )] + conversation_type: String, + #[serde(rename = "senderNick", default, deserialize_with = "deserialize_opt_string_or_number")] + sender_nick: Option, + #[serde(rename = "senderStaffId", default, deserialize_with = "deserialize_opt_string_or_number")] + sender_staff_id: Option, + #[serde(rename = "senderId", default, deserialize_with = "deserialize_opt_string_or_number")] + sender_id: Option, + #[serde(rename = "conversationId", default, deserialize_with = "deserialize_opt_string_or_number")] + conversation_id: Option, + #[serde(rename = "robotCode", default, deserialize_with = "deserialize_opt_string_or_number")] + robot_code: Option, + #[serde(rename = "sessionWebhook", default, deserialize_with = "deserialize_opt_string_or_number")] + session_webhook: Option, + #[serde( + rename = "sessionWebhookExpiredTime", + default, + deserialize_with = "deserialize_opt_i64_string_or_number" + )] + session_webhook_expired_time: Option, + #[serde( + rename = "createAt", + default, + deserialize_with = "deserialize_opt_i64_string_or_number" + )] + create_at: Option, + #[serde(rename = "msgtype", default)] + msg_type: Option, + text: Option, +} + +impl std::fmt::Debug for RobotPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RobotPayload") + .field("msg_id_present", &!self.msg_id.trim().is_empty()) + .field( + "conversation_type_present", + &!self.conversation_type.trim().is_empty(), + ) + .field("sender_nick_present", &self.sender_nick.is_some()) + .field("sender_staff_id_present", &self.sender_staff_id.is_some()) + .field("sender_id_present", &self.sender_id.is_some()) + .field("conversation_id_present", &self.conversation_id.is_some()) + .field("robot_code_present", &self.robot_code.is_some()) + .field("session_webhook_present", &self.session_webhook.is_some()) + .field( + "session_webhook_expired_time", + &self.session_webhook_expired_time, + ) + .field("create_at", &self.create_at) + .field("msg_type", &self.msg_type) + .field("text_present", &self.text.is_some()) + .field( + "text_len", + &self.text.as_ref().map(|text| text.content.chars().count()), + ) + .finish() + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct RobotText { + content: String, +} + +// --------------------------------------------------------------------------- +// Compatible scalar deserializers +// +// DingTalk delivers robot callback metadata on different paths/versions with +// drifting JSON types: timestamps arrive as integer milliseconds or as string +// numbers, and identity-like fields occasionally appear as numbers. These +// helpers normalize number | string-number | null | missing into a single +// Rust type so a single metadata field can never drop the whole message. +// --------------------------------------------------------------------------- + +/// Optional i64 accepting `1786437210268`, `"1786437210268"`, null, missing. +fn deserialize_opt_i64_string_or_number<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum I64OrString { + Integer(i64), + Unsigned(u64), + Text(String), + } + + let value = Option::::deserialize(deserializer)?; + Ok(value.and_then(|v| match v { + I64OrString::Integer(n) => Some(n), + I64OrString::Unsigned(n) => i64::try_from(n).ok(), + I64OrString::Text(s) => s.trim().parse().ok(), + })) +} + +/// Optional String accepting text, integer, unsigned, null, missing. +fn deserialize_opt_string_or_number<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + Text(String), + Integer(i64), + Unsigned(u64), + } + + let value = Option::::deserialize(deserializer)?; + Ok(value.map(|v| match v { + StringOrNumber::Text(s) => s, + StringOrNumber::Integer(n) => n.to_string(), + StringOrNumber::Unsigned(n) => n.to_string(), + })) +} + +/// Required String accepting text or numeric scalars. +fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + Text(String), + Integer(i64), + Unsigned(u64), + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::Text(s) => Ok(s), + StringOrNumber::Integer(n) => Ok(n.to_string()), + StringOrNumber::Unsigned(n) => Ok(n.to_string()), + } +} + +/// Log only JSON field *types* (never values) of a robot callback payload. +/// Used on parse failure so a future type drift is visible in one line. +fn log_robot_payload_field_types(data: &str) { + if let Some(summary) = build_robot_payload_field_types(data) { + println!("[dingtalk-stream] robot_payload_types {}", summary); + } +} + +/// Build a `key=type ...` summary of the payload fields. Never includes values. +fn build_robot_payload_field_types(data: &str) -> Option { + let value = serde_json::from_str::(data).ok()?; + let obj = value.as_object()?; + const DIAGNOSTIC_FIELDS: &[&str] = &[ + "msgId", + "msgtype", + "text", + "conversationType", + "createAt", + "sessionWebhookExpiredTime", + "isAdmin", + "isInAtList", + ]; + + let parts: Vec = DIAGNOSTIC_FIELDS + .iter() + .filter_map(|key| obj.get(*key).map(|v| (*key, v))) + .map(|(key, v)| { + let ty = match v { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + }; + format!("{}={}", key, ty) + }) + .collect(); + Some(parts.join(" ")) +} + +fn parse_robot_payload(envelope: &StreamEnvelope) -> Result { + let data_str = &envelope.data; + let payload: RobotPayload = + serde_json::from_str(data_str).map_err(|e| format!("parse_error:{}", e))?; + + if payload.msg_id.trim().is_empty() { + return Err("core_error:empty_msg_id".to_string()); + } + if payload.conversation_type.trim().is_empty() { + return Err("core_error:empty_conversation_type".to_string()); + } + if payload + .msg_type + .as_deref() + .is_some_and(|kind| kind.eq_ignore_ascii_case("text")) + && payload.text.is_none() + { + return Err("core_error:missing_text".to_string()); + } + + Ok(payload) +} + +fn short_hash(s: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + format!("{:x}", hasher.finish())[..8].to_string() +} + +// --------------------------------------------------------------------------- +// ACK builders +// --------------------------------------------------------------------------- + +fn build_stream_ack(envelope: &StreamEnvelope, data: serde_json::Value) -> serde_json::Value { + let message_id = envelope.headers.message_id.as_deref().unwrap_or_default(); + + serde_json::json!({ + "code": 200, + "headers": { + "contentType": "application/json", + "messageId": message_id + }, + "message": "OK", + "data": data.to_string() + }) +} + +/// Build SYSTEM ACK that echoes original headers and data per DingTalk Stream protocol. +/// For SYSTEM frames (ping, disconnect), the response must preserve the original headers and data. +fn build_system_ack(envelope: &StreamEnvelope) -> serde_json::Value { + // Echo back the original headers from the downstream message + // This is required by the DingTalk Stream protocol for SYSTEM frames + serde_json::json!({ + "code": 200, + "headers": { + "appId": envelope.headers.app_id.as_deref().unwrap_or_default(), + "connectionId": envelope.headers.connection_id.as_deref().unwrap_or_default(), + "contentType": envelope.headers.content_type.as_deref().unwrap_or_default(), + "messageId": envelope.headers.message_id.as_deref().unwrap_or_default(), + "time": envelope.headers.time.as_deref().unwrap_or_default(), + "topic": envelope.headers.topic + }, + "message": "OK", + "data": envelope.data + }) +} + +// --------------------------------------------------------------------------- +// Gateway connection +// --------------------------------------------------------------------------- + +async fn request_stream_connection( + app_key: &str, + app_secret: &str, +) -> Result<(String, String), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|_| "http_client_error".to_string())?; + + let response = client + .post(DINGTALK_STREAM_GATEWAY_URL) + .json(&serde_json::json!({ + "clientId": app_key, + "clientSecret": app_secret, + "ua": "omninova-claw-rust/0.1", + "subscriptions": [ + { "type": "EVENT", "topic": "*" }, + { "type": "CALLBACK", "topic": TOPIC_ROBOT }, + { "type": "CALLBACK", "topic": TOPIC_CARD } + ] + })) + .send() + .await + .map_err(|e| format!("network_error:{}", e))?; + + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|e| format!("read_error:{}", e))?; + + if !(200..300).contains(&status) { + let code: String = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from)) + .unwrap_or_else(|| "unknown".to_string()); + return Err(format!("gateway_error:status={}:code={}", status, code)); + } + + let payload: serde_json::Value = + serde_json::from_str(&body).map_err(|_| "invalid_json".to_string())?; + + let endpoint = payload + .get("endpoint") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .ok_or("missing_endpoint")? + .to_string(); + + let ticket = payload + .get("ticket") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .ok_or("missing_ticket")? + .to_string(); + + println!( + "[dingtalk-stream] endpoint_acquired=true endpoint_hash={} ticket_hash={}", + opaque_sha256(&endpoint), + opaque_sha256(&ticket) + ); + Ok((endpoint, ticket)) +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +fn reconnect_delay(attempt: u32) -> Duration { + let exp = attempt.min(MAX_BACKOFF_ATTEMPTS); + Duration::from_secs(2u64.saturating_pow(exp).min(60)) +} + +/// Opaque SHA-256 digest (first 6 bytes hex) for diagnostics. Never reveals +/// the original endpoint/ticket/identifier value. +fn opaque_sha256(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..6]) +} + +/// Classified category of a WebSocket connect failure. The raw tungstenite +/// error string may embed the endpoint/ticket, so only the category and safe +/// scalars (HTTP status, IO kind) are ever logged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DingtalkWsConnectErrorKind { + Http, + Io, + Tls, + Protocol, + Url, + Capacity, + Other, +} + +impl DingtalkWsConnectErrorKind { + fn classify(error: &tokio_tungstenite::tungstenite::Error) -> Self { + use tokio_tungstenite::tungstenite::Error; + match error { + Error::Http(_) => Self::Http, + Error::Io(_) => Self::Io, + Error::Tls(_) => Self::Tls, + Error::Protocol(_) => Self::Protocol, + Error::Url(_) => Self::Url, + Error::Capacity(_) => Self::Capacity, + _ => Self::Other, + } + } + + const fn log_value(self) -> &'static str { + match self { + Self::Http => "http", + Self::Io => "io", + Self::Tls => "tls", + Self::Protocol => "protocol", + Self::Url => "url", + Self::Capacity => "capacity", + Self::Other => "other", + } + } +} + +/// Classify a WebSocket connect error and log only safe diagnostics: +/// error_kind, http_status (when present), io_error_kind (when present), +/// retry-after presence. Returns a safe reason string for the reconnect loop. +fn classify_ws_connect_error( + error: &tokio_tungstenite::tungstenite::Error, + owner_gen: u64, + attempt: u32, +) -> String { + use tokio_tungstenite::tungstenite::Error; + let kind = DingtalkWsConnectErrorKind::classify(error); + match error { + Error::Http(response) => { + let http_status = response.status().as_u16(); + let retry_after_present = response.headers().contains_key("retry-after"); + println!( + "[dingtalk-stream] websocket_connect_failed=true error_kind=http http_status={} retry_after_present={} owner_gen={} attempt={}", + http_status, retry_after_present, owner_gen, attempt + ); + if http_status == 429 { + "websocket_connect_error:http:429".to_string() + } else { + format!("websocket_connect_error:http:{}", http_status) + } + } + Error::Io(io_error) => { + println!( + "[dingtalk-stream] websocket_connect_failed=true error_kind=io io_kind={:?} owner_gen={} attempt={}", + io_error.kind(), + owner_gen, + attempt + ); + "websocket_connect_error:io".to_string() + } + _ => { + println!( + "[dingtalk-stream] websocket_connect_failed=true error_kind={} owner_gen={} attempt={}", + kind.log_value(), + owner_gen, + attempt + ); + format!("websocket_connect_error:{}", kind.log_value()) + } + } +} + +fn safe_action(action: &str) -> String { + action + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + .take(48) + .collect() +} + +fn stream_error_kind(error: &str) -> String { + error + .split(':') + .next() + .unwrap_or("unknown") + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + .take(64) + .collect() +} + +// --------------------------------------------------------------------------- +// GatewayRuntime extensions for Stream +// --------------------------------------------------------------------------- + +impl GatewayRuntime { + /// Bounded enqueue for Stream robot messages via the runtime's existing worker queue. + pub async fn try_enqueue_dingtalk_stream_job( + &self, + payload: RobotPayload, + ) -> Result<(), String> { + // Build inbound message from payload + let text = payload + .text + .as_ref() + .map(|t| t.content.clone()) + .unwrap_or_default(); + + // Extract values we need for the inbound message + let sender_staff_id = payload.sender_staff_id.clone(); + let sender_id = payload.sender_id.clone(); + let conversation_id = payload.conversation_id.clone(); + let session_webhook = payload.session_webhook.clone(); + let robot_code = payload.robot_code.clone(); + let msg_id = payload.msg_id.clone(); + + let mut metadata = std::collections::HashMap::new(); + if let Some(ref id) = sender_staff_id { + metadata.insert("senderStaffId".to_string(), serde_json::json!(id)); + } + if let Some(ref id) = sender_id { + metadata.insert("senderId".to_string(), serde_json::json!(id)); + } + if let Some(ref id) = conversation_id { + metadata.insert("conversationId".to_string(), serde_json::json!(id)); + } + if let Some(ref webhook) = session_webhook { + metadata.insert("sessionWebhook".to_string(), serde_json::json!(webhook)); + } + if let Some(ref code) = robot_code { + metadata.insert("robotCode".to_string(), serde_json::json!(code)); + } + // Robot callback conversationType must reach the card target builder: + // "1" = single chat, "2" = group chat. Without this, `from_inbound` + // sees an empty type and falls back to a Direct (single-chat) target, + // misrouting group cards into the user's private chat. + metadata.insert( + "conversationType".to_string(), + serde_json::json!(payload.conversation_type.clone()), + ); + let raw_payload = serde_json::json!(payload); + metadata.insert("raw_payload".to_string(), raw_payload.clone()); + metadata.insert("source".to_string(), serde_json::json!("dingtalk")); + metadata.insert("stream_msg_id".to_string(), serde_json::json!(msg_id)); + + let inbound = InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: sender_staff_id.or(sender_id), + session_id: conversation_id, + text, + metadata, + }; + + let job = DingtalkAsyncJob::new(inbound, raw_payload); + + self.try_send_dingtalk_job(job) + .await + .map_err(|_| "queue_full".to_string()) + } + + /// Try to dedupe a Stream message using the runtime's dedup cache. + pub async fn try_dingtalk_stream_dedupe(&self, msg_id: &str) -> bool { + use std::time::Instant; + + let key = format!("dt_stream:{}", msg_id); + self.dedup_cache().check_and_insert(&key).await + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::pin::Pin; + use std::sync::{Arc as StdArc, Mutex as StdMutex}; + use std::task::{Context, Poll}; + + struct RecordingSink { + sent: StdArc>>, + } + + impl RecordingSink { + fn new() -> (Self, StdArc>>) { + let sent = StdArc::new(StdMutex::new(Vec::new())); + (Self { sent: sent.clone() }, sent) + } + } + + impl futures_util::Sink for RecordingSink { + type Error = tokio_tungstenite::tungstenite::Error; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + self.sent.lock().unwrap().push(item); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[test] + fn test_stream_state_serialization() { + assert_eq!(DingTalkStreamState::Disconnected as u8, 0); + assert_eq!(DingTalkStreamState::Connecting as u8, 1); + assert_eq!(DingTalkStreamState::Connected as u8, 2); + assert_eq!(DingTalkStreamState::Registered as u8, 3); + assert_eq!(DingTalkStreamState::Reconnecting as u8, 4); + assert_eq!(DingTalkStreamState::Stopping as u8, 5); + } + + #[test] + fn test_reconnect_delay_bounded() { + assert_eq!(reconnect_delay(0), Duration::from_secs(1)); + assert_eq!(reconnect_delay(1), Duration::from_secs(2)); + assert_eq!(reconnect_delay(2), Duration::from_secs(4)); + assert_eq!(reconnect_delay(3), Duration::from_secs(8)); + assert_eq!(reconnect_delay(4), Duration::from_secs(16)); + assert_eq!(reconnect_delay(5), Duration::from_secs(32)); + assert_eq!(reconnect_delay(6), Duration::from_secs(60)); + assert_eq!(reconnect_delay(99), Duration::from_secs(60)); + } + + #[test] + fn ws_connect_error_http_is_classified_with_status() { + // HTTP handshake rejection (e.g. 403/409) must be classified as + // error_kind=http and carry the status in the returned reason. + use tokio_tungstenite::tungstenite::Error; + let response = tokio_tungstenite::tungstenite::http::Response::builder() + .status(403) + .body(Some(Vec::::new())) + .unwrap(); + let error = Error::Http(Box::new(response)); + + let kind = DingtalkWsConnectErrorKind::classify(&error); + assert_eq!(kind, DingtalkWsConnectErrorKind::Http); + let reason = classify_ws_connect_error(&error, 2, 1); + assert_eq!(reason, "websocket_connect_error:http:403"); + } + + #[test] + fn ws_connect_error_http_429_is_classified_with_status() { + use tokio_tungstenite::tungstenite::Error; + let response = tokio_tungstenite::tungstenite::http::Response::builder() + .status(429) + .header("retry-after", "30") + .body(Some(Vec::::new())) + .unwrap(); + let error = Error::Http(Box::new(response)); + + let kind = DingtalkWsConnectErrorKind::classify(&error); + assert_eq!(kind, DingtalkWsConnectErrorKind::Http); + let reason = classify_ws_connect_error(&error, 2, 1); + assert_eq!(reason, "websocket_connect_error:http:429"); + } + + #[test] + fn ws_connect_error_io_is_classified() { + use std::io; + use tokio_tungstenite::tungstenite::Error; + let error = Error::Io(io::Error::from(io::ErrorKind::ConnectionReset)); + let kind = DingtalkWsConnectErrorKind::classify(&error); + assert_eq!(kind, DingtalkWsConnectErrorKind::Io); + let reason = classify_ws_connect_error(&error, 2, 1); + assert_eq!(reason, "websocket_connect_error:io"); + } + + #[test] + fn ws_connect_error_protocol_is_classified() { + use tokio_tungstenite::tungstenite::Error; + let error = Error::Protocol( + tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake, + ); + let kind = DingtalkWsConnectErrorKind::classify(&error); + assert_eq!(kind, DingtalkWsConnectErrorKind::Protocol); + let reason = classify_ws_connect_error(&error, 2, 1); + assert_eq!(reason, "websocket_connect_error:protocol"); + } + + #[test] + fn ws_connect_error_url_is_classified() { + use tokio_tungstenite::tungstenite::Error; + let error = Error::Url(tokio_tungstenite::tungstenite::error::UrlError::NoHostName); + let kind = DingtalkWsConnectErrorKind::classify(&error); + assert_eq!(kind, DingtalkWsConnectErrorKind::Url); + } + + #[test] + fn opaque_sha256_never_reveals_original_value() { + let digest = opaque_sha256("https://api.dingtalk.com/stream?ticket=secret-abc"); + assert_eq!(digest.len(), 12); + assert!(!digest.contains("dingtalk")); + assert!(!digest.contains("secret")); + // Deterministic: same input produces same digest. + assert_eq!( + digest, + opaque_sha256("https://api.dingtalk.com/stream?ticket=secret-abc") + ); + } + + #[test] + fn test_build_stream_ack() { + let envelope = StreamEnvelope { + spec_version: None, + frame_type: "CALLBACK".to_string(), + headers: StreamHeaders { + app_id: None, + connection_id: None, + message_id: Some("test-msg-id".to_string()), + content_type: Some("application/json".to_string()), + time: None, + topic: TOPIC_ROBOT.to_string(), + }, + data: "{}".to_string(), + }; + + let ack = build_stream_ack(&envelope, serde_json::json!({ "test": "data" })); + assert_eq!(ack["code"], 200); + assert_eq!(ack["headers"]["messageId"], "test-msg-id"); + assert_eq!(ack["message"], "OK"); + } + + #[test] + fn test_build_system_ack_for_ping() { + // Test that SYSTEM ping ACK uses build_system_ack + let envelope = StreamEnvelope { + spec_version: None, + frame_type: "SYSTEM".to_string(), + headers: StreamHeaders { + app_id: Some("app".to_string()), + connection_id: Some("conn".to_string()), + message_id: Some("ping-msg-id".to_string()), + content_type: Some("application/json".to_string()), + time: Some("0".to_string()), + topic: "ping".to_string(), + }, + data: "ping-data".to_string(), + }; + + let ack = build_system_ack(&envelope); + assert_eq!(ack["code"], 200); + assert_eq!(ack["headers"]["messageId"], "ping-msg-id"); + assert_eq!(ack["data"], "ping-data"); + } + + #[test] + fn test_robot_payload_parsing() { + // Real DingTalk Stream uses msgtype (lowercase), which maps to msg_type in Rust + let json = serde_json::json!({ + "msgId": "msg123", + "conversationType": "2", + "senderStaffId": "staff123", + "conversationId": "conv456", + "robotCode": "ding123", + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.msg_id, "msg123"); + assert_eq!(payload.conversation_type, "2"); + // msg_type comes from msgtype in JSON (optional field) + assert_eq!(payload.msg_type.as_deref(), Some("text")); + assert_eq!(payload.text.as_ref().unwrap().content, "hello"); + } + + #[test] + fn robot_callback_integer_timestamp_parses() { + // Real DingTalk delivers timestamps as integer milliseconds. + let json = serde_json::json!({ + "msgId": "msg-ts-int", + "conversationType": "2", + "senderStaffId": "staff1", + "conversationId": "conv1", + "sessionWebhookExpiredTime": 1786437210268i64, + "createAt": 1786437348372i64, + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.session_webhook_expired_time, Some(1786437210268)); + assert_eq!(payload.create_at, Some(1786437348372)); + assert_eq!(payload.text.as_ref().unwrap().content, "hello"); + } + + #[test] + fn robot_callback_string_timestamp_parses() { + // Some DingTalk paths deliver the same timestamps as string numbers. + let json = serde_json::json!({ + "msgId": "msg-ts-str", + "conversationType": "2", + "senderStaffId": "staff1", + "conversationId": "conv1", + "sessionWebhookExpiredTime": "1786437210268", + "createAt": "1786437348372", + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.session_webhook_expired_time, Some(1786437210268)); + assert_eq!(payload.create_at, Some(1786437348372)); + } + + #[test] + fn robot_callback_optional_timestamp_missing_parses() { + // Timestamps are optional metadata: missing must not fail parsing. + let json = serde_json::json!({ + "msgId": "msg-ts-none", + "conversationType": "2", + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.session_webhook_expired_time, None); + assert_eq!(payload.create_at, None); + assert_eq!(payload.msg_id, "msg-ts-none"); + } + + #[test] + fn robot_callback_null_timestamp_parses() { + // Null metadata must parse as None rather than failing the message. + let json = serde_json::json!({ + "msgId": "msg-ts-null", + "conversationType": "2", + "sessionWebhookExpiredTime": null, + "createAt": null, + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.session_webhook_expired_time, None); + assert_eq!(payload.create_at, None); + } + + #[test] + fn robot_callback_invalid_optional_timestamp_does_not_drop_message() { + let json = serde_json::json!({ + "msgId": "msg-ts-invalid", + "conversationType": "2", + "sessionWebhookExpiredTime": "not-a-timestamp", + "createAt": "", + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.session_webhook_expired_time, None); + assert_eq!(payload.create_at, None); + assert_eq!(payload.text.as_ref().unwrap().content, "hello"); + } + + #[test] + fn robot_callback_numeric_metadata_fields_parse() { + // Identity-ish metadata can arrive as numbers on some delivery paths. + let json = serde_json::json!({ + "msgId": "msg-num-meta", + "conversationType": 2, + "senderStaffId": 123456789, + "senderId": 987654321, + "conversationId": 1122334455, + "msgtype": "text", + "text": { "content": "hello" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.conversation_type, "2"); + assert_eq!(payload.sender_staff_id.as_deref(), Some("123456789")); + assert_eq!(payload.sender_id.as_deref(), Some("987654321")); + assert_eq!(payload.conversation_id.as_deref(), Some("1122334455")); + } + + #[test] + fn robot_callback_realistic_text_payload_parses() { + // Full realistic DingTalk text callback with timestamps as integers. + let data = serde_json::json!({ + "msgId": "msg-real-1", + "senderNick": "tester", + "isAdmin": false, + "chatbotCorpId": "dingcorp", + "senderStaffId": "staff-real", + "sessionWebhookExpiredTime": 1786437210268i64, + "createAt": 1786437348372i64, + "senderCorpId": "corp-real", + "conversationType": "2", + "senderId": "uid-real", + "conversationTitle": "test group", + "isInAtList": true, + "conversationId": "cid-real", + "atUsers": [], + "chatbotUserId": "bot-real", + "msgtype": "text", + "text": { "content": "@OmniNova 123" } + }); + + let envelope = StreamEnvelope { + spec_version: None, + frame_type: "CALLBACK".to_string(), + headers: StreamHeaders { + app_id: None, + connection_id: None, + content_type: Some("application/json".to_string()), + message_id: Some("stream-envelope-1".to_string()), + time: None, + topic: TOPIC_ROBOT.to_string(), + }, + data: data.to_string(), + }; + let payload = parse_robot_payload(&envelope).unwrap(); + assert_eq!(payload.msg_id, "msg-real-1"); + assert_eq!(payload.sender_nick.as_deref(), Some("tester")); + assert_eq!(payload.sender_staff_id.as_deref(), Some("staff-real")); + assert_eq!(payload.conversation_id.as_deref(), Some("cid-real")); + assert_eq!(payload.session_webhook_expired_time, Some(1786437210268)); + assert_eq!(payload.create_at, Some(1786437348372)); + assert_eq!(payload.msg_type.as_deref(), Some("text")); + assert_eq!(payload.text.as_ref().unwrap().content, "@OmniNova 123"); + } + + #[tokio::test] + async fn parsed_robot_callback_reaches_enqueue() { + let json = serde_json::json!({ + "msgId": "msg-enqueue-1", + "conversationType": "2", + "senderStaffId": "staff-enq", + "conversationId": "conv-enq", + "sessionWebhookExpiredTime": 1786437210268i64, + "msgtype": "text", + "text": { "content": "hello worker" } + }); + let envelope = StreamEnvelope { + spec_version: None, + frame_type: "CALLBACK".to_string(), + headers: StreamHeaders { + app_id: None, + connection_id: None, + content_type: Some("application/json".to_string()), + message_id: Some("stream-enqueue-1".to_string()), + time: None, + topic: TOPIC_ROBOT.to_string(), + }, + data: json.to_string(), + }; + let runtime = Arc::new(crate::gateway::GatewayRuntime::new( + crate::config::Config::default(), + )); + let (sender, mut receiver) = mpsc::channel(4); + *runtime.dingtalk_job_sender.write().await = Some(sender); + let (mut socket, sent) = RecordingSink::new(); + + handle_robot_callback(&mut socket, &runtime, &envelope) + .await + .unwrap(); + + let job = receiver.try_recv().expect("parsed callback must reach queue"); + assert_eq!(job.inbound.text, "hello worker"); + assert_eq!(sent.lock().unwrap().len(), 1, "enqueue success must ACK"); + } + + #[test] + fn malformed_core_text_payload_still_rejected() { + fn envelope(data: serde_json::Value) -> StreamEnvelope { + StreamEnvelope { + spec_version: None, + frame_type: "CALLBACK".to_string(), + headers: StreamHeaders { + app_id: None, + connection_id: None, + content_type: Some("application/json".to_string()), + message_id: Some("malformed-core".to_string()), + time: None, + topic: TOPIC_ROBOT.to_string(), + }, + data: data.to_string(), + } + } + + let missing_content = envelope(serde_json::json!({ + "msgId": "msg-bad-text", + "conversationType": "2", + "msgtype": "text", + "text": {} + })); + assert!( + parse_robot_payload(&missing_content).is_err(), + "missing text.content must be rejected" + ); + + let missing_text = envelope(serde_json::json!({ + "msgId": "msg-no-text", + "conversationType": "2", + "msgtype": "text" + })); + assert_eq!( + parse_robot_payload(&missing_text).unwrap_err(), + "core_error:missing_text" + ); + + // Missing required msgId must also fail. + let missing_id = envelope(serde_json::json!({ + "conversationType": "2", + "msgtype": "text", + "text": { "content": "no id" } + })); + assert!( + parse_robot_payload(&missing_id).is_err(), + "missing msgId must be rejected" + ); + } + + #[tokio::test] + async fn malformed_robot_callback_is_not_acked_as_success() { + let envelope = StreamEnvelope { + spec_version: None, + frame_type: "CALLBACK".to_string(), + headers: StreamHeaders { + app_id: None, + connection_id: None, + content_type: Some("application/json".to_string()), + message_id: Some("stream-malformed-1".to_string()), + time: None, + topic: TOPIC_ROBOT.to_string(), + }, + data: serde_json::json!({ + "msgId": "msg-malformed-1", + "conversationType": "2", + "msgtype": "text" + }) + .to_string(), + }; + let runtime = Arc::new(crate::gateway::GatewayRuntime::new( + crate::config::Config::default(), + )); + let (mut socket, sent) = RecordingSink::new(); + + handle_robot_callback(&mut socket, &runtime, &envelope) + .await + .unwrap(); + + assert!(sent.lock().unwrap().is_empty(), "parse failure must not ACK"); + } + + #[test] + fn sensitive_fields_not_visible_in_debug() { + // Debug formatting must never expose sessionWebhook / senderId / + // conversationId / robotCode raw values. + let json = serde_json::json!({ + "msgId": "SECRET_MESSAGE_ID", + "conversationType": "2", + "senderNick": "SECRET_NICK", + "senderStaffId": "SECRET_STAFF_ID", + "senderId": "SECRET_SENDER_123", + "conversationId": "SECRET_CONV_456", + "robotCode": "SECRET_ROBOT_789", + "sessionWebhook": "https://oapi.dingtalk.com/robot/send?access_token=SECRET_TOKEN_ABC", + "sessionWebhookExpiredTime": 1786437210268i64, + "msgtype": "text", + "text": { "content": "SECRET_MESSAGE_BODY" } + }); + + let payload: RobotPayload = serde_json::from_value(json).unwrap(); + let debug_str = format!("{:?}", payload); + + assert!(!debug_str.contains("SECRET_MESSAGE_ID")); + assert!(!debug_str.contains("SECRET_NICK")); + assert!(!debug_str.contains("SECRET_STAFF_ID")); + assert!(!debug_str.contains("SECRET_SENDER_123")); + assert!(!debug_str.contains("SECRET_CONV_456")); + assert!(!debug_str.contains("SECRET_ROBOT_789")); + assert!(!debug_str.contains("SECRET_TOKEN_ABC")); + assert!(!debug_str.contains("oapi.dingtalk.com")); + assert!(!debug_str.contains("SECRET_MESSAGE_BODY")); + } + + #[test] + fn robot_payload_field_types_logger_is_safe() { + // Type logger outputs key=type pairs only; no values, no secrets. + let data = r#"{ + "msgId": "msg-log-1", + "SECRET_FIELD_WITH_VALUE": "must-not-appear", + "sessionWebhook": "https://oapi.dingtalk.com/robot/send?access_token=TOP_SECRET", + "sessionWebhookExpiredTime": 1786437210268, + "createAt": 1786437348372, + "conversationType": "2", + "text": { "content": "hello" } + }"#; + + let summary = build_robot_payload_field_types(data) + .expect("valid payload must produce a type summary"); + + assert!(summary.contains("sessionWebhookExpiredTime=number"), "summary: {summary}"); + assert!(summary.contains("createAt=number"), "summary: {summary}"); + assert!(summary.contains("conversationType=string"), "summary: {summary}"); + assert!(summary.contains("text=object"), "summary: {summary}"); + assert!(!summary.contains("SECRET_FIELD_WITH_VALUE")); + assert!( + !summary.contains("TOP_SECRET"), + "type logger leaked a secret: {summary}" + ); + assert!( + !summary.contains("oapi.dingtalk.com"), + "type logger leaked a URL: {summary}" + ); + } + + #[test] + fn log_robot_payload_field_types_does_not_panic_on_bad_json() { + assert_eq!(build_robot_payload_field_types("not-json"), None); + assert_eq!(build_robot_payload_field_types(""), None); + assert_eq!(build_robot_payload_field_types("[]"), None); + } + + #[test] + fn test_safe_action() { + assert_eq!(safe_action("gateway_status"), "gateway_status"); + assert_eq!(safe_action("gateway; DROP TABLE"), "gatewayDROPTABLE"); + assert_eq!(safe_action("中文"), ""); + assert_eq!(safe_action(""), ""); + } + + #[test] + fn test_stream_error_kind() { + assert_eq!( + stream_error_kind("websocket_connect_error:something"), + "websocket_connect_error" + ); + assert_eq!(stream_error_kind("network_error:timeout"), "network_error"); + assert_eq!(stream_error_kind("simple_error"), "simple_error"); + } + + #[test] + fn test_short_hash() { + let h1 = short_hash("test-message-id-12345"); + let h2 = short_hash("test-message-id-12345"); + assert_eq!(h1, h2); // Same input = same hash + assert_eq!(h1.len(), 8); + + let h3 = short_hash("different-message-id"); + assert_ne!(h1, h3); // Different input = different hash + } + + #[test] + fn test_system_frame_parsing_connected() { + // Real DingTalk: SYSTEM type is in headers.topic, data may be empty + let json = serde_json::json!({ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "topic": "CONNECTED" + }, + "data": "" + }); + + let envelope: StreamEnvelope = serde_json::from_value(json).unwrap(); + assert_eq!(envelope.headers.topic, "CONNECTED"); + } + + #[test] + fn test_system_frame_parsing_registered() { + // Real DingTalk: REGISTERED has empty data, type is in headers.topic + let json = serde_json::json!({ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "topic": "REGISTERED", + "messageId": "msg-reg-123" + }, + "data": "" + }); + + let envelope: StreamEnvelope = serde_json::from_value(json).unwrap(); + assert_eq!(envelope.headers.topic, "REGISTERED"); + } + + #[test] + fn test_system_frame_parsing_ping() { + let json = serde_json::json!({ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "topic": "ping", + "messageId": "ping-123" + }, + "data": "" + }); + + let envelope: StreamEnvelope = serde_json::from_value(json).unwrap(); + assert_eq!(envelope.headers.topic, "ping"); + } + + #[test] + fn test_system_frame_parsing_disconnect() { + let json = serde_json::json!({ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "topic": "disconnect", + "messageId": "disc-123" + }, + "data": "" + }); + + let envelope: StreamEnvelope = serde_json::from_value(json).unwrap(); + assert_eq!(envelope.headers.topic, "disconnect"); + } + + #[test] + fn test_system_frame_parsing_keepalive() { + let json = serde_json::json!({ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "topic": "KEEPALIVE", + "messageId": "keep-123" + }, + "data": "" + }); + + let envelope: StreamEnvelope = serde_json::from_value(json).unwrap(); + assert_eq!(envelope.headers.topic, "KEEPALIVE"); + } + + #[test] + fn test_system_frame_unknown_topic_safe() { + // Unknown topic should not panic, just be logged as unknown + let json = serde_json::json!({ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "topic": "UNKNOWN_TOPIC_XYZ" + }, + "data": "" + }); + + let envelope: StreamEnvelope = serde_json::from_value(json).unwrap(); + assert_eq!(envelope.headers.topic, "UNKNOWN_TOPIC_XYZ"); + // Match against known topics - should fall into default branch + match envelope.headers.topic.as_str() { + "CONNECTED" | "REGISTERED" | "disconnect" | "ping" | "KEEPALIVE" => { + panic!("UNKNOWN_TOPIC_XYZ should not match any known topic"); + } + _ => { + // Expected: unknown topic falls through to default branch + } + } + } + + #[test] + fn test_stream_subscriptions_include_event_wildcard() { + // Verify the subscriptions payload includes EVENT * for SYSTEM frames + // Official DingTalk Stream SDK subscribes to EVENT * by default + let subscriptions = serde_json::json!([ + { "type": "EVENT", "topic": "*" }, + { "type": "CALLBACK", "topic": "/v1.0/im/bot/messages/get" }, + { "type": "CALLBACK", "topic": "/v1.0/card/instances/callback" } + ]); + + let sub_array = subscriptions.as_array().unwrap(); + assert_eq!(sub_array.len(), 3); + + // Check EVENT * is present + let event_sub = sub_array.iter().find(|s| s["type"] == "EVENT"); + assert!(event_sub.is_some(), "EVENT * subscription must be present"); + assert_eq!(event_sub.unwrap()["topic"], "*"); + + // Check CALLBACK robot is present + let robot_sub = sub_array + .iter() + .find(|s| s["type"] == "CALLBACK" && s["topic"] == "/v1.0/im/bot/messages/get"); + assert!( + robot_sub.is_some(), + "CALLBACK robot subscription must be present" + ); + + // Check CALLBACK card is present + let card_sub = sub_array + .iter() + .find(|s| s["type"] == "CALLBACK" && s["topic"] == "/v1.0/card/instances/callback"); + assert!( + card_sub.is_some(), + "CALLBACK card subscription must be present" + ); + + // Check no duplicate subscriptions + let topics: Vec<&str> = sub_array + .iter() + .filter_map(|s| s["topic"].as_str()) + .collect(); + let unique_topics: std::collections::HashSet<&str> = topics.iter().cloned().collect(); + assert_eq!( + topics.len(), + unique_topics.len(), + "No duplicate subscription topics" + ); + } + + #[test] + fn test_text_frame_json_parsing_registered() { + // Text frame with REGISTERED JSON parses correctly + let json = r#"{"specVersion":"1.0","type":"SYSTEM","headers":{"topic":"REGISTERED","messageId":"msg-reg-123"},"data":""}"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(envelope.headers.topic, "REGISTERED"); + assert_eq!(envelope.frame_type, "SYSTEM"); + } + + #[test] + fn test_binary_frame_json_parsing_registered() { + // Binary frame (UTF-8 encoded JSON) produces same result as Text + let json_str = r#"{"specVersion":"1.0","type":"SYSTEM","headers":{"topic":"REGISTERED","messageId":"msg-reg-123"},"data":""}"#; + + // Simulate Binary by encoding to bytes and decoding + let bytes = json_str.as_bytes(); + let decoded = std::str::from_utf8(bytes).unwrap(); + + let envelope: StreamEnvelope = serde_json::from_str(decoded).unwrap(); + assert_eq!(envelope.headers.topic, "REGISTERED"); + assert_eq!(envelope.frame_type, "SYSTEM"); + } + + #[test] + fn test_binary_invalid_utf8_no_panic() { + // Invalid UTF-8 bytes should not cause panic when attempting decode + let invalid_bytes: [u8; 4] = [0x80, 0x81, 0x82, 0x83]; // Invalid UTF-8 + + let result = std::str::from_utf8(&invalid_bytes); + assert!(result.is_err(), "Invalid UTF-8 should fail to decode"); + } + + #[test] + fn test_malformed_json_no_panic() { + // Malformed JSON should not panic during parsing + let malformed = r#"{"specVersion":"1.0","type":"SYSTEM","headers":{"topic":"REGISTERED""#; + + let result: Result = serde_json::from_str(malformed); + assert!(result.is_err(), "Malformed JSON should fail to parse"); + } + + #[test] + fn test_binary_and_text_produce_same_result() { + // Text and Binary (UTF-8) must produce identical parsing results + let json_str = + r#"{"specVersion":"1.0","type":"SYSTEM","headers":{"topic":"CONNECTED"},"data":""}"#; + + // Parse as Text + let envelope_text: StreamEnvelope = serde_json::from_str(json_str).unwrap(); + + // Parse as Binary (UTF-8) + let bytes = json_str.as_bytes(); + let decoded = std::str::from_utf8(bytes).unwrap(); + let envelope_binary: StreamEnvelope = serde_json::from_str(decoded).unwrap(); + + // Results must be identical + assert_eq!(envelope_text.frame_type, envelope_binary.frame_type); + assert_eq!(envelope_text.headers.topic, envelope_binary.headers.topic); + } + + #[test] + fn test_official_envelope_system_registered() { + // Official DingTalk Stream SDK envelope format for REGISTERED + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "test", + "connectionId": "test", + "contentType": "application/json", + "messageId": "test", + "time": "0", + "topic": "REGISTERED" + }, + "data": "" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(envelope.frame_type, "SYSTEM"); + assert_eq!(envelope.headers.topic, "REGISTERED"); + assert_eq!(envelope.data, ""); + assert!(envelope.headers.app_id.is_some()); + assert!(envelope.headers.connection_id.is_some()); + } + + #[test] + fn test_official_envelope_system_connected() { + // Official DingTalk Stream SDK envelope format for CONNECTED + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "test", + "connectionId": "test", + "contentType": "application/json", + "messageId": "test", + "time": "0", + "topic": "CONNECTED" + }, + "data": "" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(envelope.frame_type, "SYSTEM"); + assert_eq!(envelope.headers.topic, "CONNECTED"); + } + + #[test] + fn test_official_envelope_callback_robot() { + // Official DingTalk Stream SDK envelope format for robot callback + let json = r#"{ + "specVersion": "1.0", + "type": "CALLBACK", + "headers": { + "appId": "test", + "connectionId": "test", + "contentType": "application/json", + "messageId": "test", + "time": "0", + "topic": "/v1.0/im/bot/messages/get" + }, + "data": "{}" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(envelope.frame_type, "CALLBACK"); + assert_eq!(envelope.headers.topic, "/v1.0/im/bot/messages/get"); + } + + #[test] + fn test_official_envelope_callback_card() { + // Official DingTalk Stream SDK envelope format for card callback + let json = r#"{ + "specVersion": "1.0", + "type": "CALLBACK", + "headers": { + "appId": "test", + "connectionId": "test", + "contentType": "application/json", + "messageId": "test", + "time": "0", + "topic": "/v1.0/card/instances/callback" + }, + "data": "{}" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(envelope.frame_type, "CALLBACK"); + assert_eq!(envelope.headers.topic, "/v1.0/card/instances/callback"); + } + + #[test] + fn test_system_ack_preserves_message_id_and_data() { + // SYSTEM ping ACK must preserve original messageId and data + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "test-app", + "connectionId": "test-connection", + "contentType": "application/json", + "messageId": "msg-123", + "time": "0", + "topic": "ping" + }, + "data": "{\"opaque\":\"abc-123\"}" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + let ack = build_system_ack(&envelope); + + assert_eq!(ack["code"], 200); + assert_eq!(ack["message"], "OK"); + assert_eq!(ack["headers"]["messageId"], "msg-123"); + // Critical: data must be preserved exactly as-is + assert_eq!(ack["data"], "{\"opaque\":\"abc-123\"}"); + } + + #[test] + fn test_system_ack_empty_data() { + // SYSTEM ping with empty data must return empty data in ACK + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "test", + "connectionId": "test", + "contentType": "application/json", + "messageId": "msg-empty", + "time": "0", + "topic": "ping" + }, + "data": "" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + let ack = build_system_ack(&envelope); + + assert_eq!(ack["code"], 200); + assert_eq!(ack["message"], "OK"); + assert_eq!(ack["headers"]["messageId"], "msg-empty"); + assert_eq!(ack["data"], ""); + } + + #[test] + fn test_system_ack_disconnect_preserves_original() { + // SYSTEM disconnect ACK must preserve original messageId and data + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "test-disconnect", + "connectionId": "conn-456", + "contentType": "application/json", + "messageId": "disconnect-789", + "time": "1000", + "topic": "disconnect" + }, + "data": "{\"reason\":\"server_reboot\"}" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + let ack = build_system_ack(&envelope); + + assert_eq!(ack["code"], 200); + assert_eq!(ack["message"], "OK"); + assert_eq!(ack["headers"]["messageId"], "disconnect-789"); + assert_eq!(ack["data"], "{\"reason\":\"server_reboot\"}"); + } + + #[test] + fn test_system_ack_serialize_parse_roundtrip() { + // SYSTEM ACK must serialize to valid JSON and parse back correctly + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "app-id", + "connectionId": "conn-id", + "contentType": "text/plain", + "messageId": "roundtrip-test", + "time": "2000", + "topic": "ping" + }, + "data": "test-data-string" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + let ack = build_system_ack(&envelope); + + // Parse the ACK as a generic Value to verify structure + #[derive(Deserialize)] + struct AckFormat { + code: u32, + message: String, + headers: AckHeaders, + data: String, + } + #[derive(Deserialize)] + struct AckHeaders { + appId: Option, + connectionId: Option, + contentType: Option, + messageId: String, + time: Option, + topic: String, + } + + let ack_str = ack.to_string(); + let parsed: AckFormat = serde_json::from_str(&ack_str).unwrap(); + + assert_eq!(parsed.code, 200); + assert_eq!(parsed.message, "OK"); + assert_eq!(parsed.headers.messageId, "roundtrip-test"); + assert_eq!(parsed.headers.topic, "ping"); + assert_eq!(parsed.data, "test-data-string"); + } + + #[test] + fn test_system_ack_all_headers_preserved() { + // Verify all headers are echoed back in SYSTEM ACK + let json = r#"{ + "specVersion": "1.0", + "type": "SYSTEM", + "headers": { + "appId": "my-app-id", + "connectionId": "my-conn-id", + "contentType": "my-content-type", + "messageId": "my-msg-id", + "time": "1234567890", + "topic": "ping" + }, + "data": "" + }"#; + + let envelope: StreamEnvelope = serde_json::from_str(json).unwrap(); + let ack = build_system_ack(&envelope); + + assert_eq!(ack["headers"]["appId"], "my-app-id"); + assert_eq!(ack["headers"]["connectionId"], "my-conn-id"); + assert_eq!(ack["headers"]["contentType"], "my-content-type"); + assert_eq!(ack["headers"]["messageId"], "my-msg-id"); + assert_eq!(ack["headers"]["time"], "1234567890"); + assert_eq!(ack["headers"]["topic"], "ping"); + } +} diff --git a/crates/omninova-core/src/gateway/dingtalk_tests.rs b/crates/omninova-core/src/gateway/dingtalk_tests.rs new file mode 100644 index 00000000..3bd26da6 --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_tests.rs @@ -0,0 +1,3046 @@ +//! Tests for DingTalk integration (Phase 1) +//! +//! Required tests per Phase 1 spec: +//! - valid_dingtalk_signature_is_accepted +//! - invalid_dingtalk_signature_is_rejected +//! - dingtalk_text_message_is_normalized +//! - session_webhook_text_reply_success +//! - session_webhook_failure_returns_platform_error +//! - dingtalk_outbound_logs_redact_sensitive_values + +use crate::channels::{ChannelKind, InboundMessage}; +use crate::config::env::apply_env_overrides; +use crate::config::{Config, GatewayDingtalkConfig}; +use crate::gateway::dingtalk_worker::{ + fetch_dingtalk_access_token, hmac_sha256_base64, send_dingtalk_text_message, + verify_dingtalk_signature, verify_dingtalk_webhook_signature, +}; +use axum::http::HeaderMap; + +// ============================================================================= +// Environment-variable isolation helpers +// ============================================================================= +// +// `apply_env_overrides` (env.rs) and the resolver fallbacks in +// `gateway/mod.rs` both read the six `OMNINOVA_DINGTALK_*` process env vars +// at runtime. Test cases must therefore control those vars deterministically: +// any host-level value, leak from a sibling test, or out-of-order execution +// would otherwise flake these tests. +// +// `DingtalkEnvGuard` is a RAII guard that: +// 1. acquires a process-wide mutex (serializes env-var tests); +// 2. snapshots every existing `OMNINOVA_DINGTALK_*` value; +// 3. removes every one of them so the test starts from a clean slate; +// 4. on Drop, restores the snapshotted values (or removes them if absent). +// +// All tests that touch any of the six env vars must acquire this guard +// first; nothing else mutates the process env without going through it. + +/// The set of process env vars that DingTalk resolver / override code reads. +const DINGTALK_ENV_VARS: &[&str] = &[ + "OMNINOVA_DINGTALK_ENABLED", + "OMNINOVA_DINGTALK_APP_KEY", + "OMNINOVA_DINGTALK_APP_SECRET", + "OMNINOVA_DINGTALK_ROBOT_CODE", + "OMNINOVA_DINGTALK_WEBHOOK_PATH", + "OMNINOVA_DINGTALK_OUTBOUND_MODE", +]; + +/// Process-wide mutex serializing tests that mutate `OMNINOVA_DINGTALK_*`. +static DINGTALK_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Snapshot of the previous values for the env vars this guard is +/// tracking. Captures the canonical six at construction; additional keys +/// added via `set()` are appended lazily and restored on Drop. +#[derive(Debug)] +struct EnvSnapshot { + canonical: Vec<(&'static str, Option)>, + /// Extra keys the test set via `DingtalkEnvGuard::set`. + extra: Vec<(String, Option)>, +} + +impl EnvSnapshot { + fn capture_canonical() -> Self { + let canonical = DINGTALK_ENV_VARS + .iter() + .map(|k| (*k, std::env::var(k).ok())) + .collect(); + Self { + canonical, + extra: Vec::new(), + } + } + + fn clear_canonical(&self) { + for (k, _) in &self.canonical { + std::env::remove_var(k); + } + } + + /// Capture the previous value of an arbitrary key (used by `set` so + /// the value is restored on Drop). Idempotent per key: if the test + /// calls `set(same_key, ...)` twice, only the very first capture + /// survives, which is the correct semantics (the prior value at the + /// moment the test "took control" of the var is what we want to put + /// back later). + fn capture_extra(&mut self, key: &str) { + if self.extra.iter().any(|(k, _)| k == key) { + return; + } + self.extra.push((key.to_string(), std::env::var(key).ok())); + } + + fn restore(&self) { + for (k, prev) in &self.canonical { + match prev { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + for (k, prev) in &self.extra { + match prev { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + } +} + +/// RAII guard that gives the test exclusive, deterministic control over +/// the `OMNINOVA_DINGTALK_*` process env vars for its lifetime. +pub(crate) struct DingtalkEnvGuard { + /// Held for the guard's lifetime to serialize tests; the inner `()` + /// carries no data. + _lock: std::sync::MutexGuard<'static, ()>, + snapshot: std::cell::RefCell, +} + +impl DingtalkEnvGuard { + /// Acquire the env lock, snapshot the current values, then clear them + /// so the test starts from a known-clean state. + pub fn new() -> Self { + let lock = DINGTALK_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let snapshot = EnvSnapshot::capture_canonical(); + snapshot.clear_canonical(); + Self { + _lock: lock, + snapshot: std::cell::RefCell::new(snapshot), + } + } + + /// Set one env var for the duration of the test. Captures the prior + /// value (if any) so it can be restored on Drop. Safe to call with + /// any name, including the canonical six and per-test `*_FOR_TEST` + /// keys. + pub fn set(&self, key: &str, value: &str) { + self.snapshot.borrow_mut().capture_extra(key); + std::env::set_var(key, value); + } + + /// Convenience: remove the canonical six vars (same as on + /// construction). Useful when a test wants to "start over" mid-test. + pub fn clear_canonical(&self) { + self.snapshot.borrow().clear_canonical(); + } +} + +impl Drop for DingtalkEnvGuard { + fn drop(&mut self) { + // `RefCell::borrow` may panic if already borrowed mutably; that + // can only happen if `set` was called re-entrantly, which the + // `set` implementation does not do (it drops the borrow before + // returning). Snapshotting on Drop is therefore safe. + self.snapshot.borrow().restore(); + } +} + +impl Default for DingtalkEnvGuard { + fn default() -> Self { + Self::new() + } +} + +/// Run `f` with a clean `OMNINOVA_DINGTALK_*` environment. The guard is +/// active for the duration of the closure and the previous values are +/// restored on return. +/// +/// Use this for every test that asserts on resolver or override behavior +/// so host-level state can never bleed in. +pub(crate) fn with_clean_dingtalk_env(f: impl FnOnce(&DingtalkEnvGuard) -> R) -> R { + let guard = DingtalkEnvGuard::new(); + f(&guard) +} + +// ============================================================================= +// Signature verification tests +// ============================================================================= + +/// Test: valid HMAC-SHA256 signature is accepted +#[test] +fn valid_dingtalk_signature_is_accepted() { + let secret = "dingtalk-test-secret-123"; + let timestamp = "1723001234567"; + let sign_base = format!("{}\n{}", timestamp, secret); + let computed_sign = hmac_sha256_base64(&sign_base, secret); + + let result = verify_dingtalk_signature(timestamp, &computed_sign, secret); + assert!(result.is_ok(), "Valid signature should be accepted"); +} + +/// Test: invalid HMAC-SHA256 signature is rejected +#[test] +fn invalid_dingtalk_signature_is_rejected() { + let secret = "dingtalk-test-secret-456"; + let timestamp = "1723001234567"; + let wrong_sign = "aW52YWxpZC1zaWduYXR1cmUtZHVtbXk="; // "invalid-signature-dummy" + + let result = verify_dingtalk_signature(timestamp, wrong_sign, secret); + assert!(result.is_err(), "Invalid signature should be rejected"); + let err = result.unwrap_err(); + assert!( + err.contains("signature_mismatch"), + "Error should indicate signature mismatch, got: {}", + err + ); +} + +/// Test: signature with wrong secret is rejected +#[test] +fn dingtalk_signature_wrong_secret_rejected() { + let correct_secret = "correct-secret"; + let wrong_secret = "wrong-secret"; + let timestamp = "1723001234567"; + let sign_base = format!("{}\n{}", timestamp, correct_secret); + let sign_with_correct = hmac_sha256_base64(&sign_base, correct_secret); + + let result = verify_dingtalk_signature(timestamp, &sign_with_correct, wrong_secret); + assert!( + result.is_err(), + "Signature with wrong secret should be rejected" + ); +} + +/// Test: verify_dingtalk_webhook_signature passes when no secret (dev mode) +#[test] +fn dingtalk_webhook_signature_dev_mode_passes() { + let mut headers = HeaderMap::new(); + headers.insert("x-dingtalk-signature", "any".parse().unwrap()); + headers.insert("x-dingtalk-signature-for-isv", "any".parse().unwrap()); + + // No secret = dev mode = skip verification + let result = verify_dingtalk_webhook_signature(&headers, "{}", None); + assert!( + result.is_ok(), + "Dev mode (no secret) should accept any request" + ); +} + +/// Test: verify_dingtalk_webhook_signature fails with invalid signature when secret set +#[test] +fn dingtalk_webhook_signature_fails_with_invalid_sign() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-dingtalk-signature-for-isv", + "some-signature".parse().unwrap(), + ); + headers.insert( + "x-dingtalk-signature-for-isv-sign", + "some-sign".parse().unwrap(), + ); + headers.insert("timestamp", "1234567890".parse().unwrap()); + + let result = verify_dingtalk_webhook_signature(&headers, "{}", Some("test-secret")); + assert!(result.is_err(), "Invalid signature should be rejected"); +} + +/// Test: missing timestamp header is rejected when secret is configured +#[test] +fn dingtalk_webhook_signature_missing_timestamp_rejected() { + let mut headers = HeaderMap::new(); + // Include sign but no timestamp header + headers.insert("x-dingtalk-signature", "some-sign".parse().unwrap()); + + let result = verify_dingtalk_webhook_signature(&headers, "{}", Some("test-secret")); + assert!(result.is_err()); + let err = result.unwrap_err(); + // Either "missing_timestamp" or "signature_mismatch" is acceptable depending on + // whether the function finds a sign-only header and treats it as both timestamp and sign + assert!( + err.contains("missing_timestamp") || err.contains("signature_mismatch"), + "Should report missing timestamp or signature mismatch, got: {}", + err + ); +} + +/// Test: missing sign header is rejected when secret is configured +#[test] +fn dingtalk_webhook_signature_missing_sign_rejected() { + let mut headers = HeaderMap::new(); + headers.insert("timestamp", "1234567890".parse().unwrap()); + // No signature header + + let result = verify_dingtalk_webhook_signature(&headers, "{}", Some("test-secret")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("missing_sign"), + "Should report missing sign, got: {}", + err + ); +} + +// ============================================================================= +// Message normalization tests +// ============================================================================= + +/// Test: DingTalk text message payload is correctly normalized to InboundMessage +#[test] +fn dingtalk_text_message_is_normalized() { + let payload = serde_json::json!({ + "msgType": "text", + "text": { + "content": "Hello from DingTalk" + }, + "senderStaffId": "manager1234", + "conversationId": "cid_abc123", + "sessionWebhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx", + "messageId": "msg_xyz789", + "robotCode": "dingtalk_robot_001" + }); + + let inbound = parse_dingtalk_payload(&payload); + + assert_eq!(inbound.channel, ChannelKind::Dingtalk); + assert_eq!(inbound.text, "Hello from DingTalk"); + assert_eq!(inbound.user_id.as_deref(), Some("manager1234")); + assert_eq!(inbound.session_id.as_deref(), Some("cid_abc123")); + assert_eq!( + inbound + .metadata + .get("sessionWebhook") + .and_then(|v| v.as_str()), + Some("https://oapi.dingtalk.com/robot/send?access_token=xxx") + ); + assert_eq!( + inbound.metadata.get("messageId").and_then(|v| v.as_str()), + Some("msg_xyz789") + ); + assert_eq!( + inbound + .metadata + .get("senderStaffId") + .and_then(|v| v.as_str()), + Some("manager1234") + ); + assert_eq!( + inbound.metadata.get("source").and_then(|v| v.as_str()), + Some("dingtalk") + ); +} + +/// Test: DingTalk message without text field produces empty text +#[test] +fn dingtalk_message_without_text_is_empty() { + let payload = serde_json::json!({ + "msgType": "text", + "senderStaffId": "user999", + "conversationId": "c999", + }); + + let inbound = parse_dingtalk_payload(&payload); + assert_eq!(inbound.text, ""); + assert_eq!(inbound.user_id.as_deref(), Some("user999")); +} + +/// Test: DingTalk non-text message is filtered (no InboundMessage created from it) +#[test] +fn dingtalk_non_text_message_filtered() { + let payload = serde_json::json!({ + "msgType": "image", + "image": { "content": "base64..." }, + "senderStaffId": "user123", + "conversationId": "c123", + }); + + let is_text = payload.get("msgType").and_then(|v| v.as_str()) == Some("text"); + + assert!(!is_text, "Non-text message should not be treated as text"); +} + +// ============================================================================= +// Outbound / sessionWebhook tests +// ============================================================================= + +/// Test: sessionWebhook text reply payload is correctly formed +#[test] +fn session_webhook_text_reply_payload_correct() { + let token = "test_access_token_abc"; + let session_webhook = "https://oapi.dingtalk.com/robot/send?access_token=xyz"; + let conversation_id = "cid_123"; + let sender_staff_id = Some("staff_456"); + let text = "Hello from agent!"; + + let body = build_dingtalk_send_payload( + token, + session_webhook, + conversation_id, + sender_staff_id, + text, + ); + + assert_eq!( + body.get("msgKey").and_then(|v| v.as_str()), + Some("sampleText") + ); + assert_eq!( + body.get("msgParam") + .and_then(|v| v.get("content")) + .and_then(|v| v.as_str()), + Some("Hello from agent!") + ); + assert_eq!( + body.get("topLevelUnitId").and_then(|v| v.as_str()), + Some("cid_123") + ); + assert_eq!( + body.get("senderStaffId").and_then(|v| v.as_str()), + Some("staff_456") + ); + assert_eq!( + body.get("robotCode").and_then(|v| v.as_str()), + Some(session_webhook) + ); +} + +/// Test: token fetch error produces platform_error in result +#[tokio::test] +async fn session_webhook_failure_returns_platform_error() { + // Use an invalid app_key/app_secret to trigger an error response + // This tests that network errors are mapped to platform_error + let result = fetch_dingtalk_access_token("invalid_app_key", "invalid_secret").await; + + // Should be an Err (either network error or API error) + assert!( + result.is_err(), + "Invalid credentials should produce an error" + ); + let err = result.unwrap_err(); + + // Error should contain "token_error" or "http_error" or "network_error" + assert!( + err.contains("error") || err.contains("Error"), + "Error should be descriptive, got: {}", + err + ); +} + +/// Test: send_dingtalk_text_message handles API error response +#[tokio::test] +async fn dingtalk_send_api_error_is_captured() { + use crate::channels::InboundMessage; + + let token = "fake_token"; + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "robotCode".to_string(), + serde_json::json!("fake_robot_code"), + ); + metadata.insert( + "sessionWebhook".to_string(), + serde_json::json!("https://oapi.dingtalk.com/robot/send?access_token=fake"), + ); + metadata.insert( + "senderStaffId".to_string(), + serde_json::json!("fake_sender"), + ); + + let inbound = InboundMessage { + channel: crate::channels::ChannelKind::Dingtalk, + user_id: Some("fake_sender".to_string()), + session_id: Some("fake_cid".to_string()), + text: "test".to_string(), + metadata, + }; + let fallback_robot_code: Option<&str> = None; + let text = "test"; + + // This will fail because the token is invalid. + let result = send_dingtalk_text_message(token, &inbound, fallback_robot_code, text).await; + + assert!(result.is_err(), "Invalid token should produce an error"); + let err = result.unwrap_err(); + // Should be http_error or parse_error or token-related. + assert!( + err.contains("error") || err.contains("Error") || err.contains("err"), + "Error should be captured, got: {}", + err + ); +} + +// ============================================================================= +// Logging redaction tests +// ============================================================================= + +/// Test: sensitive values are redacted in log output +#[test] +fn dingtalk_outbound_logs_redact_sensitive_values() { + let app_secret = "dingtalk_real_secret_12345ABCDE"; + let access_token = "access_token_abcdef123456"; + let session_webhook = "https://oapi.dingtalk.com/robot/send?access_token=session_webhook_token"; + + let log_line = format!( + "[dingtalk-webhook] app_key_present=true app_secret_present=true token_prefix={} webhook_token_prefix={}", + redact_for_log_preview(&access_token), + redact_for_log_preview(session_webhook) + ); + + // The redacted log should NOT contain the full secrets + assert!( + !log_line.contains("access_token_abcdef"), + "Full access_token should be redacted" + ); + assert!( + !log_line.contains("session_webhook_token"), + "Full sessionWebhook token should be redacted" + ); + + // The redaction should produce something visible (not empty) + assert!( + log_line.contains("***") || log_line.contains("[") || log_line.contains("token"), + "Redacted log should still contain some indicator" + ); +} + +/// Test: app_secret is never printed in full +#[test] +fn dingtalk_app_secret_not_printed_in_logs() { + let app_secret = "my_super_secret_app_secret_xyz"; + + let log_output = format!("app_secret={}", redact_secret(&app_secret)); + + assert!( + !log_output.contains("my_super_secret"), + "Full secret should be redacted" + ); + assert!( + !log_output.contains("xyz"), + "End of secret should also be redacted" + ); +} + +/// Test: senderStaffId is redacted in logs +#[test] +fn dingtalk_sender_staff_id_redacted_in_logs() { + let staff_id = "staff_1234567890"; + let log_output = format!("sender={}", redact_for_log_preview(staff_id)); + + assert!( + !log_output.contains("1234567890"), + "Full staff ID should be redacted" + ); +} + +/// Test: conversationId is redacted in logs +#[test] +fn dingtalk_conversation_id_redacted_in_logs() { + let cid = "cid_long_conversation_id_abc123xyz"; + let log_output = format!("conversation={}", redact_for_log_preview(cid)); + + assert!( + !log_output.contains("long_conversation"), + "Full conversationId should be redacted" + ); +} + +// ============================================================================= +// Helper functions (replicate key logic for testing) +// ============================================================================= + +fn parse_dingtalk_payload(payload: &serde_json::Value) -> InboundMessage { + let text = payload + .get("text") + .and_then(|v| v.get("content")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let sender_staff_id = payload + .get("senderStaffId") + .and_then(|v| v.as_str()) + .map(String::from); + + let conversation_id = payload + .get("conversationId") + .and_then(|v| v.as_str()) + .map(String::from); + + let session_webhook = payload + .get("sessionWebhook") + .and_then(|v| v.as_str()) + .map(String::from); + + let message_id = payload + .get("messageId") + .and_then(|v| v.as_str()) + .map(String::from); + + let robot_code = payload + .get("robotCode") + .and_then(|v| v.as_str()) + .map(String::from); + + let mut metadata = std::collections::HashMap::new(); + if let Some(ref sid) = sender_staff_id { + metadata.insert("senderStaffId".to_string(), serde_json::json!(sid)); + } + if let Some(ref cid) = conversation_id { + metadata.insert("conversationId".to_string(), serde_json::json!(cid)); + } + if let Some(ref webhook) = session_webhook { + metadata.insert("sessionWebhook".to_string(), serde_json::json!(webhook)); + } + if let Some(ref mid) = message_id { + metadata.insert("messageId".to_string(), serde_json::json!(mid)); + } + if let Some(ref rc) = robot_code { + metadata.insert("robotCode".to_string(), serde_json::json!(rc)); + } + metadata.insert("source".to_string(), serde_json::json!("dingtalk")); + + InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: sender_staff_id, + session_id: conversation_id, + text, + metadata, + } +} + +fn build_dingtalk_send_payload( + token: &str, + session_webhook: &str, + conversation_id: &str, + sender_staff_id: Option<&str>, + text: &str, +) -> serde_json::Value { + let mut body = serde_json::json!({ + "robotCode": session_webhook, + "topLevelUnitId": conversation_id, + "msgKey": "sampleText", + "msgParam": serde_json::json!({ + "content": text, + }), + }); + + if let Some(staff_id) = sender_staff_id { + body["senderStaffId"] = serde_json::json!(staff_id); + } + + body +} + +/// Redact a string for log preview (show prefix only) +fn redact_for_log_preview(s: &str) -> String { + if s.len() <= 6 { + "***".to_string() + } else { + format!("{}***", &s[..4]) + } +} + +/// Redact a secret value fully +fn redact_secret(s: &str) -> String { + if s.len() <= 4 { + "***".to_string() + } else { + format!("{}***", &s[..4]) + } +} + +// ============================================================================= +// Phase 1 config-layer tests +// ============================================================================= + +/// Test: a default `Config` has `gateway.dingtalk.enabled == false`. +/// This is the master switch; until the user explicitly enables it +/// (and supplies real secrets via env), DingTalk must stay out of the +/// request path. +#[test] +fn dingtalk_config_defaults_disabled() { + let cfg = Config::default(); + assert!( + !cfg.gateway.dingtalk.enabled, + "DingTalk gateway config must default to disabled" + ); + assert_eq!( + cfg.gateway.dingtalk.app_key, + String::new(), + "DingTalk app_key must default to empty" + ); + assert_eq!( + cfg.gateway.dingtalk.app_secret, + String::new(), + "DingTalk app_secret must default to empty" + ); + assert_eq!( + cfg.gateway.dingtalk.outbound_mode, "session_webhook", + "DingTalk outbound_mode must default to session_webhook" + ); + assert!( + cfg.gateway.dingtalk.redact_sensitive_logs, + "Sensitive log redaction must default to true" + ); + assert_eq!( + cfg.gateway.dingtalk.webhook_path, "/api/v1/gateway/dingtalk/events", + "DingTalk webhook_path must default to /api/v1/gateway/dingtalk/events" + ); +} + +/// Test: the bundled `config.template.toml` contains a documented +/// `[gateway.dingtalk]` block with safe placeholder values and an explicit +/// comment that real secrets must come from the environment. This guards +/// against accidentally shipping a config template that omits the new block +/// or that hardcodes a real secret. +#[test] +fn dingtalk_config_template_contains_dingtalk_section() { + let template = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root") + .join("config.template.toml"), + ) + .expect("config.template.toml must be readable from the workspace root"); + + assert!( + template.contains("[gateway.dingtalk]"), + "config.template.toml must include a [gateway.dingtalk] section" + ); + + // Safe defaults — never commit a real secret. + assert!( + template.contains("app_secret = \"\""), + "Template app_secret must default to empty" + ); + assert!( + template.contains("app_key = \"\""), + "Template app_key must default to empty" + ); + + // Real-secret guidance must be present. + let lower = template.to_lowercase(); + assert!( + lower.contains("omninova_dingtalk_app_secret") + || lower.contains("omninova_dingtalk_app_key"), + "Template must reference OMNINOVA_DINGTALK_APP_* env vars" + ); + assert!( + lower.contains("never commit") + || lower.contains("must be supplied via") + || lower.contains("prefer env"), + "Template must warn against committing real secrets" + ); + + // Default disabled must be visible. + assert!( + template.contains("enabled = false"), + "Template must show enabled = false as the safe default" + ); +} + +/// Test: when the inline `gateway.dingtalk.app_secret` is empty, the +/// resolver falls back to the env var named in +/// `gateway.dingtalk.app_secret_env`. This is the documented "secret +/// stays out of config.toml" path. +/// +/// Precedence (first non-empty wins): +/// 1. channels_config.dingtalk.extra["app_secret"] (legacy compat) +/// 2. gateway.dingtalk.app_secret (inline) +/// 3. gateway.dingtalk.app_secret_env env var (named env var) +/// 4. OMNINOVA_DINGTALK_APP_SECRET (default env var) +#[test] +fn dingtalk_resolver_uses_env_when_inline_empty() { + with_clean_dingtalk_env(|guard| { + // Step 3: a custom-named env var carries the secret. + let env_name = "OMNINOVA_DINGTALK_APP_SECRET_FOR_TEST"; + guard.set(env_name, "env_secret_value_xyz_123"); + + let mut cfg = Config::default(); + // Step 2 (inline) is empty; step 3 (named env var) supplies the value. + cfg.gateway.dingtalk.app_secret = String::new(); + cfg.gateway.dingtalk.app_secret_env = Some(env_name.to_string()); + + let resolved = crate::gateway::dingtalk_worker::resolve_dingtalk_secret( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert_eq!( + resolved.as_deref(), + Some("env_secret_value_xyz_123"), + "Resolver must fall back to env var (step 3) when inline (step 2) is empty" + ); + }); +} + +/// Test: when the inline `gateway.dingtalk.app_secret` is non-empty, the +/// resolver returns it directly and never touches an env var. Inline +/// (step 2) strictly precedes env (step 3/4) so committed-but-empty +/// config files cannot have their secrets pulled from the host env +/// behind the operator's back. +#[test] +fn dingtalk_resolver_prefers_inline_config_before_env_fallback() { + with_clean_dingtalk_env(|guard| { + // Set a hostile env var that must NOT be picked up. + let env_name = "OMNINOVA_DINGTALK_APP_SECRET_FOR_TEST"; + guard.set(env_name, "env_secret_must_be_ignored"); + + let mut cfg = Config::default(); + cfg.gateway.dingtalk.app_secret = "inline_secret_wins".to_string(); + cfg.gateway.dingtalk.app_secret_env = Some(env_name.to_string()); + + let resolved = crate::gateway::dingtalk_worker::resolve_dingtalk_secret( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert_eq!( + resolved.as_deref(), + Some("inline_secret_wins"), + "Inline app_secret (step 2) must beat the named env var (step 3)" + ); + }); +} + +/// Test: a Config built from a stock template (no DingTalk section) keeps +/// DingTalk disabled and uses the documented default webhook path. This +/// guarantees that pre-existing user config files do not silently break. +/// +/// Runs under a clean env guard so a host-level `OMNINOVA_DINGTALK_*` +/// value (or a leak from a sibling test) cannot make the resolver return +/// a non-None value and trip the "nothing is set" assertions below. +#[test] +fn gateway_without_dingtalk_config_still_defaults_disabled() { + with_clean_dingtalk_env(|_guard| { + let mut cfg = Config::default(); + // Simulate an existing user config.toml with no [gateway.dingtalk] block. + cfg.gateway.dingtalk = GatewayDingtalkConfig::default(); + assert!(!cfg.gateway.dingtalk.enabled); + assert!(cfg.gateway.dingtalk.app_key.is_empty()); + assert!(cfg.gateway.dingtalk.app_secret.is_empty()); + assert!(cfg.gateway.dingtalk.robot_code.is_empty()); + assert_eq!(cfg.gateway.dingtalk.outbound_mode, "session_webhook"); + assert_eq!( + cfg.gateway.dingtalk.webhook_path, + "/api/v1/gateway/dingtalk/events" + ); + + // Resolvers return None when nothing is configured AND the env is + // clean. + let resolved = crate::gateway::dingtalk_worker::resolve_dingtalk_secret( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert!( + resolved.is_none(), + "No secret should resolve when nothing is set" + ); + + let resolved_key = crate::gateway::dingtalk_worker::resolve_dingtalk_app_key( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert!( + resolved_key.is_none(), + "No app_key should resolve when nothing is set" + ); + }); +} + +/// Test: env override path populates the inline `app_secret` field. +#[test] +fn dingtalk_env_overrides_populate_config() { + with_clean_dingtalk_env(|guard| { + guard.set("OMNINOVA_DINGTALK_APP_KEY", "env_app_key_abc"); + + let mut cfg = Config::default(); + apply_env_overrides(&mut cfg); + assert_eq!( + cfg.gateway.dingtalk.app_key, "env_app_key_abc", + "apply_env_overrides must populate gateway.dingtalk.app_key from env" + ); + }); +} + +/// Test: when `gateway.dingtalk` is fully populated via the env, the +/// `resolve_dingtalk_app_key` and `resolve_dingtalk_robot_code` helpers +/// pull values correctly. +#[test] +fn dingtalk_resolvers_pull_from_top_level_config() { + let mut cfg = Config::default(); + cfg.gateway.dingtalk.app_key = "my-app-key".to_string(); + cfg.gateway.dingtalk.app_secret = "my-app-secret".to_string(); + cfg.gateway.dingtalk.robot_code = "my-robot-code".to_string(); + + let key = crate::gateway::dingtalk_worker::resolve_dingtalk_app_key( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert_eq!(key.as_deref(), Some("my-app-key")); + + let secret = crate::gateway::dingtalk_worker::resolve_dingtalk_secret( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert_eq!(secret.as_deref(), Some("my-app-secret")); + + let robot = crate::gateway::dingtalk_worker::resolve_dingtalk_robot_code( + &cfg, + cfg.channels_config.dingtalk.as_ref(), + ); + assert_eq!(robot.as_deref(), Some("my-robot-code")); +} + +// ============================================================================= +// Enablement (master switch) tests +// ============================================================================= +// +// The `is_dingtalk_effectively_enabled` helper is the single source of +// truth for whether the `/webhook/dingtalk` route should accept traffic. +// Effective state is: +// `gateway.dingtalk.enabled == true` OR +// `channels_config.dingtalk.enabled == true` (legacy). +// Either switch being on is sufficient; both default to off. + +/// Test: enabling only the top-level `gateway.dingtalk` block is enough +/// for the webhook to accept traffic. The legacy per-channel flag is +/// optional. +#[test] +fn dingtalk_gateway_enabled_from_top_level_config() { + let mut cfg = Config::default(); + // Top-level on, legacy off (and channel entry missing entirely). + cfg.gateway.dingtalk.enabled = true; + cfg.channels_config.dingtalk = None; + + assert!( + crate::gateway::is_dingtalk_effectively_enabled_for_test(&cfg), + "Top-level gateway.dingtalk.enabled must be sufficient to accept traffic" + ); + + // Sanity: `gateway.dingtalk.enabled == false` and no legacy entry + // must remain disabled. + cfg.gateway.dingtalk.enabled = false; + cfg.channels_config.dingtalk = None; + assert!( + !crate::gateway::is_dingtalk_effectively_enabled_for_test(&cfg), + "Both switches off -> disabled" + ); +} + +/// Test: a default `Config` (no DingTalk entry anywhere) is disabled. +#[test] +fn dingtalk_gateway_disabled_by_default() { + let cfg = Config::default(); + assert!( + !crate::gateway::is_dingtalk_effectively_enabled_for_test(&cfg), + "Default Config must not accept DingTalk traffic" + ); + assert!( + !cfg.gateway.dingtalk.enabled, + "Top-level master switch must default to false" + ); + assert!( + cfg.channels_config.dingtalk.is_none(), + "Legacy channel entry must default to None" + ); +} + +/// Test: existing users who only set `channels_config.dingtalk.enabled` +/// (the legacy flag) keep working unchanged. Setting the top-level +/// master switch remains optional. +#[test] +fn dingtalk_gateway_legacy_channel_enabled_compat() { + use crate::config::schema::ChannelEntry; + + let mut cfg = Config::default(); + cfg.gateway.dingtalk.enabled = false; // top-level off + let mut legacy_entry = ChannelEntry::default(); + legacy_entry.enabled = true; + cfg.channels_config.dingtalk = Some(legacy_entry); + + assert!( + crate::gateway::is_dingtalk_effectively_enabled_for_test(&cfg), + "Legacy channels_config.dingtalk.enabled must still enable DingTalk" + ); + + // Reverse: legacy off, top-level on -> still enabled. + cfg.gateway.dingtalk.enabled = true; + cfg.channels_config.dingtalk.as_mut().unwrap().enabled = false; + assert!( + crate::gateway::is_dingtalk_effectively_enabled_for_test(&cfg), + "Top-level on must override legacy off (OR semantics)" + ); + + // Both off -> disabled. + cfg.gateway.dingtalk.enabled = false; + cfg.channels_config.dingtalk.as_mut().unwrap().enabled = false; + assert!( + !crate::gateway::is_dingtalk_effectively_enabled_for_test(&cfg), + "Both off -> disabled" + ); +} + +// ============================================================================= +// Phase 2 — DingTalk text command router +// ============================================================================= +// +// These tests assert that the command router produces the right reply text +// for known commands and that plain text falls through to the agent. They +// do NOT depend on any network, store, or runtime — they only exercise +// `dingtalk_commands::evaluate_dingtalk_command` and friends. + +use crate::gateway::dingtalk_commands::{ + build_dingtalk_help_text, build_dingtalk_menu_text, build_dingtalk_monitor_text, + build_dingtalk_ping_text, build_dingtalk_status_text, evaluate_dingtalk_command, + parse_dingtalk_command, strip_bot_mention, to_normalized_for_match, DingtalkCommand, + DingtalkStatusInputs, +}; + +/// Helper: a default `Config` for the command router. Phase 2 does not +/// require config to enable commands — the user might type `help` even +/// when the bot is disabled, to see what's going on. +fn cmd_test_config() -> Config { + let mut cfg = Config::default(); + cfg.gateway.dingtalk.enabled = false; // disabled is the default + cfg +} + +fn cmd_inputs<'a>(cfg: &'a Config) -> DingtalkStatusInputs<'a> { + DingtalkStatusInputs { + config: cfg, + worker_initialized: false, + queue_len: 0, + } +} + +/// Build a fake DingTalk payload containing a `text.content` field as +/// the real platform actually delivers it. Phase 1 currently copies +/// `payload.text` (a string) into `InboundMessage.text`; the command +/// router prefers the real `text.content` form when present. +fn build_dingtalk_payload(text_content: &str) -> serde_json::Value { + serde_json::json!({ + "msgType": "text", + "text": { "content": text_content }, + "senderStaffId": "staff-redact-me", + "conversationId": "conv-redact-me", + "sessionWebhook": "https://oapi.dingtalk.com/robot/hook?access_token=redact-me", + "messageId": "msg-redact-me", + "robotCode": "robot-redact-me", + }) +} + +/// Test: `help` returns the shared Agent menu text. +#[test] +fn dingtalk_help_command_returns_help_text() { + let cfg = cmd_test_config(); + let payload = build_dingtalk_payload("help"); + let result = evaluate_dingtalk_command("help", Some(&payload), cmd_inputs(&cfg)); + let (cmd, reply) = result.expect("help must be recognized as a command"); + assert_eq!(cmd, DingtalkCommand::Help); + assert!( + reply.contains("OmniNova Agent 功能菜单"), + "reply must contain the shared menu header (got {reply:?})" + ); + assert!( + reply.contains("status"), + "help text must mention status command" + ); + // Help text must be exactly the builder's output (snapshot to catch + // accidental formatting changes). + assert_eq!(reply, build_dingtalk_help_text()); +} + +/// Test: `menu` returns the shared Agent menu text. +#[test] +fn dingtalk_menu_command_returns_menu_text() { + let cfg = cmd_test_config(); + let payload = build_dingtalk_payload("menu"); + let (cmd, reply) = evaluate_dingtalk_command("menu", Some(&payload), cmd_inputs(&cfg)) + .expect("menu must be a command"); + assert_eq!(cmd, DingtalkCommand::Menu); + assert_eq!(reply, build_dingtalk_menu_text()); + for token in [ + "普通聊天说明", + "桌面监控 30 秒", + "桌面监控 60 秒", + "Gateway 状态", + "最近任务", + "帮助说明", + "高风险工具不在普通聊天中直接执行", + ] { + assert!( + reply.contains(token), + "menu must include `{token}` (got {reply:?})" + ); + } +} + +#[test] +fn dingtalk_menu_aliases_return_the_shared_menu() { + let cfg = cmd_test_config(); + for alias in [ + "menu", "/menu", "菜单", "panel", "/panel", "面板", "help", "帮助", + ] { + let payload = build_dingtalk_payload(alias); + let (_, reply) = evaluate_dingtalk_command(alias, Some(&payload), cmd_inputs(&cfg)) + .unwrap_or_else(|| panic!("{alias:?} must open the Agent menu")); + assert_eq!(reply, build_dingtalk_menu_text()); + } +} + +#[test] +fn dingtalk_menu_aliases_prefer_card_delivery() { + let cfg = cmd_test_config(); + for alias in [ + "menu", "/menu", "菜单", "panel", "/panel", "面板", "help", "帮助", + ] { + let payload = build_dingtalk_payload(alias); + let (command, _) = evaluate_dingtalk_command(alias, Some(&payload), cmd_inputs(&cfg)) + .unwrap_or_else(|| panic!("{alias:?} must open the Agent menu")); + assert!( + command.prefers_menu_card(), + "{alias:?} must prefer the interactive card" + ); + } +} + +/// Test: `status` output is redacted — no app_secret, no access_token, +/// no sessionWebhook URL, no sender/conversation/message/robot ids. +#[test] +fn dingtalk_status_command_returns_redacted_status() { + let mut cfg = cmd_test_config(); + cfg.gateway.dingtalk.app_secret = "super-secret-app-secret-value".to_string(); + cfg.gateway.dingtalk.app_key = "super-secret-app-key-value".to_string(); + cfg.gateway.dingtalk.robot_code = "super-secret-robot-code".to_string(); + + // Mirror the webhook's sessionWebhook value into the inbound + // metadata, exactly as Phase 1 does. If status leaks it, the + // redaction check below will fail. + let mut payload = build_dingtalk_payload("status"); + payload["sessionWebhook"] = + serde_json::json!("https://oapi.dingtalk.com/robot/hook?access_token=token-redact-me"); + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "sessionWebhook".to_string(), + serde_json::json!("webhook-redact-me"), + ); + metadata.insert( + "accessToken".to_string(), + serde_json::json!("token-redact-me-very-very-secret"), + ); + + let reply = build_dingtalk_status_text(DingtalkStatusInputs { + config: &cfg, + worker_initialized: true, + queue_len: 7, + }); + + assert_does_not_leak( + &reply, + "status", + &[ + "super-secret-app-secret-value", + "super-secret-app-key-value", + "super-secret-robot-code", + "token-redact-me", + "webhook-redact-me", + ], + ); + // The redaction notice must explicitly call out that webhook URLs + // are not shown, so operators can see at a glance that the output + // is intentionally redacted. + assert!( + reply.contains("no secrets") + && reply.contains("tokens") + && reply.contains("ids") + && reply.contains("webhook URLs are shown"), + "status must include a redaction disclaimer (got {reply:?})" + ); + assert!( + !reply.contains("access_token=") + && !reply.contains("accessToken") + && !reply.contains("sessionWebhook=") + && !reply.contains("senderStaffId=") + && !reply.contains("conversationId=") + && !reply.contains("messageId=") + && !reply.contains("robotCode="), + "status must NOT include raw field=value fragments (got {reply:?})" + ); + // Sanity: the redacted status must still mention the configured + // counts and states. + assert!( + reply.contains("enabled"), + "status must report enabled/disabled" + ); + assert!( + reply.contains("present"), + "status must report app_key present" + ); + assert!( + reply.contains("queue_count"), + "status must report queue_count" + ); + assert!(reply.contains("7"), "status must include the queue length"); + assert_eq!( + reply, + build_dingtalk_status_text(DingtalkStatusInputs { + config: &cfg, + worker_initialized: true, + queue_len: 7, + }) + ); +} + +fn assert_does_not_leak(haystack: &str, label: &str, needles: &[&str]) { + for needle in needles { + assert!( + !haystack.contains(needle), + "{label} reply must NOT contain {needle:?}; got: {haystack:?}" + ); + } +} + +/// Test: `ping` returns exactly `pong`. +#[test] +fn dingtalk_ping_command_returns_pong() { + let cfg = cmd_test_config(); + let payload = build_dingtalk_payload("ping"); + let (cmd, reply) = evaluate_dingtalk_command("ping", Some(&payload), cmd_inputs(&cfg)) + .expect("ping must be a command"); + assert_eq!(cmd, DingtalkCommand::Ping); + assert_eq!(reply, build_dingtalk_ping_text()); + assert_eq!(reply, "pong"); +} + +/// Test: `monitor` returns the explicit "not available" notice rather +/// than half-implementing the feature. +#[test] +fn dingtalk_monitor_command_returns_not_available_notice() { + let cfg = cmd_test_config(); + let payload = build_dingtalk_payload("monitor"); + let (cmd, reply) = evaluate_dingtalk_command("monitor", Some(&payload), cmd_inputs(&cfg)) + .expect("monitor must be a command"); + assert_eq!(cmd, DingtalkCommand::Monitor); + assert_eq!(reply, build_dingtalk_monitor_text()); + assert!(reply.contains("not available")); +} + +/// Test: `strip_bot_mention` removes the leading `@bot` token so that +/// `@bot help`, `/help`, and `help` all parse identically. Phase 2 +/// commands must work whether or not the user explicitly mentioned the +/// bot. +#[test] +fn dingtalk_command_strips_bot_mention() { + assert_eq!(strip_bot_mention("@bot help"), "help"); + assert_eq!(strip_bot_mention("@机器人菜单"), ""); + assert_eq!(strip_bot_mention(" @bot menu "), "menu"); + // No mention -> unchanged. + assert_eq!(strip_bot_mention("hello"), "hello"); + // Slash prefix on the bot-mention input is also handled by the + // normalizer, not the mention stripper. + assert_eq!(strip_bot_mention("@bot /help"), "/help"); + + // End-to-end: a payload whose `text.content` is "@bot help" must be + // recognized as the help command. + let cfg = cmd_test_config(); + let payload = build_dingtalk_payload("@bot help"); + let result = evaluate_dingtalk_command("@bot help", Some(&payload), cmd_inputs(&cfg)); + assert!( + result.is_some(), + "after mention strip, @bot help -> help must parse" + ); + assert_eq!(result.unwrap().0, DingtalkCommand::Help); +} + +/// Test: a leading `/` is stripped before matching. +#[test] +fn dingtalk_command_strips_leading_slash() { + let cfg = cmd_test_config(); + let payload = build_dingtalk_payload("/help"); + let (cmd, _) = evaluate_dingtalk_command("/help", Some(&payload), cmd_inputs(&cfg)) + .expect("/help must be a command"); + assert_eq!(cmd, DingtalkCommand::Help); + + // Mixed case + leading slash + spaces. + let payload2 = build_dingtalk_payload(" /STATUS "); + let (cmd, _) = evaluate_dingtalk_command(" /STATUS ", Some(&payload2), cmd_inputs(&cfg)) + .expect("/STATUS must be a command"); + assert_eq!(cmd, DingtalkCommand::Status); +} + +/// Test: plain text that is NOT a command returns `None` so the agent +/// pipeline keeps handling it exactly like Phase 1. +#[test] +fn dingtalk_unknown_plain_text_still_flows_to_agent() { + let cfg = cmd_test_config(); + // No payload -> helper falls back to raw_text. + let result = evaluate_dingtalk_command("tell me about rust", None, cmd_inputs(&cfg)); + assert!( + result.is_none(), + "ordinary text must NOT be classified as a command, got {result:?}" + ); + + // With a payload that does not contain a command. + let payload = build_dingtalk_payload("what is the weather like in Tokyo"); + let result = evaluate_dingtalk_command( + "what is the weather like in Tokyo", + Some(&payload), + cmd_inputs(&cfg), + ); + assert!( + result.is_none(), + "weather question must not parse as a command" + ); +} + +/// Test: Chinese aliases (`帮助`, `菜单`, `状态`) parse the same as the +/// ASCII forms. +#[test] +fn dingtalk_chinese_command_aliases_parse() { + assert_eq!(parse_dingtalk_command("帮助"), Some(DingtalkCommand::Help)); + assert_eq!(parse_dingtalk_command("菜单"), Some(DingtalkCommand::Menu)); + assert_eq!( + parse_dingtalk_command("状态"), + Some(DingtalkCommand::Status) + ); +} + +/// Test: extra redaction coverage on top of the inline status tests. +/// `to_normalized_for_match` and `parse_dingtalk_command` together +/// should never surface any of the forbidden strings to a reply. +#[test] +fn dingtalk_status_does_not_leak_secret_or_webhook() { + let mut cfg = Config::default(); + cfg.gateway.dingtalk.app_key = "leak-app-key".to_string(); + cfg.gateway.dingtalk.app_secret = "leak-app-secret".to_string(); + cfg.gateway.dingtalk.robot_code = "leak-robot-code".to_string(); + cfg.gateway.dingtalk.webhook_path = "/leak-webhook-path-do-not-show".to_string(); + + let reply = build_dingtalk_status_text(DingtalkStatusInputs { + config: &cfg, + worker_initialized: false, + queue_len: 0, + }); + + // `webhook_path` is shown (it's a route hint, not a secret), but the + // configured value must be the only thing that could leak. The + // exhaustive leak test below must not contain any of the secrets. + for forbidden in [ + "leak-app-key", + "leak-app-secret", + "leak-robot-code", + "leak-webhook-path", + "leak-app_key", + "leak-app_secret", + "leak-robot_code", + ] { + assert!( + !reply.contains(forbidden), + "status reply leaked {forbidden:?}: {reply:?}" + ); + } +} + +/// Test: `to_normalized_for_match` preserves the textual semantics for +/// non-commands (so the agent path can still echo them back via the +/// inbound metadata) — we only mutate a private copy. +#[test] +fn dingtalk_normalizer_does_not_mutate_known_plain_text() { + assert_eq!(to_normalized_for_match("hello world"), "hello world"); + // Chinese text stays as-is (we only ascii-case-fold). + assert_eq!(to_normalized_for_match("今天天气怎么样"), "今天天气怎么样"); + // Slash only trims; the remainder is left intact. + assert_eq!(to_normalized_for_match("/hello"), "hello"); +} + +// ============================================================================= +// Phase 3 — real-DingTalk integration regression tests +// ============================================================================= +// +// These tests pin the contract changes that fix the integration issues +// uncovered during real-platform integration. They are read-only and do +// not make network calls. (ChannelKind + InboundMessage are imported at +// the top of the file already.) + +/// Test: `extract_dingtalk_text` accepts the real DingTalk payload +/// shape (`text.content` nested object), which is what the live +/// enterprise app bot callback delivers. The Phase 1 handler used to +/// treat `text` as a flat string, silently dropping every real +/// callback as `empty_text`. +#[test] +fn dingtalk_extract_text_accepts_nested_text_content() { + let payload = serde_json::json!({ + "msgtype": "text", + "text": { "content": "hello world" } + }); + assert_eq!( + crate::gateway::extract_dingtalk_text_for_test(&payload), + "hello world" + ); +} + +/// Test: `extract_dingtalk_text` still accepts the legacy flat +/// `text` string for backward compatibility with tests and proxies. +#[test] +fn dingtalk_extract_text_accepts_flat_text_string() { + let payload = serde_json::json!({ + "msgType": "text", + "text": "hello world" + }); + assert_eq!( + crate::gateway::extract_dingtalk_text_for_test(&payload), + "hello world" + ); +} + +/// Test: `extract_dingtalk_text` returns empty when no text shape is +/// present (no panic, no garbage). The handler treats empty text as +/// `empty_text` and skips it. +#[test] +fn dingtalk_extract_text_missing_returns_empty() { + let payload = serde_json::json!({ + "msgType": "text", + "msgId": "abc" + }); + assert_eq!(crate::gateway::extract_dingtalk_text_for_test(&payload), ""); +} + +/// Test: real DingTalk callbacks send `msgtype` (lowercase). The +/// handler must accept that shape, not only the legacy `msgType`. +#[test] +fn dingtalk_msgtype_lowercase_is_recognized() { + let payload = serde_json::json!({ + "msgtype": "text", + "text": { "content": "hi" } + }); + let t = payload.get("msgType").or_else(|| payload.get("msgtype")); + let mt = t.and_then(|v| v.as_str()).map(String::from); + assert_eq!(mt.as_deref(), Some("text")); +} + +/// Test: real DingTalk callbacks send `msgId` (not `messageId`). +/// The handler must accept either spelling for downstream logging. +#[test] +fn dingtalk_msg_id_field_is_extracted_from_real_callback() { + let payload = serde_json::json!({ + "msgtype": "text", + "msgId": "platform-msg-id-123", + "text": { "content": "hi" } + }); + let message_id = payload + .get("messageId") + .or_else(|| payload.get("msgId")) + .and_then(|v| v.as_str()) + .map(String::from); + assert_eq!(message_id.as_deref(), Some("platform-msg-id-123")); +} + +/// Test: DingTalk's first connection URL-verification handshake must +/// be answered with the `challenge` echoed back so the platform +/// registers the callback URL. The handler's first-pass security +/// checks must not consume the challenge. +#[test] +fn dingtalk_url_verification_returns_challenge() { + // Mirrors what `http_dingtalk_webhook` returns on the + // `eventType=url_verification` branch. + let payload = serde_json::json!({ + "eventType": "url_verification", + "challenge": "test-challenge-token" + }); + let is_challenge = + payload.get("eventType").and_then(|v| v.as_str()) == Some("url_verification"); + let challenge = payload.get("challenge").and_then(|v| v.as_str()); + assert!(is_challenge); + assert_eq!(challenge, Some("test-challenge-token")); + + let response = serde_json::json!({ "challenge": challenge.unwrap() }); + assert_eq!( + response.get("challenge").and_then(|v| v.as_str()), + Some("test-challenge-token") + ); +} + +/// Test: route registration must include BOTH the legacy +/// `/webhook/dingtalk` and the documented +/// `/api/v1/gateway/dingtalk/events`. The latter is the URL printed in +/// `config.template.toml` and is what the DingTalk app-bot callback +/// wizard expects. +#[test] +fn dingtalk_both_route_paths_are_registered() { + // Build a real router and exercise its `route_data` to confirm the + // two paths are bound. Using `Router::with(...).route(...)` would + // require a tower::Service full setup, so we check the static + // mapping used by `register_routes` instead — that's the surface + // the gateway exposes to the rest of the codebase. + let known_paths = crate::gateway::dingtalk_known_route_paths_for_test(); + assert!( + known_paths.contains(&"/webhook/dingtalk"), + "legacy route must still be registered, got {known_paths:?}" + ); + assert!( + known_paths.contains(&"/api/v1/gateway/dingtalk/events"), + "documented callback URL must be registered, got {known_paths:?}" + ); +} + +/// Test: the inbound `InboundMessage` constructed for a real +/// callback must carry the `robotCode` field that `sendFromApp` +/// requires. Phase 1 was passing `session_webhook` into `robotCode`, +/// which the platform rejects. +#[test] +fn dingtalk_inbound_carries_robot_code_metadata() { + let payload = serde_json::json!({ + "msgtype": "text", + "robotCode": "real-dingtalk-robot-code", + "senderStaffId": "real-sender-staff-id", + "conversationId": "real-conversation-id", + "msgId": "real-msg-id", + "text": { "content": "hi" } + }); + + let robot_code = payload + .get("robotCode") + .and_then(|v| v.as_str()) + .map(String::from); + assert_eq!(robot_code.as_deref(), Some("real-dingtalk-robot-code")); + + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "robotCode".to_string(), + serde_json::json!(robot_code.unwrap()), + ); + metadata.insert( + "senderStaffId".to_string(), + serde_json::json!(payload.get("senderStaffId").unwrap().as_str().unwrap()), + ); + metadata.insert( + "conversationId".to_string(), + serde_json::json!(payload.get("conversationId").unwrap().as_str().unwrap()), + ); + + let inbound = InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: Some("real-sender-staff-id".to_string()), + session_id: Some("real-conversation-id".to_string()), + text: "hi".to_string(), + metadata, + }; + + let rc = inbound + .metadata + .get("robotCode") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from); + assert_eq!(rc.as_deref(), Some("real-dingtalk-robot-code")); +} + +/// Test: log lines must never print full message bodies or full +/// error payloads. The Phase 3 outbound path only ever logs +/// presence flags and length counts. +#[test] +fn dingtalk_logs_never_print_full_message_or_token() { + let secret = "APP_SECRET_VALUE_DO_NOT_LEAK"; + let token = "ACCESS_TOKEN_DO_NOT_LEAK"; + let session_webhook = "https://oapi.dingtalk.com/robot/send?access_token=DO_NOT_LEAK"; + let msg_id = "MESSAGE_ID_DO_NOT_LEAK"; + + // Build a representative log line following the Phase 3 contract. + let log_line = format!( + "[dingtalk-webhook] received msg_type=Some(\"text\") has_sender={} has_conversation={} has_webhook={} has_msgid={} has_robot_code={} text_len={}", + false, false, true, true, true, 9 + ); + + for forbidden in [secret, token, session_webhook, msg_id] { + assert!( + !log_line.contains(forbidden), + "log line leaked {forbidden:?}: {log_line:?}" + ); + } + + // The presence flags must always be present. + assert!(log_line.contains("has_sender=")); + assert!(log_line.contains("has_conversation=")); + assert!(log_line.contains("has_webhook=")); + assert!(log_line.contains("has_msgid=")); + assert!(log_line.contains("has_robot_code=")); + assert!(log_line.contains("text_len=")); +} + +/// Test: command router must still classify a real-callback text +/// (nested `text.content`) into the help command. Phase 1's broken +/// text extractor caused every real help request to be misclassified +/// as `empty_text` and skipped — the Phase 3 fix unblocks this. +#[test] +fn dingtalk_help_command_recognized_from_real_callback_shape() { + let payload = serde_json::json!({ + "msgtype": "text", + "text": { "content": "help" }, + "robotCode": "real-robot", + "conversationId": "real-conv", + "senderStaffId": "real-sender", + "msgId": "real-msg", + }); + let text = payload + .get("text") + .and_then(|v| v.get("content")) + .and_then(|v| v.as_str()) + .unwrap(); + assert_eq!(text, "help"); + + let cfg = cmd_test_config(); + let (cmd, _) = evaluate_dingtalk_command(text, Some(&payload), cmd_inputs(&cfg)) + .expect("help from real-callback shape must parse"); + assert_eq!(cmd, DingtalkCommand::Help); +} + +/// Test: command router must still work with the legacy flat-`text` +/// shape. This guarantees backwards compatibility for proxies and +/// tests. +#[test] +fn dingtalk_ping_command_recognized_from_legacy_shape() { + let payload = serde_json::json!({ + "msgType": "text", + "text": "ping", + }); + let cfg = cmd_test_config(); + let (cmd, _) = evaluate_dingtalk_command("ping", Some(&payload), cmd_inputs(&cfg)) + .expect("ping from legacy shape must parse"); + assert_eq!(cmd, DingtalkCommand::Ping); +} + +/// Test: the documented gateway port is `10809`. Real DingTalk's +/// cloudflared tunnel must forward to this port for the webhook to +/// arrive at the right place. +#[test] +fn dingtalk_default_gateway_port_is_10809() { + use crate::config::GatewayConfig; + let cfg = GatewayConfig::default(); + assert_eq!( + cfg.port, 10809, + "gateway default port must match config.template.toml" + ); + assert_eq!( + cfg.host, "127.0.0.1", + "default host must stay loopback; cloudflared forwards externally" + ); +} + +#[tokio::test] +async fn dingtalk_worker_init_is_idempotent_and_keeps_the_same_channel() { + let mut runtime = crate::gateway::GatewayRuntime::new(Config::default()); + runtime.init_dingtalk_worker().await; + let first = runtime + .dingtalk_job_sender + .read() + .await + .as_ref() + .cloned() + .expect("worker sender should be installed"); + assert!(!first.is_closed()); + + runtime.init_dingtalk_worker().await; + let second = runtime + .dingtalk_job_sender + .read() + .await + .as_ref() + .cloned() + .expect("worker sender should remain installed"); + + assert!(first.same_channel(&second)); + assert!(!second.is_closed()); +} + +#[tokio::test] +async fn dingtalk_reused_worker_sender_keeps_one_store_for_panel_callback() { + use crate::gateway::dingtalk_store::{ + DingtalkPanelContext, PanelContextLookup, now_for_tests, + }; + + let directory = std::env::temp_dir().join(format!( + "omninova-dingtalk-no-store-{}", + uuid::Uuid::new_v4() + )); + let mut config = Config::default(); + config.config_path = directory.join("config.toml"); + let mut runtime = crate::gateway::GatewayRuntime::new(config); + let mut callback_runtime = runtime.clone(); + + // Reproduce the old early-return path: sender already exists on both + // runtime clones, while the clone-local Option store used to remain None. + let worker_state = crate::gateway::dingtalk_worker::DingtalkWorkerState::new(); + let sender = worker_state.sender(); + *runtime.dingtalk_job_sender.write().await = Some(sender); + assert!(runtime.dingtalk_store().is_none()); + assert!(callback_runtime.dingtalk_store().is_none()); + + runtime.init_dingtalk_worker().await; + callback_runtime.init_dingtalk_worker().await; + let worker_store = runtime.dingtalk_store().expect("worker store initialized"); + let callback_store = callback_runtime + .dingtalk_store() + .expect("callback store initialized"); + assert!(std::sync::Arc::ptr_eq(&worker_store, &callback_store)); + + worker_store + .save_panel_context(DingtalkPanelContext::new( + "menu-track".to_string(), + Some("group-conversation".to_string()), + Some("robot".to_string()), + Some("https://example.invalid/session-webhook".to_string()), + Some("user".to_string()), + Some("dtv1.card//IM_GROUP.group-conversation".to_string()), + now_for_tests(), + )) + .await; + assert!(matches!( + callback_store.lookup_and_touch("menu-track").await, + PanelContextLookup::Hit(_) + )); + + drop(callback_store); + drop(worker_store); + drop(callback_runtime); + drop(runtime); + drop(worker_state); + let _ = std::fs::remove_dir_all(directory); +} + +// ============================================================================= +// TLS / rustls CryptoProvider tests +// ============================================================================= + +/// Test: ensure_rustls_crypto_provider does not panic +#[test] +fn dingtalk_stream_tls_provider_init_does_not_panic() { + // Calling ensure multiple times should not panic + crate::gateway::dingtalk_stream::ensure_rustls_crypto_provider(); + crate::gateway::dingtalk_stream::ensure_rustls_crypto_provider(); + crate::gateway::dingtalk_stream::ensure_rustls_crypto_provider(); +} + +/// Test: ensure_rustls_crypto_provider can be called from multiple threads +/// This simulates the tokio runtime spawning behavior +#[test] +fn dingtalk_stream_tls_provider_concurrent_init_does_not_panic() { + use std::sync::Arc; + use std::thread; + + let barrier = Arc::new(std::sync::Barrier::new(10)); + let handles: Vec<_> = (0..10) + .map(|_| { + let b = barrier.clone(); + thread::spawn(move || { + b.wait(); + crate::gateway::dingtalk_stream::ensure_rustls_crypto_provider(); + }) + }) + .collect(); + + for h in handles { + h.join().expect("thread should not panic"); + } +} + +/// Test: after ensure_rustls_crypto_provider, a default CryptoProvider exists +#[test] +fn dingtalk_stream_tls_provider_ensures_default_exists() { + use rustls::crypto::CryptoProvider; + + // Ensure initialization + crate::gateway::dingtalk_stream::ensure_rustls_crypto_provider(); + + // Verify a default provider is now installed + assert!( + CryptoProvider::get_default().is_some(), + "CryptoProvider::get_default() must be Some after ensure_rustls_crypto_provider()" + ); +} + +// ============================================================================= +// Advanced Card Panel tests +// ============================================================================= + +/// Test: all canonical actions are allowed in the Advanced Card panel +#[test] +fn advanced_card_allowlist_contains_all_canonical_actions() { + let allowed = ["gateway_status", "monitor_30s", "monitor_60s", "recent_jobs", "help"]; + for action in allowed { + assert!( + crate::gateway::dingtalk_card_stream::is_allowed_action(action), + "action {} should be allowed", + action + ); + } +} + +/// Test: unknown actions are rejected +#[test] +fn advanced_card_rejects_unknown_actions() { + let rejected = ["file_delete", "exec", "rm_rf", "", "unknown_action"]; + for action in rejected { + assert!( + !crate::gateway::dingtalk_card_stream::is_allowed_action(action), + "action {} should be rejected", + action + ); + } +} + +/// Test: dedupe cache prevents duplicate callbacks +#[test] +fn advanced_card_dedupe_cache_prevents_duplicates() { + use crate::gateway::dingtalk_card_stream::CallbackDedupeCache; + + let cache = CallbackDedupeCache::new(100); + + // A delivery retry is rejected while distinct callback identities pass. + let key1 = "track1:gateway_status"; + let key2 = "track1:recent_jobs"; + let key3 = "track2:gateway_status"; + + // First insert should succeed + assert!(cache.try_insert(key1)); + + // Same key should fail (duplicate) + assert!(!cache.try_insert(key1)); + + // Different key should succeed + assert!(cache.try_insert(key2)); + assert!(cache.try_insert(key3)); +} + +/// Test: HTTP mode should not call createAndDeliver +#[test] +fn advanced_card_not_available_in_http_mode() { + use crate::gateway::dingtalk_card::determine_card_availability; + use crate::config::schema::DingtalkTransportMode; + + // HTTP mode = not available + let availability = determine_card_availability( + DingtalkTransportMode::Http, + true, // template configured + true, // stream registered + true, // context complete + ); + assert!( + matches!(availability, crate::gateway::dingtalk_card::DingtalkCardAvailability::UnsupportedTransport), + "HTTP mode should return UnsupportedTransport" + ); +} + +/// Test: Stream mode requires template configured +#[test] +fn advanced_card_requires_template() { + use crate::gateway::dingtalk_card::determine_card_availability; + use crate::config::schema::DingtalkTransportMode; + + let availability = determine_card_availability( + DingtalkTransportMode::Stream, + false, // template NOT configured + true, // stream registered + true, // context complete + ); + assert!( + matches!(availability, crate::gateway::dingtalk_card::DingtalkCardAvailability::MissingTemplate), + "Missing template should return MissingTemplate" + ); +} + +/// Test: Stream mode requires stream to be registered +#[test] +fn advanced_card_requires_stream_registered() { + use crate::gateway::dingtalk_card::determine_card_availability; + use crate::config::schema::DingtalkTransportMode; + + let availability = determine_card_availability( + DingtalkTransportMode::Stream, + true, // template configured + false, // stream NOT registered + true, // context complete + ); + assert!( + matches!(availability, crate::gateway::dingtalk_card::DingtalkCardAvailability::StreamDisconnected), + "Stream disconnected should return StreamDisconnected" + ); +} + +/// Test: callback dedupe key is deterministic +#[test] +fn advanced_card_dedupe_key_is_deterministic() { + use crate::gateway::dingtalk_card_stream::public_opaque_short_hash; + + let key = "secret-track-123:gateway_status"; + let hash1 = public_opaque_short_hash(key); + let hash2 = public_opaque_short_hash(key); + + assert_eq!(hash1, hash2, "same key should produce same hash"); + assert_ne!(hash1, public_opaque_short_hash("different:key"), "different keys produce different hashes"); + assert_eq!(hash1.len(), 12, "hash should be 12 characters (6 bytes hex)"); + assert!(!hash1.contains("secret"), "hash should not contain original value"); +} + +/// Test: card update preserves outTrackId +#[test] +fn advanced_card_update_payload_preserves_outtrack_id() { + use crate::gateway::dingtalk_card::build_card_update_payload; + + let payload = build_card_update_payload( + "original-track-id-123", + "SUCCESS", + "Gateway 状态读取完成", + "status details here", + "gateway_status" + ); + + assert_eq!( + payload["outTrackId"].as_str().unwrap(), + "original-track-id-123", + "outTrackId should be preserved" + ); + assert_eq!( + payload["cardData"]["cardParamMap"]["status"].as_str().unwrap(), + "在线", + "successful status should be rendered as user-facing online state" + ); +} + +/// Test: menu card payload has correct initial state +#[test] +fn advanced_card_menu_payload_initial_state() { + use crate::gateway::dingtalk_card::{DingtalkCardTarget, build_menu_create_payload}; + + let target = DingtalkCardTarget::Direct { + user_id: "user-secret".to_string(), + robot_code: "robot-secret".to_string(), + }; + + let payload = build_menu_create_payload("template-123", "track-abc", &target); + + assert_eq!( + payload["cardData"]["cardParamMap"]["status"].as_str().unwrap(), + "在线", + "initial status should be user-facing online state" + ); + assert_eq!( + payload["callbackType"].as_str().unwrap(), + "STREAM", + "callback type should be STREAM" + ); +} + +/// Test: secrets never serialized into card payloads +#[test] +fn advanced_card_payload_contains_no_secrets() { + use crate::gateway::dingtalk_card::{DingtalkCardTarget, build_menu_create_payload}; + + let target = DingtalkCardTarget::Group { + open_conversation_id: "secret-conversation-id".to_string(), + robot_code: "secret-robot-code".to_string(), + user_id: Some("secret-user-id".to_string()), + }; + + let payload = build_menu_create_payload("template", "track", &target); + let serialized = payload.to_string(); + + // The openSpaceId field is expected to contain a predictable format including the conversation id + // That's OK - it's the same conversation ID that's sent to DingTalk anyway + // We just verify app_secret and access_token are not in the payload + let forbidden = [ + "app_secret", + "access_token", + ]; + + for f in forbidden { + assert!( + !serialized.contains(f), + "payload should not contain {}", + f + ); + } +} + +/// Test: canonical agent menu action aliases work +#[test] +fn advanced_card_canonical_action_resolution() { + use crate::gateway::agent_menu::canonical_agent_menu_action; + + // Direct matches + assert_eq!(canonical_agent_menu_action("gateway_status"), Some("gateway_status")); + assert_eq!(canonical_agent_menu_action("monitor_30s"), Some("monitor_30s")); + assert_eq!(canonical_agent_menu_action("monitor_60s"), Some("monitor_60s")); + assert_eq!(canonical_agent_menu_action("recent_jobs"), Some("recent_jobs")); + assert_eq!(canonical_agent_menu_action("help"), Some("help")); + + // Aliases + assert_eq!(canonical_agent_menu_action("desktop_monitor_30"), Some("monitor_30s")); + assert_eq!(canonical_agent_menu_action("desktop_monitor_60"), Some("monitor_60s")); + assert_eq!(canonical_agent_menu_action("recent_tasks"), Some("recent_jobs")); + + // Unknown + assert_eq!(canonical_agent_menu_action("evil"), None); + assert_eq!(canonical_agent_menu_action(""), None); +} + +// ============================================================================= +// Panel Stability Phase S1 tests +// ============================================================================= + +/// Test: same callback retry (same callback_id) is deduped. +#[test] +fn advanced_card_same_callback_id_retry_deduped() { + use crate::gateway::dingtalk_card_stream::callback_dedupe_key; + let first = callback_dedupe_key(Some("cb-A"), "track-1", "gateway_status", "fp-1"); + let retry = callback_dedupe_key(Some("cb-A"), "track-1", "gateway_status", "fp-2"); + assert_eq!(first, retry, "same callback_id must dedupe"); +} + +/// Test: distinct callback IDs for the same action are allowed. +#[test] +fn advanced_card_different_callback_ids_same_action_allowed() { + use crate::gateway::dingtalk_card_stream::callback_dedupe_key; + let a = callback_dedupe_key(Some("cb-A"), "track-1", "gateway_status", "fp"); + let b = callback_dedupe_key(Some("cb-B"), "track-1", "gateway_status", "fp"); + let c = callback_dedupe_key(Some("cb-C"), "track-1", "gateway_status", "fp"); + assert_ne!(a, b); + assert_ne!(b, c); + assert_ne!(a, c); +} + +/// Test: fallback dedupe key must include a fingerprint component so two +/// callbacks with no callback_id are not auto-deduped just by action+track. +#[test] +fn advanced_card_fallback_dedupe_key_includes_fingerprint() { + use crate::gateway::dingtalk_card_stream::callback_dedupe_key; + let fallback1 = callback_dedupe_key(None, "track-1", "gateway_status", "fp-1"); + let fallback2 = callback_dedupe_key(None, "track-1", "gateway_status", "fp-2"); + assert_ne!( + fallback1, fallback2, + "fallback dedupe key must change when fingerprint changes" + ); + let both_present1 = callback_dedupe_key(Some("cb-X"), "track-1", "gateway_status", "fp-1"); + let both_present2 = callback_dedupe_key(Some("cb-X"), "track-1", "gateway_status", "fp-2"); + assert_eq!( + both_present1, both_present2, + "when callback_id is present, fingerprint is ignored" + ); +} + +/// Test: monitor BUSY returns the dedicated busy PanelActionResult without a +/// detailed message body (so a later detail send never fires for the second +/// monitor attempt). +#[test] +fn advanced_card_busy_result_has_no_detailed_message() { + use crate::gateway::dingtalk_card_stream::PanelActionResult; + let result = PanelActionResult::busy("monitor_30s"); + assert!(result.busy); + assert!(!result.success); + assert!( + result.message_body.is_none(), + "BUSY must not carry a detailed message — otherwise the gateway \ + would reply as if the monitor actually completed" + ); +} + +/// Test: per-card generation increments and tracks ownership across claims. +#[tokio::test] +async fn advanced_card_per_card_generation_increments() { + use crate::gateway::dingtalk_store::DingtalkStore; + let store = DingtalkStore::new(); + let g1 = store.claim_card_generation("track-A").await; + let g2 = store.claim_card_generation("track-A").await; + let g3 = store.claim_card_generation("track-A").await; + assert!(g1 < g2); + assert!(g2 < g3); +} + +/// Test: a stale generation can no longer overwrite the card. +#[tokio::test] +async fn advanced_card_stale_generation_cannot_overwrite() { + use crate::gateway::dingtalk_store::DingtalkStore; + let store = DingtalkStore::new(); + let stale = store.claim_card_generation("track-X").await; + let _newer = store.claim_card_generation("track-X").await; + // Stale owner has lost — its terminal READY update must be refused. + assert!(!store.is_card_generation_current("track-X", stale).await); +} + +/// Test: different cards do not share generation state. +#[tokio::test] +async fn advanced_card_independent_cards_have_independent_generations() { + use crate::gateway::dingtalk_store::DingtalkStore; + let store = DingtalkStore::new(); + let a1 = store.claim_card_generation("card-A").await; + let b1 = store.claim_card_generation("card-B").await; + assert!(store.is_card_generation_current("card-A", a1).await); + assert!(store.is_card_generation_current("card-B", b1).await); + // Bumping A must not change B's current generation. + let _a2 = store.claim_card_generation("card-A").await; + assert!(store.is_card_generation_current("card-B", b1).await); +} + +/// Test: PanelContext persistence survives a runtime / cache drop and reload +/// from SQLite (covers the "restart recovery" path). +#[tokio::test] +async fn advanced_card_panel_context_survives_restart() { + use crate::gateway::dingtalk_store::{ + DingtalkPanelContext, DingtalkStore, dingtalk_store_test_path, + }; + let directory = dingtalk_store_test_path("restart"); + + // Original runtime writes a context. + let original = DingtalkStore::open(&directory).unwrap(); + original + .save_panel_context(DingtalkPanelContext::new( + "restart-track".to_string(), + Some("conv-secret".to_string()), + Some("robot-secret".to_string()), + Some("webhook-secret".to_string()), + Some("user-secret".to_string()), + Some("space-secret".to_string()), + crate::gateway::dingtalk_store::now_for_tests(), + )) + .await; + drop(original); + + // Fresh runtime (simulating restart) recovers the context from SQLite. + let recovered = DingtalkStore::open(&directory).unwrap(); + let lookup = recovered.get_panel_context("restart-track").await; + let context = lookup.expect_hit("panel context should reload from SQLite"); + assert_eq!(context.out_track_id, "restart-track"); + assert!(context.conversation_id.is_some()); + assert!(context.robot_code.is_some()); + assert!(context.session_webhook.is_some()); + assert!(context.user_id.is_some()); + assert!(context.space_id.is_some()); + + // Sensitive values must not leak through Debug, even after reload. + let debug = format!("{context:?}"); + assert!(!debug.contains("webhook-secret")); + assert!(!debug.contains("conv-secret")); + assert!(!debug.contains("robot-secret")); + + let _ = std::fs::remove_dir_all(directory); +} + +/// Test: missing/expired context ACK still happens (the gateway doesn't drop +/// ACKs on user-visible card failures). +#[test] +fn advanced_card_context_lookup_distinguishes_outcomes() { + use crate::gateway::dingtalk_store::PanelContextLookup; + let missing = PanelContextLookup::Missing; + let expired = PanelContextLookup::Expired; + assert!(missing.is_missing()); + assert!(!missing.is_expired()); + assert!(expired.is_expired()); + assert!(!expired.is_missing()); +} + +/// Test: dedup_cache is shared between HTTP webhook and Stream so a duplicate +/// delivery on both transports only produces one business execution. +#[tokio::test] +async fn advanced_card_dedup_cache_shared_between_http_and_stream() { + let runtime = crate::gateway::GatewayRuntime::new(Config::default()); + let cache = runtime.dedup_cache(); + + // Stream side claims the key. + let stream_first = cache + .check_and_insert("dt_stream:msg-abc") + .await; + assert!(stream_first, "Stream must be allowed to claim the key"); + + // HTTP side checks the same key. + let http_second = cache + .check_and_insert("dt_stream:msg-abc") + .await; + assert!( + !http_second, + "HTTP side must observe the same key and dedupe" + ); +} + +/// Test: idempotent DingTalk Stream start prevents a second reconnect loop. +#[tokio::test] +async fn dingtalk_stream_start_is_idempotent_per_runtime() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + + // First owner acquires. + let (gen1, rx1) = runtime.try_acquire_stream_owner(); + assert_eq!(gen1, 1); + assert!(rx1.is_some()); + + // Second acquire must fail (already owned). + let (gen2, rx2) = runtime.try_acquire_stream_owner(); + assert_eq!(gen2, 1, "current gen unchanged"); + assert!(rx2.is_none(), "second acquire must be rejected"); + + // Releasing re-opens the slot. + runtime.release_stream_owner(gen1); + assert!(!runtime.is_dingtalk_stream_active()); + let (gen3, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen3, 2, "gen increments after release"); + runtime.release_stream_owner(gen3); +} + +/// Test: async worker lifecycle is safe — repeated `init_dingtalk_worker` +/// does not replace the live sender. +#[tokio::test] +async fn dingtalk_async_worker_does_not_capture_stale_runtime() { + let mut runtime = crate::gateway::GatewayRuntime::new(Config::default()); + runtime.init_dingtalk_worker().await; + let first_sender = runtime.dingtalk_job_sender.read().await.clone(); + assert!(first_sender.is_some()); + + // The async worker's runtime capture is `Arc`, which is + // the same Arc we hold here. After a second init, the sender must + // remain the same channel — it cannot be silently replaced with a + // closed one from a new channel whose receiver is in the previous + // worker task. This test ensures the `init_dingtalk_worker` idempotency + // is preserved at the data-structure level (no replace on already-set). + runtime.init_dingtalk_worker().await; + let second_sender = runtime.dingtalk_job_sender.read().await.clone(); + assert!(second_sender.is_some()); + assert!( + first_sender.unwrap().same_channel(&second_sender.clone().unwrap()), + "second init must not replace the live sender" + ); +} + +/// Test: gateway startup does not accidentally spawn duplicate DingTalk +/// Stream workers because the runtime's idempotency guard is honored. +#[tokio::test] +async fn gateway_startup_does_not_spawn_duplicate_stream_worker() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + // Simulate two consecutive stream start attempts (mirrors a Tauri + // startup double-trigger or a config-save race). The second must be + // rejected by the owner guard. + let (gen1, rx1) = runtime.try_acquire_stream_owner(); + assert!(rx1.is_some(), "first acquire must succeed gen={}", gen1); + + let (gen2, rx2) = runtime.try_acquire_stream_owner(); + assert!(rx2.is_none(), "second acquire must be rejected gen={}", gen2); + + runtime.release_stream_owner(gen1); +} + +/// Test: card restore failure does not rerun the business action. The +/// `process_panel_action` function uses `card_restore_failed=true` log +/// semantics and does NOT re-invoke the business handler when the final +/// PUT update fails — that is verified here at the unit level by +/// confirming the dedup cache and generation both guard the operation. +#[tokio::test] +async fn card_restore_failure_does_not_rerun_business_action() { + use crate::gateway::dingtalk_store::DingtalkStore; + let store = DingtalkStore::new(); + // The dedupe cache at the stream layer prevents the same callback + // delivery from being processed twice. The generation token prevents + // a stale owner from issuing terminal updates. Both are required so a + // failed card PUT does not cascade into a second execution path. + let cache = crate::gateway::dingtalk_card_stream::CallbackDedupeCache::new(8); + assert!(cache.try_insert("track:gateway_status")); + assert!(!cache.try_insert("track:gateway_status")); + let _ = store.claim_card_generation("track").await; +} + +/// Test: 32 concurrent tasks claiming the same outTrackId must each receive +/// a distinct, monotonically increasing generation. This validates that +/// the RwLock over HashMap serializes all claim operations, and that no +/// generation number is skipped or duplicated under high contention. +#[tokio::test(flavor = "multi_thread")] +async fn card_generation_32_concurrent_tasks_all_distinct() { + let store = crate::gateway::dingtalk_store::DingtalkStore::new(); + let track_id = "concurrent-track-42".to_string(); + + // Spawn 32 concurrent tasks, each claiming the same outTrackId. + let handles: Vec<_> = (0u32..32) + .map(|_| { + let store = store.clone(); + let track_id = track_id.clone(); + tokio::spawn(async move { + store.claim_card_generation(&track_id).await + }) + }) + .collect(); + + let mut generations: Vec = Vec::with_capacity(32); + for h in handles { + generations.push(h.await.expect("task must not panic")); + } + + // All generations must be unique and form 1..32. + generations.sort(); + for (i, &gen) in generations.iter().enumerate() { + assert_eq!(gen, (i + 1) as u64, "generation {i} must be {}+1", i); + } + assert_eq!(generations.len(), 32, "must have collected all 32 generations"); + + // Final state must show current generation is 32. + let final_gen = store + .current_card_generation(&track_id) + .await + .expect("generation must exist after all claims"); + assert_eq!(final_gen, 32); +} + +/// Test: two distinct outTrackIds never share a generation counter — each +/// card maintains its own independent sequence. +#[tokio::test(flavor = "multi_thread")] +async fn card_generation_independent_per_track() { + let store = crate::gateway::dingtalk_store::DingtalkStore::new(); + + let handles_a: Vec<_> = (0u32..8) + .map(|_| { + let store = store.clone(); + tokio::spawn(async move { store.claim_card_generation("card-A").await }) + }) + .collect(); + let handles_b: Vec<_> = (0u32..8) + .map(|_| { + let store = store.clone(); + tokio::spawn(async move { store.claim_card_generation("card-B").await }) + }) + .collect(); + + let mut gens_a: Vec = Vec::with_capacity(8); + for h in handles_a { + gens_a.push(h.await.expect("task A must not panic")); + } + let mut gens_b: Vec = Vec::with_capacity(8); + for h in handles_b { + gens_b.push(h.await.expect("task B must not panic")); + } + + // Each set of 8 must be 1..8 with no overlap. + let mut all_a = gens_a.clone(); + all_a.sort(); + let mut all_b = gens_b.clone(); + all_b.sort(); + for (i, &gen) in all_a.iter().enumerate() { + assert_eq!(gen, (i + 1) as u64); + } + for (i, &gen) in all_b.iter().enumerate() { + assert_eq!(gen, (i + 1) as u64); + } + assert_eq!(gens_a.len(), 8); + assert_eq!(gens_b.len(), 8); +} + +/// Test: the BUSY result does not get its own generation claim. The only +/// claim in `process_panel_action` is the one at the top (line 501). BUSY +/// follows the same `is_card_generation_current(gen)` gate as READY updates. +/// This means if a running monitor bumps the generation while a stale callback +/// is being processed, that stale callback's BUSY update is also blocked, +/// preserving the running monitor's terminal READY ownership. +#[tokio::test] +async fn busy_result_shares_same_generation_gate_as_ready() { + let store = crate::gateway::dingtalk_store::DingtalkStore::new(); + + // Simulate a running monitor bumping the generation to 5. + for _ in 0..5 { + store.claim_card_generation("monitor-card").await; + } + + // A stale callback arrives, claims generation 6. + let stale_gen = store.claim_card_generation("monitor-card").await; + assert_eq!(stale_gen, 6); + + // But a second monitor already bumped it to 7. + let current_gen = store.claim_card_generation("monitor-card").await; + assert_eq!(current_gen, 7); + + // Both BUSY and READY gates check generation 6 — neither passes. + assert!( + !store.is_card_generation_current("monitor-card", 6).await, + "generation 6 must not be current (was bumped to 7)" + ); + + // The running monitor's generation 7 is current. + assert!(store.is_card_generation_current("monitor-card", 7).await); + + // Confirm: generation 6 is genuinely stale. + assert!(!store.is_card_generation_current("monitor-card", 5).await); +} + +/// Test: StreamOwner ABA-safety — old owners cannot clear new owners' state. +#[tokio::test] +async fn stream_owner_aba_old_cannot_clear_new() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + + // Owner A acquires. + let (gen_a, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen_a, 1); + assert!(runtime.is_dingtalk_stream_active()); + + // Owner A releases. + runtime.release_stream_owner(gen_a); + assert!(!runtime.is_dingtalk_stream_active()); + + // Owner B acquires. + let (gen_b, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen_b, 2, "generation must increment"); + assert!(runtime.is_dingtalk_stream_active()); + + // Owner A's release (called late, e.g. from cleanup) must not affect B. + runtime.release_stream_owner(gen_a); // stale: gen_a != current gen_b + assert!( + runtime.is_dingtalk_stream_active(), + "stale old owner release must not deactivate current owner" + ); + + // B can still release correctly. + runtime.release_stream_owner(gen_b); + assert!(!runtime.is_dingtalk_stream_active()); + + // Fresh C acquires. + let (gen_c, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen_c, 3, "generation continues incrementing"); +} + +/// Test: concurrent owners — only one may acquire. +#[tokio::test] +async fn stream_owner_only_one_at_a_time() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + + let (gen1, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen1, 1); + + // Second acquire must fail. + let (gen2, rx2) = runtime.try_acquire_stream_owner(); + assert_eq!(gen2, 1, "current gen unchanged"); + assert!(rx2.is_none(), "second acquire must be rejected"); + + // Original releases. + runtime.release_stream_owner(gen1); + assert!(!runtime.is_dingtalk_stream_active()); + + // Now second can acquire. + let (gen3, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen3, 2, "gen must increment from previous"); + runtime.release_stream_owner(gen3); +} + +// --------------------------------------------------------------------------- +// DingtalkMonitorGuard tests (S1.6) +// --------------------------------------------------------------------------- + +/// Test: monitor admission - first monitor acquires, second is busy. +#[tokio::test] +async fn dingtalk_monitor_guard_first_wins() { + let guard = crate::gateway::DingtalkMonitorGuard::new(); + + let first = guard.try_acquire("track-1").await; + assert!(first.is_some(), "first acquire must succeed"); + + let second = guard.try_acquire("track-1").await; + assert!(second.is_none(), "second acquire must fail (busy)"); + + // Different track is still free. + let other = guard.try_acquire("track-2").await; + assert!(other.is_some(), "different track must be free"); +} + +/// Test: monitor admission - release allows new acquisition. +#[tokio::test] +async fn dingtalk_monitor_guard_release_allows_reacquire() { + let guard = crate::gateway::DingtalkMonitorGuard::new(); + + let owner = guard.try_acquire("track-1").await.expect("first"); + assert!(guard.release("track-1", &owner).await); + + let second = guard.try_acquire("track-1").await; + assert!(second.is_some(), "after release, new acquire must succeed"); +} + +/// Test: monitor admission - stale owner cannot release a replaced entry. +/// Scenario: first acquires → second fails (entry still owned) → first releases +/// (removes entry) → second acquires (now succeeds). +#[tokio::test] +async fn dingtalk_monitor_guard_stale_owner_rejected() { + let guard = crate::gateway::DingtalkMonitorGuard::new(); + + let first = guard.try_acquire("track-1").await.expect("first"); + + // Second cannot acquire while first holds it. + let second = guard.try_acquire("track-1").await; + assert!(second.is_none(), "second must be busy while first holds"); + + // First releases → removes entry. + assert!(guard.release("track-1", &first).await, "first must release successfully"); + + // Now second can acquire. + let second_owner = guard.try_acquire("track-1").await; + assert!(second_owner.is_some(), "after release, second must succeed"); + + // first's release token is now stale (entry is gone). + let released_stale = guard.release("track-1", &first).await; + assert!(!released_stale, "stale owner_id must not release new entry"); +} + +/// Test: monitor admission - is_busy reflects active lease. +#[tokio::test] +async fn dingtalk_monitor_guard_is_busy() { + let guard = crate::gateway::DingtalkMonitorGuard::new(); + + assert!(!guard.is_busy("track-1").await, "must not be busy initially"); + + let owner_id = guard.try_acquire("track-1").await.unwrap(); + assert!(guard.is_busy("track-1").await, "must be busy after acquire"); + + guard.release("track-1", &owner_id).await; + assert!(!guard.is_busy("track-1").await, "must not be busy after release"); +} + +// --------------------------------------------------------------------------- +// Monitor BUSY generation tests (S1.6) +// --------------------------------------------------------------------------- + +/// Test: BUSY via admission-first — second monitor never claims generation. +/// Verifies that monitor_60s running with gen=10, then monitor_30s callback +/// arrives, gets BUSY from admission guard, does NOT claim gen=11, and +/// monitor_60s's gen=10 remains current. +#[tokio::test] +async fn dingtalk_monitor_busy_never_claims_generation() { + let store = crate::gateway::dingtalk_store::DingtalkStore::new(); + let monitor_guard = crate::gateway::DingtalkMonitorGuard::new(); + + // Monitor 60s acquires guard and claims generation 10. + let _lease = monitor_guard.try_acquire("card-1").await.unwrap(); + for _ in 0..9 { + store.claim_card_generation("card-1").await; + } + let gen60 = store.claim_card_generation("card-1").await; + assert_eq!(gen60, 10); + + // Monitor 30s arrives: admission fails. + let lease30 = monitor_guard.try_acquire("card-1").await; + assert!(lease30.is_none(), "second monitor must be busy"); + + // No generation was claimed for the second monitor. + // The running monitor's gen=10 is still current. + assert!( + store.is_card_generation_current("card-1", 10).await, + "gen=10 must remain current" + ); + assert!( + !store.is_card_generation_current("card-1", 11).await, + "gen=11 must not be current" + ); + + // Monitor 60s completes: generation 10 is still valid. + assert!( + store.is_card_generation_current("card-1", 10).await, + "gen=10 still valid for READY update" + ); +} + +/// Test: after monitor completes, next monitor can acquire and claim fresh gen. +#[tokio::test] +async fn dingtalk_monitor_after_completion_fresh_generation() { + let store = crate::gateway::dingtalk_store::DingtalkStore::new(); + let monitor_guard = crate::gateway::DingtalkMonitorGuard::new(); + + // First monitor completes. + let lease1 = monitor_guard.try_acquire("card-1").await.unwrap(); + let gen1 = store.claim_card_generation("card-1").await; + assert_eq!(gen1, 1); + let _ = monitor_guard.release("card-1", &lease1).await; + + // Second monitor acquires and gets gen 2. + let lease2 = monitor_guard.try_acquire("card-1").await.unwrap(); + let gen2 = store.claim_card_generation("card-1").await; + assert_eq!(gen2, 2); + let _ = monitor_guard.release("card-1", &lease2).await; + + // gen=1 is stale. + assert!(!store.is_card_generation_current("card-1", 1).await); +} + +// --------------------------------------------------------------------------- +// DedupCache rollback tests (S1.6) +// --------------------------------------------------------------------------- + +/// Test: dedupe reservation is rolled back on failure, allowing retry. +#[tokio::test] +async fn dedup_cache_rollback_allows_retry() { + let cache = crate::gateway::DedupCache::new(1800); + + // Reserve. + let key = "msg:test-rollback"; + let is_new = cache.check_and_insert(key).await; + assert!(is_new, "first insert must succeed"); + + // Remove (rollback). + cache.remove(key).await; + + // Reserve again — must succeed. + let is_new_again = cache.check_and_insert(key).await; + assert!(is_new_again, "after rollback, insert must succeed again"); +} + +/// Test: dedupe rollback only affects the specific key. +#[tokio::test] +async fn dedup_cache_rollback_is_key_specific() { + let cache = crate::gateway::DedupCache::new(1800); + + cache.check_and_insert("msg:A").await; + cache.check_and_insert("msg:B").await; + + // Rollback only A. + cache.remove("msg:A").await; + + // A can be re-inserted. + assert!(cache.check_and_insert("msg:A").await); + // B is still deduped. + assert!(!cache.check_and_insert("msg:B").await); +} + +/// Test: dedup cache contains() returns true for reserved keys. +#[tokio::test] +async fn dedup_cache_contains_returns_true_for_inserted() { + let cache = crate::gateway::DedupCache::new(1800); + + assert!(!cache.contains("msg:test").await); + let _ = cache.check_and_insert("msg:test").await; + assert!(cache.contains("msg:test").await); + cache.remove("msg:test").await; + assert!(!cache.contains("msg:test").await); +} + +// --------------------------------------------------------------------------- +// Worker config update tests (S1.6) +// --------------------------------------------------------------------------- + +/// Test: worker reads updated config through same runtime. +#[tokio::test] +async fn worker_reads_updated_config_through_same_runtime() { + use crate::config::Config; + + let mut cfg = Config::default(); + cfg.gateway.dingtalk.app_key = "original-key".to_string(); + let runtime = crate::gateway::GatewayRuntime::new(cfg); + + // Enqueue a job (doesn't actually need to process). + let original_key = runtime.get_config().await.gateway.dingtalk.app_key.clone(); + assert_eq!(original_key, "original-key"); + + // Update config. + let mut new_cfg = runtime.get_config().await; + new_cfg.gateway.dingtalk.app_key = "updated-key".to_string(); + runtime.set_config(new_cfg).await.unwrap(); + + // Verify updated. + let updated_key = runtime.get_config().await.gateway.dingtalk.app_key.clone(); + assert_eq!(updated_key, "updated-key"); +} + +// --------------------------------------------------------------------------- +// Tokio cancellation + StreamOwner tests (S1.6) +// --------------------------------------------------------------------------- + +/// Test: StreamOwner release only works for current generation. +#[tokio::test] +async fn stream_owner_release_only_current_gen() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + + // Gen 1 acquires. + let (gen1, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen1, 1); + + // Gen 2 cannot release gen 1. + runtime.release_stream_owner(gen1); // Actually gen1 == 1, so this IS the current gen. + // Wait, gen1 is still current. Let me test the stale release path. + + // Proper stale test: release after gen incremented. + let (gen2, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen2, 2); // gen1 is now stale + runtime.release_stream_owner(gen1); // Stale: gen1 != current gen2 + assert!( + runtime.is_dingtalk_stream_active(), + "stale release must not deactivate" + ); + runtime.release_stream_owner(gen2); // Current: should work + assert!(!runtime.is_dingtalk_stream_active()); +} + +/// Test: connected state is owner-scoped: only the current owner can set it. +#[tokio::test] +async fn stream_owner_connected_only_current_gen() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + + // Owner 1 acquires. + let (gen1, _) = runtime.try_acquire_stream_owner(); + runtime.set_dingtalk_stream_connected(gen1, true); + assert!(runtime.dingtalk_stream_connected()); + + // Owner 1 releases. + runtime.release_stream_owner(gen1); + assert!(!runtime.dingtalk_stream_connected()); + + // Owner 2 acquires. + let (gen2, _) = runtime.try_acquire_stream_owner(); + assert_eq!(gen2, 2); + runtime.set_dingtalk_stream_connected(gen2, true); + assert!(runtime.dingtalk_stream_connected()); + + // A delayed cleanup from owner 1 must not clear owner 2's state. + runtime.set_dingtalk_stream_connected(gen1, false); + assert!( + runtime.dingtalk_stream_connected(), + "stale owner must not clear current connected state" + ); + + // Owner 2 releases. + runtime.set_dingtalk_stream_connected(gen2, false); + runtime.release_stream_owner(gen2); + assert!(!runtime.dingtalk_stream_connected()); +} + +async fn wait_for_physical_stream_loops( + runtime: &crate::gateway::GatewayRuntime, + expected: usize, +) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if runtime.dingtalk_active_loop_count() == expected { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("physical stream loop count should converge"); +} + +fn start_cooperative_test_stream( + runtime: &std::sync::Arc, + exit_delay: std::time::Duration, + shutdown_seen: Option>, +) -> u64 { + runtime + .try_start_dingtalk_stream_loop(move |_owner_gen, mut shutdown| async move { + let _ = shutdown.changed().await; + if let Some(notify) = shutdown_seen { + notify.notify_one(); + } + if !exit_delay.is_zero() { + tokio::time::sleep(exit_delay).await; + } + }) + .expect("test stream owner should start") +} + +/// S1.7 A: restart joins A before B starts, so physical max remains one. +#[tokio::test] +async fn stream_restart_joins_old_loop_before_new_start() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + runtime.dingtalk_stream_owner.reset_diagnostics(); + + let gen_a = start_cooperative_test_stream( + &runtime, + std::time::Duration::ZERO, + None, + ); + wait_for_physical_stream_loops(&runtime, 1).await; + let outcome = runtime + .shutdown_dingtalk_stream_generation(gen_a, std::time::Duration::from_secs(1)) + .await; + assert_eq!(outcome, crate::gateway::StreamShutdownOutcome::Graceful); + wait_for_physical_stream_loops(&runtime, 0).await; + + let gen_b = start_cooperative_test_stream( + &runtime, + std::time::Duration::ZERO, + None, + ); + wait_for_physical_stream_loops(&runtime, 1).await; + assert_eq!(runtime.dingtalk_max_active_loops(), 1); + let _ = runtime + .shutdown_dingtalk_stream_generation(gen_b, std::time::Duration::from_secs(1)) + .await; +} + +/// S1.7 B: a delayed cooperative exit keeps ownership until JoinHandle ends. +#[tokio::test] +async fn stream_restart_cannot_start_while_old_join_is_pending() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + let shutdown_seen = std::sync::Arc::new(tokio::sync::Notify::new()); + let gen_a = start_cooperative_test_stream( + &runtime, + std::time::Duration::from_millis(80), + Some(shutdown_seen.clone()), + ); + wait_for_physical_stream_loops(&runtime, 1).await; + + let shutdown_runtime = runtime.clone(); + let joining = tokio::spawn(async move { + shutdown_runtime + .shutdown_dingtalk_stream_generation(gen_a, std::time::Duration::from_secs(1)) + .await + }); + shutdown_seen.notified().await; + + let premature = runtime.try_start_dingtalk_stream_loop(|_, mut shutdown| async move { + let _ = shutdown.changed().await; + }); + assert!(premature.is_none(), "B must wait until A has physically exited"); + assert_eq!(joining.await.unwrap(), crate::gateway::StreamShutdownOutcome::Graceful); + + let gen_b = start_cooperative_test_stream( + &runtime, + std::time::Duration::ZERO, + None, + ); + let _ = runtime + .shutdown_dingtalk_stream_generation(gen_b, std::time::Duration::from_secs(1)) + .await; +} + +/// S1.7 C: an uncooperative loop is aborted and joined before replacement. +#[tokio::test] +async fn stream_shutdown_timeout_aborts_and_joins_old_loop() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + runtime.dingtalk_stream_owner.reset_diagnostics(); + let gen_a = runtime + .try_start_dingtalk_stream_loop(|_, _shutdown| async move { + std::future::pending::<()>().await; + }) + .unwrap(); + wait_for_physical_stream_loops(&runtime, 1).await; + + let outcome = runtime + .shutdown_dingtalk_stream_generation(gen_a, std::time::Duration::from_millis(10)) + .await; + assert_eq!(outcome, crate::gateway::StreamShutdownOutcome::Aborted); + wait_for_physical_stream_loops(&runtime, 0).await; + + let gen_b = start_cooperative_test_stream( + &runtime, + std::time::Duration::ZERO, + None, + ); + wait_for_physical_stream_loops(&runtime, 1).await; + assert_eq!(runtime.dingtalk_max_active_loops(), 1); + let _ = runtime + .shutdown_dingtalk_stream_generation(gen_b, std::time::Duration::from_secs(1)) + .await; +} + +/// S1.7 D: an old connection cannot dispatch robot/card business callbacks. +#[tokio::test] +async fn stale_stream_owner_skips_business_frame_dispatch() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + let (old_gen, _) = runtime.try_acquire_stream_owner(); + runtime.release_stream_owner(old_gen); + let (new_gen, _) = runtime.try_acquire_stream_owner(); + + let business_invocations = std::sync::atomic::AtomicUsize::new(0); + if crate::gateway::dingtalk_stream::should_dispatch_business_frame(&runtime, old_gen) { + business_invocations.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } + assert_eq!(business_invocations.load(std::sync::atomic::Ordering::Acquire), 0); + assert!(crate::gateway::dingtalk_stream::should_dispatch_business_frame( + &runtime, new_gen + )); + runtime.release_stream_owner(new_gen); +} + +/// S1.7 F: repeated restarts preserve the physical 1 -> 0 -> 1 invariant. +#[tokio::test] +async fn stream_fifty_restart_cycles_never_overlap_physical_loops() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + runtime.dingtalk_stream_owner.reset_diagnostics(); + + for _ in 0..50 { + let generation = start_cooperative_test_stream( + &runtime, + std::time::Duration::ZERO, + None, + ); + wait_for_physical_stream_loops(&runtime, 1).await; + let outcome = runtime + .shutdown_dingtalk_stream_generation( + generation, + std::time::Duration::from_secs(1), + ) + .await; + assert_eq!(outcome, crate::gateway::StreamShutdownOutcome::Graceful); + wait_for_physical_stream_loops(&runtime, 0).await; + } + + let final_gen = start_cooperative_test_stream( + &runtime, + std::time::Duration::ZERO, + None, + ); + wait_for_physical_stream_loops(&runtime, 1).await; + assert_eq!(runtime.dingtalk_active_loop_count(), 1); + assert_eq!(runtime.dingtalk_max_active_loops(), 1); + let _ = runtime + .shutdown_dingtalk_stream_generation(final_gen, std::time::Duration::from_secs(1)) + .await; +} + +/// S1.7 monitor audit: one accepted action attempts admission exactly once. +#[tokio::test] +async fn accepted_monitor_action_acquires_singleflight_exactly_once() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + let guard = runtime.dingtalk_monitor_guard(); + guard.clear().await; + let callback = crate::gateway::dingtalk_card_stream::ParsedCardCallback { + out_track_id: "monitor-admission-once".to_string(), + action: "monitor_30s".to_string(), + callback_id: Some("callback-once".to_string()), + user_id: None, + space_id: None, + }; + + crate::gateway::dingtalk_card_stream::process_panel_action(runtime, callback).await; + assert_eq!(guard.acquisition_attempt_count(), 1); +} + +/// S1.7 cross-transport rollback: one HTTP/Stream reservation wins, a +/// queue-full rollback removes it, and the next concurrent retry enqueues once. +#[tokio::test] +async fn cross_transport_queue_full_rollback_allows_exactly_one_retry() { + let runtime = std::sync::Arc::new(crate::gateway::GatewayRuntime::new(Config::default())); + let message_id = "cross-transport-queue-full"; + let key = format!("dt_stream:{message_id}"); + let cache = runtime.dedup_cache(); + + let (stream_first, http_first) = tokio::join!( + runtime.try_dingtalk_stream_dedupe(message_id), + cache.check_and_insert(&key) + ); + assert_eq!(usize::from(stream_first) + usize::from(http_first), 1); + + // The winning transport cannot enqueue because the bounded queue is full. + let (queue_tx, mut queue_rx) = tokio::sync::mpsc::channel(1); + queue_tx.try_send("occupied").unwrap(); + assert!(queue_tx.try_send("first-attempt").is_err()); + // Both production paths rollback this exact shared reservation key. + cache.remove(&key).await; + assert_eq!(queue_rx.recv().await, Some("occupied")); + + let (stream_retry, http_retry) = tokio::join!( + runtime.try_dingtalk_stream_dedupe(message_id), + cache.check_and_insert(&key) + ); + assert_eq!(usize::from(stream_retry) + usize::from(http_retry), 1); + let mut enqueue_success = 0; + if stream_retry && queue_tx.try_send("stream-retry").is_ok() { + enqueue_success += 1; + } + if http_retry && queue_tx.try_send("http-retry").is_ok() { + enqueue_success += 1; + } + assert_eq!(enqueue_success, 1, "retry must enqueue exactly once"); + assert!(matches!( + queue_rx.recv().await, + Some("stream-retry" | "http-retry") + )); + assert!(cache.contains(&key).await, "one retry must remain reserved"); +} + +/// Group menu → panel context must preserve group routing so the detailed +/// reply returns to the original group, never to the user's private chat. +#[tokio::test] +async fn group_panel_context_preserves_group_routing_for_detailed_reply() { + use crate::gateway::dingtalk_card::DingtalkCardTarget; + use crate::gateway::dingtalk_worker::panel_reply_inbound; + use crate::gateway::dingtalk_store::DingtalkPanelContext; + use crate::gateway::dingtalk_worker::build_panel_context; + + let inbound = InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: Some("USER_A".to_string()), + session_id: Some("GROUP_A".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!("2")), + ("conversationId".to_string(), serde_json::json!("GROUP_A")), + ("senderStaffId".to_string(), serde_json::json!("USER_A")), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ( + "sessionWebhook".to_string(), + serde_json::json!("https://group.webhook/secret"), + ), + ]), + }; + + let target = DingtalkCardTarget::from_inbound(&inbound, None).expect("group target"); + let context = build_panel_context("track-group", &inbound, &target); + + // Context must record the GROUP conversation, not default to single chat. + assert_eq!(context.conversation_id.as_deref(), Some("GROUP_A")); + assert_eq!(context.robot_code.as_deref(), Some("robot-test")); + assert!(context + .space_id + .as_deref() + .is_some_and(|space| space.starts_with("dtv1.card//IM_GROUP."))); + assert_eq!(context.user_id.as_deref(), Some("USER_A")); + + // Simulate the button callback: reconstruct the reply target from context. + let reply_inbound = panel_reply_inbound(&context); + assert_eq!(reply_inbound.session_id.as_deref(), Some("GROUP_A")); + assert_eq!( + reply_inbound.metadata.get("sessionWebhook").and_then(|v| v.as_str()), + Some("https://group.webhook/secret") + ); + assert_eq!( + reply_inbound.metadata.get("robotCode").and_then(|v| v.as_str()), + Some("robot-test") + ); + // The reply must not be re-routed to a single-chat user target: the + // conversation session identity is the group conversation id. + assert_ne!(reply_inbound.session_id.as_deref(), Some("USER_A")); +} + +/// Direct menu → panel context must keep single-chat routing via the +/// session conversation id and user id. +#[tokio::test] +async fn direct_panel_context_preserves_direct_routing() { + use crate::gateway::dingtalk_card::DingtalkCardTarget; + use crate::gateway::dingtalk_worker::{build_panel_context, panel_reply_inbound}; + + let inbound = InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: Some("USER_A".to_string()), + session_id: Some("DIRECT_SESSION".to_string()), + text: "menu".to_string(), + metadata: std::collections::HashMap::from([ + ("conversationType".to_string(), serde_json::json!("1")), + ("senderStaffId".to_string(), serde_json::json!("USER_A")), + ("robotCode".to_string(), serde_json::json!("robot-test")), + ( + "sessionWebhook".to_string(), + serde_json::json!("https://direct.webhook/secret"), + ), + ]), + }; + + let target = DingtalkCardTarget::from_inbound(&inbound, None).expect("direct target"); + assert!(matches!(target, DingtalkCardTarget::Direct { .. })); + + let context = build_panel_context("track-direct", &inbound, &target); + assert!(context + .space_id + .as_deref() + .is_some_and(|space| space.starts_with("dtv1.card//IM_ROBOT."))); + assert_eq!(context.user_id.as_deref(), Some("USER_A")); + + let reply_inbound = panel_reply_inbound(&context); + assert_eq!(reply_inbound.session_id.as_deref(), Some("DIRECT_SESSION")); + assert_eq!(reply_inbound.user_id.as_deref(), Some("USER_A")); +} diff --git a/crates/omninova-core/src/gateway/dingtalk_worker.rs b/crates/omninova-core/src/gateway/dingtalk_worker.rs new file mode 100644 index 00000000..74a02d36 --- /dev/null +++ b/crates/omninova-core/src/gateway/dingtalk_worker.rs @@ -0,0 +1,1651 @@ +//! DingTalk async worker for background processing of webhook events. +//! +//! Phase 1: DingTalk app bot text message handling with sessionWebhook outbound. + +use crate::channels::ChannelKind; +use crate::channels::InboundMessage; +use crate::gateway::dingtalk_store::{DingtalkPanelContext, DingtalkStore, JobStatus}; +use crate::gateway::GatewayRuntime; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::RwLock; + +/// Queue capacity - max number of pending jobs +const QUEUE_CAPACITY: usize = 100; + +/// Runtime execution timeout +const RUNTIME_TIMEOUT_SECS: u64 = 120; + +/// Outbound send timeout +const OUTBOUND_TIMEOUT_SECS: u64 = 20; + +// --------------------------------------------------------------------------- +// Job types +// --------------------------------------------------------------------------- + +/// DingTalk async job - represents a webhook event to be processed in background +#[derive(Debug, Clone)] +pub struct DingtalkAsyncJob { + pub channel: ChannelKind, + pub inbound: InboundMessage, + pub raw_payload: serde_json::Value, + pub created_at: u64, + pub job_id: String, +} + +impl DingtalkAsyncJob { + pub fn new(inbound: InboundMessage, raw_payload: serde_json::Value) -> Self { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let job_id = format!( + "dt_job_{}_{}", + created_at, + &uuid::Uuid::new_v4().to_string()[..8] + ); + + Self { + channel: ChannelKind::Dingtalk, + inbound, + raw_payload, + created_at, + job_id, + } + } +} + +/// Job queue sender type +pub type DingtalkJobSender = mpsc::Sender; + +/// Shared state for the DingTalk worker +pub struct DingtalkWorkerState { + sender: Option>, + receiver: Option>, + pub queue_len: Arc>, + initialized: bool, +} + +impl Default for DingtalkWorkerState { + fn default() -> Self { + Self::new() + } +} + +impl DingtalkWorkerState { + pub fn new() -> Self { + Self::with_queue_len(Arc::new(RwLock::new(0))) + } + + pub fn with_queue_len(queue_len: Arc>) -> Self { + let (sender, receiver) = mpsc::channel::(QUEUE_CAPACITY); + Self { + sender: Some(sender), + receiver: Some(receiver), + queue_len, + initialized: false, + } + } + + /// Check if the worker has been initialized (receiver taken) + pub fn is_initialized(&self) -> bool { + self.initialized + } + + pub fn sender(&self) -> DingtalkJobSender { + self.sender.clone().expect("sender already taken") + } + + /// Take the receiver for starting the worker loop. + /// Returns Some(receiver) if not yet taken, None if already initialized. + /// This prevents panic on repeated initialization. + pub fn try_take_receiver(&mut self) -> Option> { + if self.initialized { + return None; + } + self.initialized = true; + self.receiver.take() + } + + /// Take the receiver for starting the worker loop. + /// Panics if already taken. Use try_take_receiver() for safe idempotent access. + pub fn take_receiver(&mut self) -> mpsc::Receiver { + self.try_take_receiver().expect("receiver already taken") + } + + pub async fn try_enqueue(&self, job: DingtalkAsyncJob) -> Result<(), EnqueueError> { + let queue_len = self.queue_len.read().await; + if *queue_len >= QUEUE_CAPACITY { + return Err(EnqueueError::QueueFull); + } + drop(queue_len); + + self.sender() + .send(job) + .await + .map_err(|_| EnqueueError::QueueFull)?; + + let mut queue_len = self.queue_len.write().await; + *queue_len += 1; + + Ok(()) + } +} + +#[derive(Debug)] +pub enum EnqueueError { + QueueFull, +} + +// --------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------- + +/// Start the DingTalk worker. +/// Returns immediately if the worker is already running (idempotent). +pub async fn start_dingtalk_worker( + mut state: DingtalkWorkerState, + runtime: Arc, + store: Arc, +) { + let receiver = match state.try_take_receiver() { + Some(rx) => rx, + None => { + println!("[dingtalk-async-worker] already_started=true"); + return; + } + }; + let queue_len = state.queue_len.clone(); + + println!("[dingtalk-async-worker] started=true"); + + tokio::spawn(async move { + let mut receiver = receiver; + println!("[dingtalk-worker] loop_started=true"); + + while let Some(job) = receiver.recv().await { + let text_len = job.inbound.text.chars().count(); + let has_conversation = job + .inbound + .session_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let has_webhook = job + .inbound + .metadata + .get("sessionWebhook") + .map(|v| !v.as_str().unwrap_or("").trim().is_empty()) + .unwrap_or(false); + let has_robot_code = job + .inbound + .metadata + .get("robotCode") + .map(|v| !v.as_str().unwrap_or("").trim().is_empty()) + .unwrap_or(false); + + println!( + "[dingtalk-worker] job_received=true text_len={} has_conversation={} has_webhook={} has_robot_code={}", + text_len, has_conversation, has_webhook, has_robot_code + ); + + // Record the job in the store so the Card "recent_jobs" action has + // a real data source instead of an always-empty in-memory map. + store + .store_inbound(crate::gateway::dingtalk_store::DingtalkJob { + job_id: job.job_id.clone(), + inbound: job.inbound.clone(), + status: crate::gateway::dingtalk_store::JobStatus::Received, + created_at: job.created_at, + updated_at: job.created_at, + error_message: None, + }) + .await; + + let runtime_clone = runtime.clone(); + let store_clone = store.clone(); + let queue_len_clone = queue_len.clone(); + + tokio::spawn(async move { + process_dingtalk_job(runtime_clone, store_clone, job).await; + let mut ql = queue_len_clone.write().await; + *ql = ql.saturating_sub(1); + }); + } + + println!("[dingtalk-worker] loop_stopped=true reason=receiver_closed"); + }); +} + +async fn process_dingtalk_job( + runtime: Arc, + store: Arc, + job: DingtalkAsyncJob, +) { + let job_id = job.job_id.clone(); + let inbound = job.inbound.clone(); + + // Update store: processing + store.update_status(&job_id, JobStatus::Processing).await; + + // Phase 2: short-circuit commands (help/menu/status/ping/monitor). + // Non-command text falls through to the agent exactly like Phase 1. + let command_result = evaluate_command_for_job(&runtime, &job).await; + let mut should_send_menu_card = false; + let reply = if let Some((cmd, command_reply)) = command_result { + println!("[dingtalk-command] matched=true command={}", cmd.name()); + should_send_menu_card = cmd.prefers_menu_card(); + command_reply + } else { + println!("[dingtalk-command] matched=false command=none"); + let reply = match route_to_agent(&runtime, &inbound).await { + Ok(r) => r, + Err(e) => { + println!("[dingtalk-worker] job_failed=true reason=agent_error"); + store.mark_failed(&job_id, e.to_string()).await; + return; + } + }; + reply + }; + + let result = if should_send_menu_card { + let availability = resolve_dingtalk_card_availability(&runtime, &inbound).await; + println!( + "[dingtalk-card] availability={} mode={}", + availability.log_value(), + if matches!( + availability, + crate::gateway::dingtalk_card::DingtalkCardAvailability::UnsupportedTransport + ) { + "http" + } else { + "stream" + } + ); + match menu_delivery_plan(availability) { + DingtalkMenuDeliveryPlan::InteractiveCard => { + send_menu_card_with_text_fallback( + || send_dingtalk_agent_menu_card(&runtime, &inbound), + || send_reply_via_session_webhook(&runtime, &inbound, CARD_CREATE_FAILED_TEXT), + ) + .await + } + DingtalkMenuDeliveryPlan::TextOnly(message) => { + send_reply_via_session_webhook(&runtime, &inbound, message).await + } + } + } else { + send_reply_via_session_webhook(&runtime, &inbound, &reply).await + }; + + match result { + Ok(_) => { + println!("[dingtalk-worker] job_completed=true"); + store.update_status(&job_id, JobStatus::Completed).await; + } + Err(e) => { + println!( + "[dingtalk-worker] job_failed=true reason={}", + safe_error_kind(&e) + ); + store.mark_failed(&job_id, e.to_string()).await; + } + } +} + +/// Try to interpret the inbound text as a DingTalk command. Returns +/// `Some((command, reply))` when the message is a recognized command +/// (the agent must NOT be invoked), or `None` when the message is +/// ordinary text that must continue into the agent pipeline. +/// +/// Errors reading the live config or queue length are non-fatal: when +/// the helper cannot render `/status`, the worker falls back to the +/// agent like any other non-command message. +async fn evaluate_command_for_job( + runtime: &Arc, + job: &DingtalkAsyncJob, +) -> Option<(crate::gateway::dingtalk_commands::DingtalkCommand, String)> { + let config = runtime.get_config().await; + let queue_len = runtime.dingtalk_queue_len().await; + let worker_initialized = runtime.is_dingtalk_worker_initialized().await; + + let inputs = crate::gateway::dingtalk_commands::DingtalkStatusInputs { + config: &config, + worker_initialized, + queue_len, + }; + + crate::gateway::dingtalk_commands::evaluate_dingtalk_command( + &job.inbound.text, + Some(&job.raw_payload), + inputs, + ) +} + +/// Build the same sanitized, live status used by the existing `/status` +/// command. Advanced-card callbacks reuse this rather than fabricating a +/// separate status snapshot. +pub(crate) async fn build_runtime_dingtalk_status_text(runtime: &GatewayRuntime) -> String { + let config = runtime.get_config().await; + let queue_len = runtime.dingtalk_queue_len().await; + let worker_initialized = runtime.dingtalk_worker_started().await; + crate::gateway::dingtalk_commands::build_dingtalk_status_text( + crate::gateway::dingtalk_commands::DingtalkStatusInputs { + config: &config, + worker_initialized, + queue_len, + }, + ) +} + +async fn route_to_agent( + runtime: &Arc, + inbound: &InboundMessage, +) -> Result { + let response = runtime + .process_inbound(inbound) + .await + .map_err(|e| format!("runtime_error: {}", e))?; + Ok(response.reply) +} + +async fn send_reply_via_session_webhook( + runtime: &Arc, + inbound: &InboundMessage, + reply: &str, +) -> Result<(), String> { + if reply.trim().is_empty() { + return Ok(()); + } + + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + + let outbound_mode = { + let configured = config.gateway.dingtalk.outbound_mode.trim(); + if !configured.is_empty() { + configured + } else { + entry + .and_then(|entry| entry.extra.get("outbound_mode")) + .and_then(serde_json::Value::as_str) + .unwrap_or("disabled") + } + }; + + if outbound_mode == "disabled" { + println!("[dingtalk-outbound] sending=false mode=disabled"); + return Ok(()); + } + if outbound_mode == "mock" { + println!("[dingtalk-outbound] sending=false mode=mock"); + return Ok(()); + } + + let session_webhook = inbound + .metadata + .get("sessionWebhook") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let session_webhook_present = session_webhook.is_some(); + let robot_code_present = inbound + .metadata + .get("robotCode") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let conversation_id_present = inbound.session_id.is_some(); + + if let Some(webhook) = session_webhook { + println!( + "[dingtalk-outbound] sending=true mode=session_webhook robot_code_present={} conversation_id_present={} webhook_present=true", + robot_code_present, conversation_id_present + ); + match send_dingtalk_session_webhook(webhook, reply).await { + Ok(()) => return Ok(()), + Err(error) => { + println!( + "[dingtalk-outbound] fallback=true from=session_webhook to=send_from_app reason={}", + safe_error_kind(&error) + ); + } + } + } else { + println!( + "[dingtalk-outbound] session_webhook_missing fallback=robot_code_required \ + inbound_session_id_present={}", + inbound.session_id.is_some() + ); + } + + println!( + "[dingtalk-outbound] sending=true mode=send_from_app robot_code_present={} conversation_id_present={} webhook_present={}", + robot_code_present, conversation_id_present, session_webhook_present + ); + + // App key and secret are only needed for the sendFromApp fallback. + let app_key = crate::gateway::resolve_dingtalk_app_key_for_worker(&config, entry) + .ok_or_else(|| "missing_app_key".to_string())?; + let app_secret = crate::gateway::resolve_dingtalk_secret_for_worker(&config, entry) + .ok_or_else(|| "missing_app_secret".to_string())?; + + let token = match fetch_dingtalk_access_token(&app_key, &app_secret).await { + Ok(token) => { + println!("[dingtalk-outbound] access_token_present=true"); + token + } + Err(error) => { + println!("[dingtalk-outbound] access_token_present=false"); + return Err(error); + } + }; + + let fallback_robot_code = crate::gateway::resolve_dingtalk_robot_code_for_worker( + &config, + config.channels_config.dingtalk.as_ref(), + ); + + send_dingtalk_text_message(&token, inbound, fallback_robot_code.as_deref(), reply) + .await + .map_err(|error| format!("send_from_app_error:{error}")) +} + +/// Send a detailed panel result through the same proven outbound path as an +/// ordinary DingTalk reply. The context can route through a session webhook +/// today and still retains conversation/robot/user fields for the existing +/// sendFromApp fallback. +pub(crate) async fn send_dingtalk_panel_reply( + runtime: &Arc, + context: &DingtalkPanelContext, + reply: &str, +) -> Result<(), String> { + let inbound = panel_reply_inbound(context); + send_reply_via_session_webhook(runtime, &inbound, reply).await +} + +pub(crate) fn panel_reply_inbound(context: &DingtalkPanelContext) -> InboundMessage { + let mut metadata = std::collections::HashMap::new(); + if let Some(value) = context.session_webhook.as_deref() { + metadata.insert("sessionWebhook".to_string(), serde_json::json!(value)); + } + if let Some(value) = context.robot_code.as_deref() { + metadata.insert("robotCode".to_string(), serde_json::json!(value)); + } + if let Some(value) = context.user_id.as_deref() { + metadata.insert("senderStaffId".to_string(), serde_json::json!(value)); + } + InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: context.user_id.clone(), + session_id: context.conversation_id.clone(), + text: String::new(), + metadata, + } +} + +const HTTP_CARD_UNAVAILABLE_TEXT: &str = "当前 DingTalk 连接使用 HTTP 模式。\n\nOmniNova 互动卡片仅在 Stream 模式下启用。\n如需使用可点击 Agent 菜单,请在 DingTalk 设置中切换到 Stream 模式。\n\n当前仍可通过文本命令使用 Gateway 状态、帮助和普通 Agent 对话。"; +const MISSING_CARD_TEMPLATE_TEXT: &str = "DingTalk 当前已使用 Stream 模式,但互动卡片模板尚未配置。\n\n请先在 OmniNova DingTalk 设置中填写 Card Template ID。\n当前仍可继续使用普通文本交互。"; +const CARD_STREAM_DISCONNECTED_TEXT: &str = "DingTalk 当前使用 Stream 模式,但 Stream 连接尚未就绪。\n\n互动卡片暂时不可用,请确认 DingTalk Stream 连接状态。\n当前仍可继续使用普通文本交互。"; +const MISSING_CARD_CONTEXT_TEXT: &str = "当前会话暂时无法创建互动卡片,请继续使用文本命令。"; +const CARD_CREATE_FAILED_TEXT: &str = + "互动卡片创建失败,已切换为文本交互模式。\n\n你仍可继续使用普通命令。"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DingtalkMenuDeliveryPlan { + InteractiveCard, + TextOnly(&'static str), +} + +fn menu_delivery_plan( + availability: crate::gateway::dingtalk_card::DingtalkCardAvailability, +) -> DingtalkMenuDeliveryPlan { + use crate::gateway::dingtalk_card::DingtalkCardAvailability; + match availability { + DingtalkCardAvailability::UnsupportedTransport => { + DingtalkMenuDeliveryPlan::TextOnly(HTTP_CARD_UNAVAILABLE_TEXT) + } + DingtalkCardAvailability::MissingTemplate => { + DingtalkMenuDeliveryPlan::TextOnly(MISSING_CARD_TEMPLATE_TEXT) + } + DingtalkCardAvailability::StreamDisconnected => { + DingtalkMenuDeliveryPlan::TextOnly(CARD_STREAM_DISCONNECTED_TEXT) + } + DingtalkCardAvailability::MissingContext => { + DingtalkMenuDeliveryPlan::TextOnly(MISSING_CARD_CONTEXT_TEXT) + } + DingtalkCardAvailability::Available => DingtalkMenuDeliveryPlan::InteractiveCard, + } +} + +async fn resolve_dingtalk_card_availability( + runtime: &GatewayRuntime, + inbound: &InboundMessage, +) -> crate::gateway::dingtalk_card::DingtalkCardAvailability { + use crate::config::schema::DingtalkTransportMode; + + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + let transport_mode = crate::gateway::resolve_dingtalk_transport_mode_for_worker(&config, entry); + if transport_mode == DingtalkTransportMode::Http { + return crate::gateway::dingtalk_card::determine_card_availability( + transport_mode, + false, + false, + false, + ); + } + + let template_configured = + crate::gateway::resolve_dingtalk_card_template_for_worker(&config, entry).is_some(); + let fallback_robot_code = + crate::gateway::resolve_dingtalk_robot_code_for_worker(&config, entry); + let context_complete = crate::gateway::dingtalk_card::DingtalkCardTarget::from_inbound( + inbound, + fallback_robot_code.as_deref(), + ) + .is_ok(); + let stream_registered = runtime.is_dingtalk_stream_registered(); + + crate::gateway::dingtalk_card::determine_card_availability( + transport_mode, + template_configured, + stream_registered, + context_complete, + ) +} + +async fn send_menu_card_with_text_fallback( + send_card: CardSend, + send_text: TextSend, +) -> Result<(), String> +where + CardSend: FnOnce() -> CardFuture, + CardFuture: std::future::Future>, + TextSend: FnOnce() -> TextFuture, + TextFuture: std::future::Future>, +{ + match send_card().await { + Ok(()) => Ok(()), + Err(error) => { + println!( + "[dingtalk-card] fallback_to_text=true reason={}", + safe_error_kind(&error) + ); + send_text().await + } + } +} + +/// Create and deliver the shared Agent menu through DingTalk Advanced Cards. +/// The callback is delivered over DingTalk Stream; the legacy HTTP ActionCard +/// URL is intentionally not the default because it cannot provide a reliable +/// application callback for this flow. +async fn send_dingtalk_agent_menu_card( + runtime: &Arc, + inbound: &InboundMessage, +) -> Result<(), String> { + let config = runtime.get_config().await; + let entry = config.channels_config.dingtalk.as_ref(); + let outbound_mode = { + let configured = config.gateway.dingtalk.outbound_mode.trim(); + if !configured.is_empty() { + configured + } else { + entry + .and_then(|entry| entry.extra.get("outbound_mode")) + .and_then(serde_json::Value::as_str) + .unwrap_or("disabled") + } + }; + validate_dingtalk_card_outbound_mode(outbound_mode).map_err(|error| { + log_dingtalk_card_failure(0, &error, 0, 0); + error + })?; + let template_id = crate::gateway::resolve_dingtalk_card_template_for_worker(&config, entry) + .ok_or_else(|| { + println!("[dingtalk-card] interactive_card_unavailable reason=missing_template_id"); + "missing_card_template_id".to_string() + })?; + let app_key = crate::gateway::resolve_dingtalk_app_key_for_worker(&config, entry) + .ok_or_else(|| "missing_app_key".to_string())?; + let app_secret = crate::gateway::resolve_dingtalk_secret_for_worker(&config, entry) + .ok_or_else(|| "missing_app_secret".to_string())?; + let fallback_robot_code = + crate::gateway::resolve_dingtalk_robot_code_for_worker(&config, entry); + let target = crate::gateway::dingtalk_card::DingtalkCardTarget::from_inbound( + inbound, + fallback_robot_code.as_deref(), + )?; + let token = fetch_dingtalk_access_token(&app_key, &app_secret).await?; + let out_track_id = + crate::gateway::dingtalk_card::create_and_deliver_menu_card(&token, &template_id, &target) + .await?; + let store = runtime + .dingtalk_store() + .ok_or_else(|| "dingtalk_store_unavailable".to_string())?; + let context = build_panel_context(&out_track_id, inbound, &target); + store.save_panel_context(context).await; + println!("[dingtalk-panel] context_saved=true"); + Ok(()) +} + +pub(crate) fn build_panel_context( + out_track_id: &str, + inbound: &InboundMessage, + target: &crate::gateway::dingtalk_card::DingtalkCardTarget, +) -> DingtalkPanelContext { + let (conversation_id, robot_code, target_user_id, space_id) = match target { + crate::gateway::dingtalk_card::DingtalkCardTarget::Group { + open_conversation_id, + robot_code, + user_id, + } => ( + Some(open_conversation_id.clone()), + Some(robot_code.clone()), + user_id.clone(), + Some(format!("dtv1.card//IM_GROUP.{open_conversation_id}")), + ), + crate::gateway::dingtalk_card::DingtalkCardTarget::Direct { + user_id, + robot_code, + } => ( + inbound.session_id.clone(), + Some(robot_code.clone()), + Some(user_id.clone()), + Some(format!("dtv1.card//IM_ROBOT.{user_id}")), + ), + }; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + DingtalkPanelContext::new( + out_track_id.to_string(), + conversation_id, + robot_code, + inbound + .metadata + .get("sessionWebhook") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + target_user_id.or_else(|| inbound.user_id.clone()), + space_id, + now_secs, + ) +} + +fn validate_dingtalk_card_outbound_mode(outbound_mode: &str) -> Result<(), String> { + match outbound_mode.trim() { + // `session_webhook` controls ordinary text replies. Interactive menu + // cards use the inbound sessionWebhook independently of text mode. + "session_webhook" | "real" => Ok(()), + mode => Err(format!("card_outbound_mode_{mode}")), + } +} + +const DINGTALK_MENU_CARD_API_KIND: &str = "sessionWebhook/actionCard"; + +fn log_dingtalk_card_failure( + http_status: u16, + err_code: &str, + err_msg_len: usize, + body_len: usize, +) { + println!( + "{}", + format_dingtalk_card_response_log(http_status, err_code, err_msg_len, body_len) + ); +} + +fn format_dingtalk_card_response_log( + http_status: u16, + err_code: &str, + err_msg_len: usize, + body_len: usize, +) -> String { + format!( + "[dingtalk-card] response api_kind={} http_status={} err_code={} err_msg_len={} body_len={}", + DINGTALK_MENU_CARD_API_KIND, http_status, err_code, err_msg_len, body_len + ) +} + +async fn handle_dingtalk_card_send_response(response: reqwest::Response) -> Result<(), String> { + let status = response.status().as_u16(); + let body = match response.text().await { + Ok(body) => body, + Err(_) => { + log_dingtalk_card_failure(status, "read_error", 0, 0); + return Err("read_error".to_string()); + } + }; + let summary = summarize_dingtalk_response(status, &body); + log_dingtalk_card_failure( + summary.status, + &summary.err_code, + summary.err_msg_len, + summary.body_len, + ); + + if summary.success { + Ok(()) + } else { + Err(format!( + "send_error:status={} code={} msg_len={} log_id_present={}", + summary.status, summary.err_code, summary.err_msg_len, summary.log_id_present + )) + } +} + +pub(crate) fn dingtalk_agent_menu_callback_url(config: &crate::config::Config) -> Option { + crate::gateway::resolve_public_webhook_base_url(config).map(|base| { + format!( + "{}{}", + base.trim_end_matches('/'), + crate::gateway::agent_menu::DINGTALK_MENU_CARD_CALLBACK_PATH + ) + }) +} + +async fn send_dingtalk_session_webhook(webhook: &str, text: &str) -> Result<(), String> { + let url = reqwest::Url::parse(webhook).map_err(|_| "invalid_session_webhook".to_string())?; + if url.scheme() != "https" || url.host_str() != Some("oapi.dingtalk.com") { + return Err("invalid_session_webhook_host".to_string()); + } + + let client = dingtalk_http_client()?; + let response = client + .post(url) + .header("Content-Type", "application/json; charset=utf-8") + .json(&build_session_webhook_payload(text)) + .send() + .await + .map_err(|_| { + println!( + "[dingtalk-outbound] response status=0 err_code=network_error err_msg_len=0 body_len=0" + ); + "network_error".to_string() + })?; + + handle_dingtalk_send_response(response).await +} + +/// Fetch DingTalk access token via app_key + app_secret +pub(crate) async fn fetch_dingtalk_access_token( + app_key: &str, + app_secret: &str, +) -> Result { + let client = dingtalk_http_client()?; + let resp = client + .post("https://api.dingtalk.com/v1.0/oauth2/accessToken") + .header("Content-Type", "application/json; charset=utf-8") + .json(&serde_json::json!({ + "appKey": app_key, + "appSecret": app_secret, + })) + .send() + .await + .map_err(|_| { + println!( + "[dingtalk-outbound] response status=0 err_code=token_network_error err_msg_len=0 body_len=0" + ); + "token_network_error".to_string() + })?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|_| "token_read_error".to_string())?; + + if !status.is_success() { + let summary = summarize_dingtalk_response(status.as_u16(), &body); + println!( + "[dingtalk-outbound] response status={} err_code={} err_msg_len={} body_len={}", + summary.status, summary.err_code, summary.err_msg_len, summary.body_len + ); + return Err(format!( + "token_http_error:status={} code={} msg_len={} body_len={}", + summary.status, summary.err_code, summary.err_msg_len, summary.body_len + )); + } + + let json: serde_json::Value = + serde_json::from_str(&body).map_err(|_| "token_parse_error".to_string())?; + + if let Some(token) = json.get("accessToken").and_then(|value| value.as_str()) { + return Ok(token.to_string()); + } + + let summary = summarize_dingtalk_json(status.as_u16(), body.len(), &json); + println!( + "[dingtalk-outbound] response status={} err_code={} err_msg_len={} body_len={}", + summary.status, summary.err_code, summary.err_msg_len, summary.body_len + ); + Err(format!( + "token_error:code={} msg_len={} body_len={}", + summary.err_code, summary.err_msg_len, summary.body_len + )) +} + +/// Send text message via DingTalk `sendFromApp` API. +/// +/// Field mapping (DingTalk enterprise app bot, in-house bot): +/// - `robotCode` — from `inbound.metadata["robotCode"]` (DingTalk +/// assigns this to every inbound message). Falls +/// back to the configured `gateway.dingtalk.robot_code` +/// when the metadata is missing (older proxies). +/// - `conversationId` — from `inbound.session_id` (the platform +/// `conversationId`). +/// - `senderStaffId` — from `inbound.metadata["senderStaffId"]`. +/// +/// The signature is intentionally narrow: callers must supply the +/// InboundMessage so we never have to derive these fields from a +/// possibly-leaked session webhook URL. +pub(crate) async fn send_dingtalk_text_message( + token: &str, + inbound: &InboundMessage, + fallback_robot_code: Option<&str>, + text: &str, +) -> Result<(), String> { + let client = dingtalk_http_client()?; + + let robot_code = inbound + .metadata + .get("robotCode") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| fallback_robot_code.map(str::trim).filter(|s| !s.is_empty())) + .ok_or_else(|| "missing_robot_code".to_string())?; + + let conversation_id = inbound + .session_id + .clone() + .or_else(|| { + inbound + .metadata + .get("conversationId") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .ok_or_else(|| "missing_conversation_id".to_string())?; + + let sender_staff_id = inbound + .metadata + .get("senderStaffId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from); + + // DingTalk expects `msgParam` as a JSON-encoded string, not an + // object. Serialize the inner content object as a String. + let body = build_send_from_app_payload( + robot_code, + &conversation_id, + sender_staff_id.as_deref(), + text, + ); + + let resp = client + .post("https://api.dingtalk.com/v1.0/im/robot/sendFromApp") + .header("Content-Type", "application/json") + .header("x-acs-dingtalk-access-token", token) + .json(&body) + .send() + .await + .map_err(|_| { + println!( + "[dingtalk-outbound] response status=0 err_code=network_error err_msg_len=0 body_len=0" + ); + "network_error".to_string() + })?; + + handle_dingtalk_send_response(resp).await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DingtalkResponseSummary { + status: u16, + err_code: String, + err_msg_len: usize, + body_len: usize, + log_id_present: bool, + success: bool, +} + +fn build_session_webhook_payload(text: &str) -> serde_json::Value { + serde_json::json!({ + "msgtype": "text", + "text": { "content": text }, + }) +} + +fn build_send_from_app_payload( + robot_code: &str, + conversation_id: &str, + sender_staff_id: Option<&str>, + text: &str, +) -> serde_json::Value { + // DingTalk requires msgParam to be a JSON string, not a nested object. + let msg_param = serde_json::json!({ "content": text }).to_string(); + let mut body = serde_json::json!({ + "robotCode": robot_code, + "msgKey": "sampleText", + "msgParam": msg_param, + "conversationId": conversation_id, + }); + if let Some(sender_staff_id) = sender_staff_id { + body["senderStaffId"] = serde_json::json!(sender_staff_id); + } + body +} + +fn dingtalk_http_client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(OUTBOUND_TIMEOUT_SECS)) + .build() + .map_err(|_| "http_client_error".to_string()) +} + +fn json_string_or_number(value: Option<&serde_json::Value>) -> Option { + value.and_then(|value| { + value + .as_str() + .map(ToString::to_string) + .or_else(|| value.as_i64().map(|number| number.to_string())) + .or_else(|| value.as_u64().map(|number| number.to_string())) + }) +} + +fn summarize_dingtalk_json( + status: u16, + body_len: usize, + json: &serde_json::Value, +) -> DingtalkResponseSummary { + let err_code = json_string_or_number( + json.get("errCode") + .or_else(|| json.get("errcode")) + .or_else(|| json.get("code")), + ) + .unwrap_or_else(|| "0".to_string()); + let err_msg_len = json + .get("errMsg") + .or_else(|| json.get("errmsg")) + .or_else(|| json.get("message")) + .and_then(serde_json::Value::as_str) + .map(|value| value.chars().count()) + .unwrap_or(0); + let log_id_present = json + .get("logId") + .or_else(|| json.get("logid")) + .or_else(|| json.get("requestId")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + let explicit_success = json.get("success").and_then(serde_json::Value::as_bool); + let success = (200..300).contains(&status) + && match explicit_success { + Some(value) => value, + None => err_code == "0" || err_code.eq_ignore_ascii_case("ok"), + }; + + DingtalkResponseSummary { + status, + err_code, + err_msg_len, + body_len, + log_id_present, + success, + } +} + +fn summarize_dingtalk_response(status: u16, body: &str) -> DingtalkResponseSummary { + if body.trim().is_empty() { + return DingtalkResponseSummary { + status, + err_code: if (200..300).contains(&status) { + "0" + } else { + "unknown" + } + .to_string(), + err_msg_len: 0, + body_len: body.len(), + log_id_present: false, + success: (200..300).contains(&status), + }; + } + + match serde_json::from_str::(body) { + Ok(json) => summarize_dingtalk_json(status, body.len(), &json), + Err(_) => DingtalkResponseSummary { + status, + err_code: "invalid_json".to_string(), + err_msg_len: 0, + body_len: body.len(), + log_id_present: false, + success: false, + }, + } +} + +async fn handle_dingtalk_send_response(response: reqwest::Response) -> Result<(), String> { + let status = response.status().as_u16(); + let body = match response.text().await { + Ok(body) => body, + Err(_) => { + println!( + "[dingtalk-outbound] response status={} err_code=read_error err_msg_len=0 body_len=0", + status + ); + return Err("read_error".to_string()); + } + }; + let summary = summarize_dingtalk_response(status, &body); + println!( + "[dingtalk-outbound] response status={} err_code={} err_msg_len={} body_len={}", + summary.status, summary.err_code, summary.err_msg_len, summary.body_len + ); + + if summary.success { + Ok(()) + } else { + Err(format!( + "send_error:status={} code={} msg_len={} log_id_present={}", + summary.status, summary.err_code, summary.err_msg_len, summary.log_id_present + )) + } +} + +fn safe_error_kind(error: &str) -> &'static str { + if error.starts_with("invalid_session_webhook") { + "invalid_session_webhook" + } else if error.contains("missing_app_key") { + "missing_app_key" + } else if error.contains("missing_app_secret") { + "missing_app_secret" + } else if error.contains("missing_robot_code") { + "missing_robot_code" + } else if error.contains("missing_conversation_id") { + "missing_conversation_id" + } else if error.contains("token_") { + "access_token_error" + } else if error.contains("network_error") { + "network_error" + } else if error.contains("send_error") { + "platform_error" + } else if error.contains("read_error") || error.contains("parse_error") { + "invalid_platform_response" + } else { + "outbound_error" + } +} + +// --------------------------------------------------------------------------- +// Signature verification (for use in gateway/mod.rs) +// --------------------------------------------------------------------------- + +/// Verify DingTalk webhook signature. +/// Returns Ok(()) if valid, Err(reason) if invalid. +pub(crate) fn verify_dingtalk_signature( + timestamp: &str, + sign: &str, + secret: &str, +) -> Result<(), String> { + let sign_base = format!("{}\n{}", timestamp, secret); + let computed = hmac_sha256_base64(&sign_base, secret); + if computed == sign { + Ok(()) + } else { + Err("signature_mismatch".to_string()) + } +} + +pub(crate) fn hmac_sha256_base64(data: &str, key: &str) -> String { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + type HmacSha256 = Hmac; + + let mut mac = + HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size"); + mac.update(data.as_bytes()); + let result = mac.finalize(); + let bytes = result.into_bytes(); + + BASE64.encode(&bytes) +} + +/// Extract and verify DingTalk signature from headers/body. +/// Returns Ok(()) if no secret configured (dev mode) or signature valid. +/// Returns Err(reason) if secret configured but signature invalid. +pub(crate) fn verify_dingtalk_webhook_signature( + headers: &axum::http::HeaderMap, + raw_body: &str, + app_secret: Option<&str>, +) -> Result<(), String> { + let Some(secret) = app_secret else { + // No secret configured - dev mode, skip verification + return Ok(()); + }; + + // Try dedicated timestamp header first; fall back to shared x-dingtalk-signature + let timestamp = headers + .get("timestamp") + .or_else(|| headers.get("x-dingtalk-signature-for-isv")) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| "missing_timestamp".to_string())?; + + // Try dedicated sign header first; fall back to shared x-dingtalk-signature + let sign = headers + .get("x-dingtalk-signature-for-isv-sign") + .or_else(|| headers.get("sign")) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| "missing_sign".to_string())?; + + verify_dingtalk_signature(timestamp, sign, secret) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verify_valid_signature() { + // Known test vectors for HMAC-SHA256 + Base64 + let secret = "test-secret"; + let timestamp = "1234567890"; + let sign_base = format!("{}\n{}", timestamp, secret); + + // Compute expected signature + let expected = hmac_sha256_base64(&sign_base, secret); + + let result = verify_dingtalk_signature(timestamp, &expected, secret); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_invalid_signature() { + let secret = "test-secret"; + let timestamp = "1234567890"; + let result = verify_dingtalk_signature(timestamp, "invalid-signature", secret); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("signature_mismatch")); + } + + #[test] + fn test_hmac_base64_output_length() { + // HMAC-SHA256 produces 32 bytes = 44 chars with padding + let result = hmac_sha256_base64("test-data", "test-key"); + assert!(result.len() >= 40); + } + + #[test] + fn test_session_webhook_payload_is_text_message() { + let body = build_session_webhook_payload("pong"); + assert_eq!(body["msgtype"], "text"); + assert_eq!(body["text"]["content"], "pong"); + } + + #[test] + fn test_send_from_app_payload_uses_json_string_msg_param() { + let body = build_send_from_app_payload("robot", "conversation", Some("sender"), "pong"); + assert_eq!(body["msgKey"], "sampleText"); + let msg_param = body["msgParam"] + .as_str() + .expect("msgParam must be a string"); + let decoded: serde_json::Value = serde_json::from_str(msg_param).unwrap(); + assert_eq!(decoded["content"], "pong"); + } + + #[test] + fn test_agent_menu_card_send_payload_uses_session_webhook_action_card() { + let body = crate::gateway::agent_menu::render_agent_menu_as_dingtalk_session_action_card( + "https://gateway.example.test/api/v1/gateway/dingtalk/card/callback", + ); + assert_eq!(body["msgtype"], "actionCard"); + assert_eq!(body["actionCard"]["title"], "OmniNova Agent 功能菜单"); + assert_eq!(body["actionCard"]["btnOrientation"], "0"); + let buttons = body["actionCard"]["btns"].as_array().unwrap(); + assert_eq!(buttons.len(), 5); + assert_eq!(buttons[0]["title"], "桌面监控 30 秒"); + assert_eq!(buttons[4]["title"], "帮助说明"); + for button in buttons { + assert!(button["actionURL"] + .as_str() + .is_some_and(|url| url.contains("/dingtalk/card/callback?action="))); + } + } + + #[test] + fn test_agent_menu_callback_url_uses_public_base() { + let mut config = crate::config::Config::default(); + assert_eq!(dingtalk_agent_menu_callback_url(&config), None); + config.gateway_public.public_webhook_base_url = + Some("https://gateway.example.test/".to_string()); + assert_eq!( + dingtalk_agent_menu_callback_url(&config).as_deref(), + Some("https://gateway.example.test/api/v1/gateway/dingtalk/card/callback") + ); + } + + #[test] + fn test_agent_menu_card_allows_session_webhook_mode() { + assert_eq!( + validate_dingtalk_card_outbound_mode("session_webhook"), + Ok(()) + ); + assert_eq!(validate_dingtalk_card_outbound_mode("real"), Ok(())); + assert_eq!( + validate_dingtalk_card_outbound_mode("disabled"), + Err("card_outbound_mode_disabled".to_string()) + ); + assert_eq!( + validate_dingtalk_card_outbound_mode("mock"), + Err("card_outbound_mode_mock".to_string()) + ); + } + + #[test] + fn http_menu_uses_capability_notice_without_card_delivery() { + use crate::gateway::dingtalk_card::DingtalkCardAvailability; + + let plan = menu_delivery_plan(DingtalkCardAvailability::UnsupportedTransport); + assert_eq!( + plan, + DingtalkMenuDeliveryPlan::TextOnly(HTTP_CARD_UNAVAILABLE_TEXT) + ); + let DingtalkMenuDeliveryPlan::TextOnly(message) = plan else { + panic!("HTTP transport must never select createAndDeliver"); + }; + assert!(message.contains("当前 DingTalk 连接使用 HTTP 模式")); + assert!(message.contains("仅在 Stream 模式下启用")); + } + + #[test] + fn stream_menu_selects_card_only_when_availability_is_ready() { + use crate::gateway::dingtalk_card::DingtalkCardAvailability; + + assert_eq!( + menu_delivery_plan(DingtalkCardAvailability::Available), + DingtalkMenuDeliveryPlan::InteractiveCard + ); + assert_eq!( + menu_delivery_plan(DingtalkCardAvailability::MissingTemplate), + DingtalkMenuDeliveryPlan::TextOnly(MISSING_CARD_TEMPLATE_TEXT) + ); + assert_eq!( + menu_delivery_plan(DingtalkCardAvailability::StreamDisconnected), + DingtalkMenuDeliveryPlan::TextOnly(CARD_STREAM_DISCONNECTED_TEXT) + ); + assert_eq!( + menu_delivery_plan(DingtalkCardAvailability::MissingContext), + DingtalkMenuDeliveryPlan::TextOnly(MISSING_CARD_CONTEXT_TEXT) + ); + } + + #[test] + fn menu_create_context_retains_reply_route_without_exposing_it() { + let session_webhook = "https://oapi.dingtalk.com/robot/send?access_token=secret"; + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "sessionWebhook".to_string(), + serde_json::json!(session_webhook), + ); + let inbound = InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: Some("user-secret".to_string()), + session_id: Some("conversation-secret".to_string()), + text: "menu".to_string(), + metadata, + }; + let target = crate::gateway::dingtalk_card::DingtalkCardTarget::Group { + open_conversation_id: "conversation-secret".to_string(), + robot_code: "robot-secret".to_string(), + user_id: Some("user-secret".to_string()), + }; + let context = build_panel_context("track-secret", &inbound, &target); + assert_eq!(context.session_webhook.as_deref(), Some(session_webhook)); + assert_eq!( + context.conversation_id.as_deref(), + Some("conversation-secret") + ); + let reply_inbound = panel_reply_inbound(&context); + assert_eq!( + reply_inbound.session_id.as_deref(), + Some("conversation-secret") + ); + assert_eq!( + reply_inbound + .metadata + .get("sessionWebhook") + .and_then(serde_json::Value::as_str), + Some(session_webhook) + ); + let debug = format!("{context:?}"); + for secret in [ + session_webhook, + "track-secret", + "conversation-secret", + "robot-secret", + "user-secret", + ] { + assert!(!debug.contains(secret)); + } + } + + #[test] + fn channel_transport_mode_overrides_typed_gateway_default() { + use crate::config::schema::{ChannelEntry, DingtalkTransportMode}; + + let mut config = crate::config::Config::default(); + config.gateway.dingtalk.transport_mode = DingtalkTransportMode::Stream; + assert_eq!( + crate::gateway::resolve_dingtalk_transport_mode_for_worker(&config, None), + DingtalkTransportMode::Stream + ); + + let mut entry = ChannelEntry::default(); + entry + .extra + .insert("transport_mode".into(), serde_json::json!("http")); + assert_eq!( + crate::gateway::resolve_dingtalk_transport_mode_for_worker(&config, Some(&entry)), + DingtalkTransportMode::Http + ); + } + + #[tokio::test] + async fn test_agent_menu_card_success_skips_text_fallback() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let text_calls = Arc::new(AtomicUsize::new(0)); + let text_calls_for_send = text_calls.clone(); + let result = send_menu_card_with_text_fallback( + || async { Ok(()) }, + move || async move { + text_calls_for_send.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert!(result.is_ok()); + assert_eq!(text_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn test_agent_menu_card_failure_falls_back_to_text() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let text_calls = Arc::new(AtomicUsize::new(0)); + let text_calls_for_send = text_calls.clone(); + let result = send_menu_card_with_text_fallback( + || async { Err("card_send_error:test".to_string()) }, + move || async move { + text_calls_for_send.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert!(result.is_ok()); + assert_eq!(text_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_agent_menu_card_unavailable_or_invalid_falls_back_to_text() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + for card_error in [ + "missing_card_template_id", + "missing_app_key", + "missing_app_secret", + "missing_robot_code", + "missing_sender_staff_id", + "card_callback_url_not_configured", + "missing_session_webhook", + "invalid_session_webhook", + "invalid_session_webhook_host", + ] { + let text_calls = Arc::new(AtomicUsize::new(0)); + let text_calls_for_send = text_calls.clone(); + let result = send_menu_card_with_text_fallback( + || async { Err(card_error.to_string()) }, + move || async move { + text_calls_for_send.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + + assert!(result.is_ok(), "{card_error} must preserve text fallback"); + assert_eq!( + text_calls.load(Ordering::SeqCst), + 1, + "{card_error} must invoke text fallback once" + ); + } + } + + #[test] + fn test_dingtalk_response_summary_accepts_both_api_success_shapes() { + let session = summarize_dingtalk_response(200, r#"{"errcode":0,"errmsg":"ok"}"#); + assert!(session.success); + assert_eq!(session.err_code, "0"); + + let send_from_app = summarize_dingtalk_response(200, r#"{"success":true}"#); + assert!(send_from_app.success); + } + + #[test] + fn test_dingtalk_response_summary_is_structured_and_redacted() { + let secret = "must-not-appear-in-log-summary"; + let body = format!( + r#"{{"errCode":"Forbidden","errMsg":"{}","logId":"present"}}"#, + secret + ); + let summary = summarize_dingtalk_response(403, &body); + assert!(!summary.success); + assert_eq!(summary.status, 403); + assert_eq!(summary.err_code, "Forbidden"); + assert_eq!(summary.err_msg_len, secret.chars().count()); + assert!(summary.log_id_present); + assert!(!format!("{summary:?}").contains(secret)); + } + + #[test] + fn test_dingtalk_card_failure_log_is_structured_and_redacted() { + let secret = "must-not-appear-in-card-log"; + let body = format!( + r#"{{"errCode":"InvalidParameter","errMsg":"{}","logId":"present"}}"#, + secret + ); + let summary = summarize_dingtalk_response(400, &body); + let log = format_dingtalk_card_response_log( + summary.status, + &summary.err_code, + summary.err_msg_len, + summary.body_len, + ); + + assert!(log.contains("api_kind=sessionWebhook/actionCard")); + assert!(log.contains("http_status=400")); + assert!(log.contains("err_code=InvalidParameter")); + assert!(log.contains(&format!("err_msg_len={}", secret.chars().count()))); + assert!(log.contains(&format!("body_len={}", body.len()))); + assert!(!log.contains(secret)); + assert!(!log.contains(&body)); + } + + #[tokio::test] + async fn test_dingtalk_async_job_creation() { + let job = DingtalkAsyncJob::new( + InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: Some("user123".to_string()), + session_id: Some("sess456".to_string()), + text: "hello".to_string(), + metadata: Default::default(), + }, + serde_json::json!({}), + ); + + assert_eq!(job.channel, ChannelKind::Dingtalk); + assert_eq!(job.inbound.text, "hello"); + assert!(job.job_id.starts_with("dt_job_")); + } + + #[tokio::test] + async fn dingtalk_worker_records_received_job_for_recent_jobs() { + let state = DingtalkWorkerState::new(); + let sender = state.sender(); + let store = Arc::new(DingtalkStore::new()); + let runtime = Arc::new(GatewayRuntime::new(crate::config::Config::default())); + start_dingtalk_worker(state, runtime, store.clone()).await; + + let job = DingtalkAsyncJob::new( + InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: None, + session_id: None, + text: "ping".to_string(), + metadata: Default::default(), + }, + serde_json::json!({}), + ); + let job_id = job.job_id.clone(); + sender.send(job).await.expect("worker channel open"); + + let recorded = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if store + .get_recent_jobs(5) + .await + .iter() + .any(|stored| stored.job_id == job_id) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await; + assert!(recorded.is_ok(), "worker must populate recent_jobs data source"); + } + + #[test] + fn test_dingtalk_worker_state_try_take_receiver_idempotent() { + // First take should succeed + let mut state1 = DingtalkWorkerState::new(); + assert!(!state1.is_initialized()); + let receiver1 = state1.try_take_receiver(); + assert!(receiver1.is_some()); + assert!(state1.is_initialized()); + + // Second take should return None (idempotent, no panic) + let receiver2 = state1.try_take_receiver(); + assert!(receiver2.is_none()); + } + + #[test] + fn test_dingtalk_worker_state_take_receiver_panics_on_second_call() { + // take_receiver should panic on second call (same as before, but explicit test) + let mut state = DingtalkWorkerState::new(); + let _ = state.take_receiver(); + // After first take, try_take_receiver returns None, which means the state is "done" + // The old take_receiver behavior (expect) would panic - we verify that + // try_take_receiver is the safe alternative + let result = state.try_take_receiver(); + assert!( + result.is_none(), + "try_take_receiver should return None after initialization" + ); + } + + #[test] + fn test_dingtalk_worker_state_new_is_not_initialized() { + let state = DingtalkWorkerState::new(); + assert!(!state.is_initialized()); + } + + #[test] + fn test_dingtalk_worker_state_sender_cloned_but_not_taken() { + // Creating multiple states should each have their own sender + let state1 = DingtalkWorkerState::new(); + let state2 = DingtalkWorkerState::new(); + + // Each state has its own sender, cloning is fine + let sender1 = state1.sender(); + let sender2 = state2.sender(); + assert!(!sender1.is_closed()); + assert!(!sender2.is_closed()); + } + + #[tokio::test] + async fn test_dingtalk_worker_sender_and_receiver_share_one_channel() { + let mut state = DingtalkWorkerState::new(); + let sender = state.sender(); + let mut receiver = state + .try_take_receiver() + .expect("receiver should be available once"); + let job = DingtalkAsyncJob::new( + InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: None, + session_id: Some("conversation".to_string()), + text: "ping".to_string(), + metadata: Default::default(), + }, + serde_json::json!({}), + ); + + sender.send(job).await.unwrap(); + let received = tokio::time::timeout(std::time::Duration::from_secs(1), receiver.recv()) + .await + .expect("receiver timed out") + .expect("channel closed"); + assert_eq!(received.inbound.text, "ping"); + } + + #[test] + fn test_dingtalk_worker_can_share_runtime_queue_counter() { + let queue_len = Arc::new(RwLock::new(0)); + let state = DingtalkWorkerState::with_queue_len(queue_len.clone()); + assert!(Arc::ptr_eq(&state.queue_len, &queue_len)); + } +} + +// ============================================================================= +// Config resolvers (re-exports for tests) +// ============================================================================= +// +// The actual resolution logic lives in `crate::gateway` so it can access the +// full `Config` and `ChannelEntry`. These wrappers exist so test modules +// (notably `dingtalk_tests`) can call the resolvers through the +// `dingtalk_worker` module path without depending on private items in +// `gateway::mod`. + +#[cfg(test)] +pub fn resolve_dingtalk_secret( + config: &crate::config::Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + crate::gateway::resolve_dingtalk_secret_for_test(config, entry) +} + +#[cfg(test)] +pub fn resolve_dingtalk_app_key( + config: &crate::config::Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + crate::gateway::resolve_dingtalk_app_key_for_test(config, entry) +} + +#[cfg(test)] +pub fn resolve_dingtalk_robot_code( + config: &crate::config::Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + crate::gateway::resolve_dingtalk_robot_code_for_test(config, entry) +} diff --git a/crates/omninova-core/src/gateway/feishu_worker.rs b/crates/omninova-core/src/gateway/feishu_worker.rs index 850ea331..ba29ef64 100644 --- a/crates/omninova-core/src/gateway/feishu_worker.rs +++ b/crates/omninova-core/src/gateway/feishu_worker.rs @@ -6,6 +6,7 @@ use crate::channels::ChannelKind; use crate::channels::adapters::outbound::OutboundResult; use crate::desktop_capture::{self, CaptureResult, MonitorResult}; +use crate::gateway::agent_menu::{build_agent_menu_panel, AgentMenuAction}; use crate::gateway::feishu_store::{FeishuStore, EventStatus, JobStatus, ReplyKind, chrono_timestamp}; use crate::gateway::OutboundMsgCache; use crate::gateway::{GatewayRuntime, MonitorFlightLease}; @@ -221,6 +222,26 @@ pub const ALLOWED_CARD_ACTIONS: &[&str] = &[ /// Build the command palette interactive card JSON. pub fn build_command_palette_card() -> serde_json::Value { + let panel = build_agent_menu_panel(); + let render_actions = |actions: &[AgentMenuAction]| { + actions + .iter() + .map(|item| { + serde_json::json!({ + "tag": "button", + "text": { + "tag": "plain_text", + "content": item.label + }, + "type": item.style.as_feishu_button_type(), + "value": { + "action": item.action + } + }) + }) + .collect::>() + }; + serde_json::json!({ "config": { "wide_screen_mode": true @@ -229,7 +250,7 @@ pub fn build_command_palette_card() -> serde_json::Value { "template": "blue", "title": { "tag": "plain_text", - "content": "OmniNova Agent 功能菜单" + "content": panel.title } }, "elements": [ @@ -237,7 +258,7 @@ pub fn build_command_palette_card() -> serde_json::Value { "tag": "div", "text": { "tag": "lark_md", - "content": "请选择要执行的操作。普通聊天可以直接发送文字;工具任务请使用按钮或 slash 命令。" + "content": panel.introduction } }, { @@ -247,80 +268,23 @@ pub fn build_command_palette_card() -> serde_json::Value { "tag": "div", "text": { "tag": "plain_text", - "content": "🟢 普通聊天说明" + "content": panel.ordinary_chat_label } }, { "tag": "action", - "actions": [ - { - "tag": "button", - "text": { - "tag": "plain_text", - "content": "桌面监控 30 秒" - }, - "type": "primary", - "value": { - "action": "monitor_30s" - } - }, - { - "tag": "button", - "text": { - "tag": "plain_text", - "content": "桌面监控 60 秒" - }, - "type": "primary", - "value": { - "action": "monitor_60s" - } - } - ] + "actions": render_actions(panel.primary_actions) }, { "tag": "action", - "actions": [ - { - "tag": "button", - "text": { - "tag": "plain_text", - "content": "Gateway 状态" - }, - "type": "default", - "value": { - "action": "gateway_status" - } - }, - { - "tag": "button", - "text": { - "tag": "plain_text", - "content": "最近任务" - }, - "type": "default", - "value": { - "action": "recent_jobs" - } - }, - { - "tag": "button", - "text": { - "tag": "plain_text", - "content": "帮助说明" - }, - "type": "default", - "value": { - "action": "help" - } - } - ] + "actions": render_actions(panel.secondary_actions) }, { "tag": "note", "elements": [ { "tag": "plain_text", - "content": "高风险工具不在普通聊天中直接执行。" + "content": panel.safety_note } ] } diff --git a/crates/omninova-core/src/gateway/mod.rs b/crates/omninova-core/src/gateway/mod.rs index 7af4c652..788b5629 100644 --- a/crates/omninova-core/src/gateway/mod.rs +++ b/crates/omninova-core/src/gateway/mod.rs @@ -1,8 +1,37 @@ +pub mod agent_menu; +pub mod dingtalk_card; +pub mod dingtalk_card_stream; +pub mod dingtalk_commands; +pub mod dingtalk_store; +pub mod dingtalk_stream; +pub mod dingtalk_worker; +pub mod feishu_store; +pub mod feishu_worker; pub mod pairing; pub mod ws; -pub mod feishu_worker; -pub mod feishu_store; +/// Unified DingTalk Stream lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DingTalkStreamState { + /// Stream is not running. + #[default] + Disconnected, + /// Requesting endpoint/ticket from DingTalk gateway. + Connecting, + /// WebSocket TCP connection established. + Connected, + /// Stream is registered and ready to receive business messages. + Registered, + /// Attempting to reconnect after a failure. + Reconnecting, + /// Stream is shutting down and will not accept new messages. + Stopping, +} + +#[cfg(test)] +mod dingtalk_tests; + +use crate::gateway::dingtalk_worker::verify_dingtalk_webhook_signature; use crate::gateway::feishu_store::FeishuStore; use home; @@ -49,11 +78,10 @@ use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, OnceLock}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::SystemTime; -use tokio::sync::RwLock; +use tokio::sync::{watch, RwLock}; use tracing::{info, warn}; static SESSION_LOCK_WAIT_EVENTS: AtomicU64 = AtomicU64::new(0); @@ -106,6 +134,21 @@ impl DedupCache { true // New event } + /// Remove a key from the dedup cache. Used to rollback a reservation when + /// enqueue fails (e.g. queue full), allowing retry without permanent dedupe. + async fn remove(&self, key: &str) { + let mut cache = self.inner.write().await; + cache.remove(key); + } + + /// Check if a key is present (without modifying state). + async fn contains(&self, key: &str) -> bool { + let mut cache = self.inner.write().await; + let now = Instant::now(); + cache.retain(|_, &mut expiry| now < expiry); + cache.contains_key(key) + } + /// Clear all cached entries. Intended for unit tests. #[cfg(test)] async fn clear(&self) { @@ -200,6 +243,98 @@ pub(crate) struct MonitorFlightGuard { min_ttl_secs: u64, } +// --------------------------------------------------------------------------- +// DingTalk Monitor single-flight guard +// --------------------------------------------------------------------------- + +/// Per-card single-flight guard for DingTalk monitor actions. Prevents concurrent +/// monitor executions on the same card: a second monitor callback sees BUSY +/// and returns without claiming a card generation or starting a second monitor. +#[derive(Debug, Clone)] +pub(crate) struct DingtalkMonitorGuard { + inner: Arc>>, + #[cfg(test)] + acquisition_attempts: Arc, +} + +#[derive(Debug)] +struct DingtalkMonitorEntry { + /// Opaque owner token: only this owner may release. + owner_id: String, + /// Absolute expiry instant. + expires_at: std::time::Instant, +} + +impl DingtalkMonitorGuard { + fn new() -> Self { + Self { + inner: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + #[cfg(test)] + acquisition_attempts: Arc::new(AtomicUsize::new(0)), + } + } + + /// Try to acquire the single-flight slot for `out_track_id` with a 90s TTL. + /// Returns `Some(owner_token)` if free, `None` if busy. + async fn try_acquire(&self, out_track_id: &str) -> Option { + #[cfg(test)] + self.acquisition_attempts.fetch_add(1, Ordering::AcqRel); + let owner_id = uuid::Uuid::new_v4().to_string(); + let ttl = std::time::Duration::from_secs(90); + let now = std::time::Instant::now(); + let mut guard = self.inner.write().await; + guard.retain(|_, entry| now < entry.expires_at); + if guard.contains_key(out_track_id) { + return None; + } + guard.insert( + out_track_id.to_string(), + DingtalkMonitorEntry { + owner_id: owner_id.clone(), + expires_at: now + ttl, + }, + ); + Some(owner_id) + } + + /// Release the slot. Returns `true` only if the caller is still the owner. + async fn release(&self, out_track_id: &str, owner_id: &str) -> bool { + let mut guard = self.inner.write().await; + guard.retain(|_, entry| { + std::time::Instant::now() < entry.expires_at + }); + let is_owner = guard + .get(out_track_id) + .is_some_and(|e| e.owner_id == owner_id); + if is_owner { + guard.remove(out_track_id); + } + is_owner + } + + /// Check if a card is currently busy without changing state. + async fn is_busy(&self, out_track_id: &str) -> bool { + let now = std::time::Instant::now(); + let guard = self.inner.read().await; + guard + .get(out_track_id) + .is_some_and(|e| now < e.expires_at) + } + + /// Clear all entries. For tests. + #[cfg(test)] + pub(crate) async fn clear(&self) { + let mut guard = self.inner.write().await; + guard.clear(); + self.acquisition_attempts.store(0, Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn acquisition_attempt_count(&self) -> usize { + self.acquisition_attempts.load(Ordering::Acquire) + } +} + fn monitor_flight_key(chat_id: &str) -> String { let raw_chat_id = chat_id.strip_prefix("feishu:").unwrap_or(chat_id); format!( @@ -326,10 +461,288 @@ pub struct GatewayRuntime { feishu_queue_len: Arc>, /// Feishu SQLite store for event/job/outbox persistence feishu_store: Option>, + /// DingTalk async job queue sender (for background worker processing) + dingtalk_job_sender: Arc>>, + /// DingTalk worker queue length tracker + dingtalk_queue_len: Arc>, + /// DingTalk in-memory store for job tracking + dingtalk_store: Arc>>, + /// Live DingTalk Stream connection state (unified for robot + card). + dingtalk_stream_connected: Arc, + /// Stream owner lifecycle: owner generation counter + shutdown flag + mutex. + /// The counter prevents old owners from clearing new owners' state (ABA race). + /// Only the current owner may call release (Drop or explicit cleanup). + dingtalk_stream_owner: Arc, /// Per-runtime webhook event deduplication state. dedup_cache: Arc, /// Per-runtime, per-chat desktop-monitor single-flight state. monitor_flights: Arc, + /// Per-card DingTalk monitor single-flight guard. + dingtalk_monitor_guard: Arc, +} + +// --------------------------------------------------------------------------- +// DingTalk Stream owner lifecycle +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// DingTalk Stream owner lifecycle +// --------------------------------------------------------------------------- + +/// Owner-aware Stream lifecycle guard using a generation counter to prevent +/// ABA races: an old owner cannot clear a new owner's state because each +/// start increments the generation. Only the current owner may release. +pub(crate) struct StreamOwner { + /// Incremented on each start. Guards against the ABA pattern where + /// old_owner releases → new_owner starts (CAS succeeds) → old_owner cleanup + /// incorrectly clears new_owner. + generation: AtomicU64, + /// Set true when a stream is currently active on this runtime. + active: AtomicBool, + /// Business frames are accepted only while the current owner is running + /// and has not entered shutdown. + accepting_frames: AtomicBool, + connected: AtomicBool, + /// Physical loop counter for diagnostics: incremented when a loop starts, + /// decremented when a loop exits. Never goes above 1 in normal operation. + active_loops: AtomicUsize, + /// Maximum physical active loops observed (diagnostic). + max_active_loops: AtomicUsize, + /// The active reconnect loop's JoinHandle, if spawned. + /// Uses parking_lot::Mutex because shutdown is called from sync Drop. + loop_handle: parking_lot::Mutex>, + /// Used to signal the reconnect loop to stop. Shared with the child task. + shutdown_tx: parking_lot::Mutex>>, +} + +struct OwnedStreamLoop { + owner_gen: u64, + handle: tokio::task::JoinHandle<()>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StreamShutdownOutcome { + NotRunning, + StaleOwner, + Graceful, + Aborted, + JoinFailed, +} + +struct PhysicalStreamLoopGuard { + owner: Arc, + owner_gen: u64, +} + +impl PhysicalStreamLoopGuard { + fn new(owner: Arc, owner_gen: u64) -> Self { + let current = owner.active_loops.fetch_add(1, Ordering::AcqRel) + 1; + let mut max = owner.max_active_loops.load(Ordering::Acquire); + while current > max { + match owner.max_active_loops.compare_exchange( + max, + current, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => break, + Err(observed) => max = observed, + } + } + Self { owner, owner_gen } + } +} + +impl Drop for PhysicalStreamLoopGuard { + fn drop(&mut self) { + self.owner.active_loops.fetch_sub(1, Ordering::AcqRel); + self.owner.finish_loop(self.owner_gen); + } +} + +impl StreamOwner { + fn new() -> Self { + Self { + generation: AtomicU64::new(0), + active: AtomicBool::new(false), + accepting_frames: AtomicBool::new(false), + connected: AtomicBool::new(false), + active_loops: AtomicUsize::new(0), + max_active_loops: AtomicUsize::new(0), + loop_handle: parking_lot::Mutex::new(None), + shutdown_tx: parking_lot::Mutex::new(None), + } + } + + /// Atomically acquire the owner, create its cancellation channel, spawn + /// the reconnect loop, and retain the JoinHandle. There is no interval in + /// which an active owner exists without a lifecycle handle. + fn try_start_loop(self: &Arc, build: F) -> Option + where + F: FnOnce(u64, watch::Receiver) -> Fut, + Fut: std::future::Future + Send + 'static, + { + let mut tx_guard = self.shutdown_tx.lock(); + let mut handle_guard = self.loop_handle.lock(); + if self.active.load(Ordering::Acquire) { + return None; + } + + // A naturally completed task may leave a completed JoinHandle behind. + // Physical activity is already zero before active becomes false. + let _ = handle_guard.take(); + let gen = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + let (tx, rx) = watch::channel(false); + self.active.store(true, Ordering::Release); + self.accepting_frames.store(true, Ordering::Release); + self.connected.store(false, Ordering::Release); + *tx_guard = Some(tx); + + let owner = self.clone(); + let future = build(gen, rx); + let handle = tokio::spawn(async move { + let _physical_loop = PhysicalStreamLoopGuard::new(owner, gen); + future.await; + }); + *handle_guard = Some(OwnedStreamLoop { + owner_gen: gen, + handle, + }); + Some(gen) + } + + /// Try to become the current owner and spawn the reconnect loop. + /// Returns (owner_gen, shutdown_rx) on success, or (current_gen, None) if + /// another owner is already active. + /// ABA-safe: increments generation so old owners cannot clear new owners. + fn try_acquire(&self) -> (u64, Option>) { + let mut tx_guard = self.shutdown_tx.lock(); + if self.active.load(Ordering::Acquire) { + return (self.generation.load(Ordering::Acquire), None); + } + let gen = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.active.store(true, Ordering::Release); + self.accepting_frames.store(true, Ordering::Release); + let (tx, rx) = watch::channel(false); + *tx_guard = Some(tx); + (gen, Some(rx)) + } + + /// Signal cancellation without releasing ownership. Drop uses this only as + /// a last-resort safety net; normal stop/restart calls shutdown_and_join. + fn signal_shutdown(&self, owner_gen: u64) -> bool { + if self.generation.load(Ordering::Acquire) != owner_gen { + return false; + } + self.accepting_frames.store(false, Ordering::Release); + self.connected.store(false, Ordering::Release); + if let Some(tx) = self.shutdown_tx.lock().as_ref() { + let _ = tx.send(true); + true + } else { + false + } + } + + async fn shutdown_and_join( + &self, + owner_gen: u64, + wait: Duration, + ) -> StreamShutdownOutcome { + if !self.active.load(Ordering::Acquire) { + return StreamShutdownOutcome::NotRunning; + } + if self.generation.load(Ordering::Acquire) != owner_gen { + return StreamShutdownOutcome::StaleOwner; + } + + self.signal_shutdown(owner_gen); + let owned = self.loop_handle.lock().take(); + let Some(mut owned) = owned else { + return StreamShutdownOutcome::JoinFailed; + }; + if owned.owner_gen != owner_gen { + *self.loop_handle.lock() = Some(owned); + return StreamShutdownOutcome::StaleOwner; + } + + let outcome = match tokio::time::timeout(wait, &mut owned.handle).await { + Ok(Ok(())) => StreamShutdownOutcome::Graceful, + Ok(Err(_)) => StreamShutdownOutcome::JoinFailed, + Err(_) => { + owned.handle.abort(); + let _ = owned.handle.await; + StreamShutdownOutcome::Aborted + } + }; + // PhysicalStreamLoopGuard performs the normal/abort cleanup. Keep this + // idempotent fallback for a panicking child. + self.finish_loop(owner_gen); + outcome + } + + fn finish_loop(&self, owner_gen: u64) { + if self.generation.load(Ordering::Acquire) != owner_gen { + return; + } + self.accepting_frames.store(false, Ordering::Release); + self.active.store(false, Ordering::Release); + self.connected.store(false, Ordering::Release); + self.shutdown_tx.lock().take(); + } + + /// Release this owner. Only called from Guard::Drop as a safety net. + /// The primary shutdown path is shutdown_and_join(). + fn release(&self, owner_gen: u64) { + self.finish_loop(owner_gen); + } + + /// Set connected=true. Only succeeds if caller is the current owner. + fn set_connected(&self, owner_gen: u64, value: bool) { + if self.generation.load(Ordering::Acquire) != owner_gen { + return; + } + if value && !self.accepting_frames.load(Ordering::Acquire) { + return; + } + self.connected.store(value, Ordering::Release); + } + + /// Returns true only for the current owner. + fn is_owner(&self, owner_gen: u64) -> bool { + self.generation.load(Ordering::Acquire) == owner_gen + && self.active.load(Ordering::Acquire) + && self.accepting_frames.load(Ordering::Acquire) + } + + fn current_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + pub(crate) fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) + } + + pub(crate) fn is_connected(&self) -> bool { + self.connected.load(Ordering::Acquire) + } + + /// Returns the current number of physical active loops (diagnostic). + pub(crate) fn active_loop_count(&self) -> usize { + self.active_loops.load(Ordering::Acquire) + } + + /// Returns the maximum observed active loops (diagnostic). + pub(crate) fn max_active_loop_count(&self) -> usize { + self.max_active_loops.load(Ordering::Acquire) + } + + /// Reset diagnostic counters. For tests only. + #[cfg(test)] + pub(crate) fn reset_diagnostics(&self) { + self.active_loops.store(0, Ordering::Release); + self.max_active_loops.store(0, Ordering::Release); + } } #[derive(Clone, Debug)] @@ -447,8 +860,14 @@ impl GatewayRuntime { feishu_job_sender: Arc::new(RwLock::new(None)), feishu_queue_len: Arc::new(RwLock::new(0)), feishu_store: None, + dingtalk_job_sender: Arc::new(RwLock::new(None)), + dingtalk_queue_len: Arc::new(RwLock::new(0)), + dingtalk_store: Arc::new(OnceLock::new()), + dingtalk_stream_connected: Arc::new(AtomicBool::new(false)), + dingtalk_stream_owner: Arc::new(StreamOwner::new()), dedup_cache: Arc::new(DedupCache::new(1800)), monitor_flights: MonitorFlightGuard::new(), + dingtalk_monitor_guard: Arc::new(DingtalkMonitorGuard::new()), } } @@ -466,8 +885,14 @@ impl GatewayRuntime { feishu_job_sender: Arc::new(RwLock::new(None)), feishu_queue_len: Arc::new(RwLock::new(0)), feishu_store: None, + dingtalk_job_sender: Arc::new(RwLock::new(None)), + dingtalk_queue_len: Arc::new(RwLock::new(0)), + dingtalk_store: Arc::new(OnceLock::new()), + dingtalk_stream_connected: Arc::new(AtomicBool::new(false)), + dingtalk_stream_owner: Arc::new(StreamOwner::new()), dedup_cache: Arc::new(DedupCache::new(1800)), monitor_flights: MonitorFlightGuard::new(), + dingtalk_monitor_guard: Arc::new(DingtalkMonitorGuard::new()), } } @@ -478,12 +903,81 @@ impl GatewayRuntime { pub(crate) fn monitor_flight_guard(&self) -> Arc { self.monitor_flights.clone() } + + pub(crate) fn dingtalk_monitor_guard(&self) -> Arc { + self.dingtalk_monitor_guard.clone() + } /// Initialize the Feishu async worker with the given queue pub async fn init_feishu_worker(&self, sender: FeishuJobSender) { let mut lock = self.feishu_job_sender.write().await; *lock = Some(sender); } + + pub async fn init_dingtalk_worker(&mut self) { + // The OnceLock is shared by every GatewayRuntime clone. Initialize it + // before the idempotent sender check so a clone created before worker + // startup observes the exact same store as the worker and Card callback. + let store = if let Some(store) = self.dingtalk_store.get() { + store.clone() + } else { + let config = self.get_config().await; + let config_dir = config + .config_path + .parent() + .map(std::path::Path::to_path_buf) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| home::home_dir().map(|home| home.join(".omninova"))) + .unwrap_or_else(|| std::path::PathBuf::from(".omninova")); + let candidate = Arc::new( + dingtalk_store::DingtalkStore::open(&config_dir).unwrap_or_else(|error| { + println!("[dingtalk-panel] store_persistent=false reason={error}"); + dingtalk_store::DingtalkStore::new() + }), + ); + self.dingtalk_store + .get_or_init(|| candidate) + .clone() + }; + // Keep initialization idempotent. Most importantly, never replace the + // sender while the receiver from the same channel is still running. + { + let sender = self.dingtalk_job_sender.read().await; + if sender.as_ref().is_some_and(|sender| !sender.is_closed()) { + println!("[dingtalk-async-worker] already_started=true"); + return; + } + } + + let worker_state = dingtalk_worker::DingtalkWorkerState::with_queue_len( + self.dingtalk_queue_len.clone(), + ); + let sender = worker_state.sender(); + + // Check again under the write lock. A repeated init must not install a + // sender from a new channel whose receiver will never be started. + { + let mut lock = self.dingtalk_job_sender.write().await; + if lock.as_ref().is_some_and(|sender| !sender.is_closed()) { + println!("[dingtalk-async-worker] already_started=true"); + return; + } + *lock = Some(sender); + } + + let runtime = Arc::new(self.clone()); + dingtalk_worker::start_dingtalk_worker(worker_state, runtime, store).await; + } + + /// Read-only diagnostic state. This does not start, stop, or replace the + /// worker channel and never exposes queued payloads or platform IDs. + pub async fn dingtalk_worker_started(&self) -> bool { + self.dingtalk_job_sender + .read() + .await + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + } /// Get approximate queue length pub async fn feishu_queue_len(&self) -> usize { @@ -675,6 +1169,125 @@ impl GatewayRuntime { sender.is_some() } + // ------------------------------------------------------------------------- + // DingTalk async worker methods + // ------------------------------------------------------------------------- + + pub async fn dingtalk_queue_len(&self) -> usize { + *self.dingtalk_queue_len.read().await + } + + pub async fn try_send_dingtalk_job(&self, job: dingtalk_worker::DingtalkAsyncJob) -> Result<(), dingtalk_worker::EnqueueError> { + let sender = self + .dingtalk_job_sender + .read() + .await + .as_ref() + .filter(|sender| !sender.is_closed()) + .cloned() + .ok_or(dingtalk_worker::EnqueueError::QueueFull)?; + { + // Increment before send so a fast receiver cannot decrement from + // zero and leave the approximate count permanently stale. + let mut queue_len = self.dingtalk_queue_len.write().await; + *queue_len += 1; + } + if sender.send(job).await.is_err() { + let mut queue_len = self.dingtalk_queue_len.write().await; + *queue_len = queue_len.saturating_sub(1); + return Err(dingtalk_worker::EnqueueError::QueueFull); + } + Ok(()) + } + + pub async fn is_dingtalk_worker_initialized(&self) -> bool { + let sender = self.dingtalk_job_sender.read().await; + sender.as_ref().is_some_and(|sender| !sender.is_closed()) + } + + /// Try to acquire Stream ownership. Returns (owner_gen, Some(shutdown_rx)) if + /// no other owner is active, or (current_gen, None) if already owned. + /// ABA-safe: increments generation counter so old owners cannot clear new owners. + pub(crate) fn try_acquire_stream_owner(&self) -> (u64, Option>) { + self.dingtalk_stream_owner.try_acquire() + } + + pub(crate) fn try_start_dingtalk_stream_loop(&self, build: F) -> Option + where + F: FnOnce(u64, watch::Receiver) -> Fut, + Fut: std::future::Future + Send + 'static, + { + self.dingtalk_stream_owner.try_start_loop(build) + } + + /// Release Stream ownership. ABA-safe: only releases if `owner_gen` matches + /// the current owner, preventing old owners from clearing new ones. + pub(crate) fn release_stream_owner(&self, owner_gen: u64) { + self.dingtalk_stream_owner.release(owner_gen); + } + + pub(crate) fn is_current_stream_owner(&self, owner_gen: u64) -> bool { + self.dingtalk_stream_owner.is_owner(owner_gen) + } + + /// Check if a Stream is currently active on this runtime. + pub(crate) fn is_dingtalk_stream_active(&self) -> bool { + self.dingtalk_stream_owner.is_active() + } + + /// Set connected state. Only the current owner (matching `owner_gen`) may set this. + pub(crate) fn set_dingtalk_stream_connected(&self, owner_gen: u64, value: bool) { + self.dingtalk_stream_owner.set_connected(owner_gen, value); + } + + pub(crate) fn dingtalk_stream_connected(&self) -> bool { + self.dingtalk_stream_owner.is_connected() + } + + /// Check if DingTalk Stream is in Registered state (truly ready for business). + pub fn is_dingtalk_stream_registered(&self) -> bool { + self.dingtalk_stream_connected() + } + + /// Signal the active reconnect loop to shut down without releasing its + /// physical ownership. Used only by Drop as a cancellation safety net. + pub(crate) fn signal_dingtalk_stream_shutdown(&self, owner_gen: u64) -> bool { + self.dingtalk_stream_owner.signal_shutdown(owner_gen) + } + + pub(crate) async fn shutdown_dingtalk_stream_generation( + &self, + owner_gen: u64, + wait: Duration, + ) -> StreamShutdownOutcome { + self.dingtalk_stream_owner + .shutdown_and_join(owner_gen, wait) + .await + } + + pub async fn shutdown_dingtalk_stream_and_join(&self) { + const STREAM_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + let owner_gen = self.dingtalk_stream_owner.current_generation(); + let outcome = self + .shutdown_dingtalk_stream_generation(owner_gen, STREAM_SHUTDOWN_TIMEOUT) + .await; + println!("[dingtalk-stream] lifecycle_join outcome={outcome:?}"); + } + + /// Number of physical active stream loops (diagnostic, should be 0 or 1). + pub(crate) fn dingtalk_active_loop_count(&self) -> usize { + self.dingtalk_stream_owner.active_loop_count() + } + + /// Maximum observed active stream loops (diagnostic). + pub(crate) fn dingtalk_max_active_loops(&self) -> usize { + self.dingtalk_stream_owner.max_active_loop_count() + } + + pub fn dingtalk_store(&self) -> Option> { + self.dingtalk_store.get().cloned() + } + pub fn with_cron_store(mut self, store: crate::cron::CronStore) -> Self { self.cron_store = Some(store); self @@ -2080,7 +2693,12 @@ impl GatewayRuntime { let runtime = self.clone(); let _worker_handle = spawn_worker(receiver, runtime.clone(), queue_len); println!("[gateway] feishu_async_worker started"); - + + // Initialize DingTalk async worker + self.init_dingtalk_worker().await; + // Start unified DingTalk Stream transport (handles both robot and card topics) + let mut dingtalk_stream_guard = dingtalk_stream::start(Arc::new(self.clone())).await; + // Run retry/recovery worker to re-send retryable outbox (template/monitor_final) // and to abandon LLM final outbox (cannot be sent without storing full body). crate::gateway::feishu_worker::run_retry_worker_once(&runtime).await; @@ -2107,6 +2725,20 @@ impl GatewayRuntime { .route("/webhook/feishu", post(http_feishu_webhook)) .route("/webhook/lark", post(http_lark_webhook)) .route("/webhook/dingtalk", post(http_dingtalk_webhook)) + // Alias documented in `config.template.toml` and used by the + // real DingTalk enterprise app bot callback wizard: + // `https:///api/v1/gateway/dingtalk/events`. + // Both routes point to the same handler so the documented + // URL works end-to-end while the legacy `/webhook/dingtalk` + // path keeps working for anyone already wired up. + .route( + "/api/v1/gateway/dingtalk/events", + post(http_dingtalk_webhook), + ) + .route( + agent_menu::DINGTALK_MENU_CARD_CALLBACK_PATH, + get(http_dingtalk_card_callback_get).post(http_dingtalk_card_callback_post), + ) .route("/webhook/feishu/card", post(http_feishu_card_callback)) .route("/sessions/tree", get(http_sessions_tree)) .route("/estop/status", get(http_estop_status)) @@ -2165,7 +2797,11 @@ impl GatewayRuntime { } let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; + let serve_result = axum::serve(listener, app).await; + if let Some(stream_guard) = dingtalk_stream_guard.as_mut() { + stream_guard.shutdown().await; + } + serve_result?; Ok(()) } } @@ -2479,10 +3115,17 @@ pub fn normalize_public_webhook_base_url(value: &str) -> Option { return None; } - let without_endpoint = trimmed - .strip_suffix("/webhook/feishu/card") - .or_else(|| trimmed.strip_suffix("/webhook/feishu")) - .unwrap_or(trimmed) + let lower = trimmed.to_ascii_lowercase(); + let endpoint_len = [ + "/api/v1/gateway/dingtalk/events", + "/webhook/feishu/card", + "/webhook/feishu", + "/webhook/dingtalk", + ] + .iter() + .find_map(|suffix| lower.ends_with(suffix).then_some(suffix.len())) + .unwrap_or(0); + let without_endpoint = trimmed[..trimmed.len().saturating_sub(endpoint_len)] .trim_end_matches('/'); if without_endpoint.is_empty() @@ -2757,6 +3400,112 @@ pub async fn check_gateway_public_health(config: &Config) -> GatewayPublicHealth } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct DingtalkPublicRouteProbe { + pub configured: bool, + pub reachable: bool, + pub status_code: Option, + pub result_kind: String, + pub message: String, +} + +fn classify_dingtalk_route_response(status_code: u16, body: &str) -> DingtalkPublicRouteProbe { + let lower = body.to_ascii_lowercase(); + if lower.contains("1033") { + return DingtalkPublicRouteProbe { + configured: true, + reachable: false, + status_code: Some(status_code), + result_kind: "tunnel_unreachable".to_string(), + message: "公网隧道不可达或地址已过期。".to_string(), + }; + } + if lower.contains("missing_timestamp") || lower.contains("missing_sign") { + return DingtalkPublicRouteProbe { + configured: true, + reachable: true, + status_code: Some(status_code), + result_kind: "missing_timestamp".to_string(), + message: "路由可达,缺少签名头;这是手动空请求的预期结果。".to_string(), + }; + } + if lower.contains("signature_mismatch") || lower.contains("invalid_sign") { + return DingtalkPublicRouteProbe { + configured: true, + reachable: true, + status_code: Some(status_code), + result_kind: "signature_mismatch".to_string(), + message: "路由可达,签名未通过;这是无有效签名手动请求的预期结果。".to_string(), + }; + } + if (200..300).contains(&status_code) { + return DingtalkPublicRouteProbe { + configured: true, + reachable: true, + status_code: Some(status_code), + result_kind: "ok".to_string(), + message: "钉钉公网回调路由可达。".to_string(), + }; + } + DingtalkPublicRouteProbe { + configured: true, + reachable: false, + status_code: Some(status_code), + result_kind: "http_error".to_string(), + message: format!("钉钉公网回调路由返回 HTTP {status_code}。"), + } +} + +/// Probe only route reachability with an empty JSON body. No platform secret, +/// signature, user message, or identifier is sent or retained. +pub async fn check_dingtalk_public_route(base_url: &str) -> DingtalkPublicRouteProbe { + let Some(base_url) = normalize_public_webhook_base_url(base_url) else { + return DingtalkPublicRouteProbe { + configured: false, + reachable: false, + status_code: None, + result_kind: "not_configured".to_string(), + message: "Public Base URL 未配置。".to_string(), + }; + }; + let callback_url = format!("{base_url}/api/v1/gateway/dingtalk/events"); + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .build() + { + Ok(client) => client, + Err(_) => { + return DingtalkPublicRouteProbe { + configured: true, + reachable: false, + status_code: None, + result_kind: "client_error".to_string(), + message: "无法创建公网路由检测客户端。".to_string(), + }; + } + }; + + match client.post(callback_url).json(&serde_json::json!({})).send().await { + Ok(response) => { + let status_code = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + classify_dingtalk_route_response(status_code, &body) + } + Err(error) => DingtalkPublicRouteProbe { + configured: true, + reachable: false, + status_code: None, + result_kind: if error.is_timeout() { + "timeout" + } else { + "network_error" + } + .to_string(), + message: "公网隧道不可达或地址已过期,请检查 cloudflared。".to_string(), + }, + } +} + /// Productized runtime status — safe to return to UI/CLI without leaking secrets. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GatewayRuntimeStatus { @@ -3872,6 +4621,30 @@ async fn process_feishu_card_action_callback( } }; + // Feishu validates the dedicated card callback URL with the same + // top-level url_verification payload used by the normal event endpoint. + // This must return before card action extraction because the challenge + // intentionally has no event/action object. + if payload.get("type").and_then(serde_json::Value::as_str) + == Some("url_verification") + { + println!("[feishu-card] url_verification_detected"); + let challenge = payload + .get("challenge") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(GatewayError { + message: "missing_challenge".to_string(), + }), + ) + })?; + println!("[feishu-card] challenge_response_sent"); + return Ok(Json(serde_json::json!({ "challenge": challenge }))); + } + println!( "[feishu-card] payload_shape top_keys={:?} event_keys={:?}", safe_json_object_keys(Some(&payload)), @@ -4508,7 +5281,518 @@ async fn http_dingtalk_webhook( headers: HeaderMap, raw_body: String, ) -> Result, (StatusCode, Json)> { - http_channel_webhook(runtime, headers, raw_body, ChannelKind::Dingtalk).await + let cfg = runtime.get_config().await; + + // ==== DingTalk-specific security checks ==== + // 1. Enabled check. + // + // Effective state = `gateway.dingtalk.enabled` (preferred new master + // switch) OR legacy `channels_config.dingtalk.enabled` (kept for + // backwards compatibility). When both are unset (the default), the + // route returns `channel_disabled` and no further work is done. + if !is_dingtalk_effectively_enabled(&cfg) { + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": false, + "processing": "skipped", + "reason": "channel_disabled" + }))); + } + + // 2. Parse JSON payload early for signature verification + let payload: serde_json::Value = match serde_json::from_str(&raw_body) { + Ok(p) => p, + Err(e) => { + println!("[dingtalk-webhook] rejected reason=invalid_json parse_error={}", e); + return Err(( + StatusCode::BAD_REQUEST, + Json(GatewayError { + message: "invalid_json".to_string(), + }), + )); + } + }; + + // 3. Signature verification (DingTalk uses HMAC-SHA256 with timestamp+app_secret) + let dingtalk_entry = cfg.channels_config.dingtalk.as_ref(); + let app_secret = resolve_dingtalk_secret(&cfg, dingtalk_entry); + if app_secret.is_some() { + match verify_dingtalk_webhook_signature(&headers, &raw_body, app_secret.as_deref()) { + Ok(()) => { + println!("[dingtalk-security] signature_verified"); + } + Err(reason) => { + println!("[dingtalk-security] rejected reason={}", reason); + return Err(( + StatusCode::UNAUTHORIZED, + Json(GatewayError { + message: reason, + }), + )); + } + } + } else { + // Dev mode - no app_secret configured + if std::sync::atomic::AtomicU64::new(0) + .fetch_add(0, std::sync::atomic::Ordering::Relaxed) + == 0 + {} + // Low-frequency dev mode warning (every 100th request) + use std::sync::atomic::{AtomicU64, Ordering}; + static DEV_WARNING_COUNTER: AtomicU64 = AtomicU64::new(0); + let count = DEV_WARNING_COUNTER.fetch_add(1, Ordering::Relaxed); + if count % 100 == 0 { + println!("[dingtalk-security] insecure_webhook_allowed mode=dev"); + } + } + + // 4. Extract key fields for filtering. + // + // Real DingTalk enterprise app bot v1 callbacks send: + // - `msgtype` (lowercase) for the message-type discriminator + // - `text.content` (nested) for the user-visible text + // - `msgId` (camelCase) for the platform message id + // + // Some DingTalk client SDKs and proxies still emit `msgType`, + // `text` as a flat string, or `messageId`. We accept both for + // forward compatibility without changing the Phase 1 internal model. + let msg_type = payload + .get("msgType") + .or_else(|| payload.get("msgtype")) + .and_then(|v| v.as_str()) + .map(String::from); + let is_text_message = msg_type.as_deref() == Some("text"); + + // URL verification challenge (real DingTalk sends this once at + // first connection). Phase 1 silently dropped these; Phase 3 must + // echo `challenge` back so the platform registers our URL. + if payload.get("eventType").and_then(|v| v.as_str()) + == Some("url_verification") + { + if let Some(challenge) = payload.get("challenge").and_then(|v| v.as_str()) { + println!( + "[dingtalk-webhook] url_verification_ok challenge_len={}", + challenge.len() + ); + return Ok(Json(serde_json::json!({ "challenge": challenge }))); + } + println!("[dingtalk-webhook] url_verification_missing_challenge"); + } + + // 5. Extract sender info (DingTalk corpid/userid) + let sender_staff_id = payload + .get("senderStaffId") + .and_then(|v| v.as_str()) + .map(String::from); + let sender_nick = payload + .get("senderNick") + .and_then(|v| v.as_str()) + .map(String::from); + let conversation_id = payload + .get("conversationId") + .and_then(|v| v.as_str()) + .map(String::from); + let session_webhook = payload + .get("sessionWebhook") + .and_then(|v| v.as_str()) + .map(String::from); + let message_id = payload + .get("messageId") + .or_else(|| payload.get("msgId")) + .and_then(|v| v.as_str()) + .map(String::from); + let robot_code = payload + .get("robotCode") + .and_then(|v| v.as_str()) + .map(String::from); + + // Log inbound (presence flags and length only — never the values). + println!( + "[dingtalk-webhook] received msg_type={:?} has_sender={} has_conversation={} has_webhook={} has_msgid={} has_robot_code={} text_len={}", + msg_type, + sender_staff_id.as_ref().map(|_| true).unwrap_or(false), + conversation_id.as_ref().map(|_| true).unwrap_or(false), + session_webhook.as_ref().map(|_| true).unwrap_or(false), + message_id.as_ref().map(|_| true).unwrap_or(false), + robot_code.as_ref().map(|_| true).unwrap_or(false), + extract_dingtalk_text_len(&payload) + ); + + // 6. Filter: only handle text messages (Phase 1) + if !is_text_message { + println!("[dingtalk-webhook] skip_unsupported_message_type msg_type={:?}", msg_type); + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "skipped", + "reason": "unsupported_message_type" + }))); + } + + // 7. Extract text content. Accept both nested `text.content` (real + // DingTalk) and flat `text` (some proxies / SDKs). `text.content` + // wins when both are present. + let text = extract_dingtalk_text(&payload); + if text.trim().is_empty() { + println!("[dingtalk-webhook] skip_empty_text"); + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "skipped", + "reason": "empty_text" + }))); + } + + // 8. Build InboundMessage with DingTalk-specific metadata + let mut metadata = std::collections::HashMap::new(); + if let Some(ref sid) = sender_staff_id { + metadata.insert("senderStaffId".to_string(), serde_json::json!(sid)); + } + if let Some(ref nick) = sender_nick { + metadata.insert("senderNick".to_string(), serde_json::json!(nick)); + } + if let Some(ref cid) = conversation_id { + metadata.insert("conversationId".to_string(), serde_json::json!(cid)); + } + if let Some(ref webhook) = session_webhook { + metadata.insert("sessionWebhook".to_string(), serde_json::json!(webhook)); + } + if let Some(ref mid) = message_id { + metadata.insert("messageId".to_string(), serde_json::json!(mid)); + } + if let Some(ref rc) = robot_code { + metadata.insert("robotCode".to_string(), serde_json::json!(rc)); + } + metadata.insert("source".to_string(), serde_json::json!("dingtalk")); + metadata.insert("raw_payload".to_string(), payload.clone()); + + let inbound = InboundMessage { + channel: ChannelKind::Dingtalk, + user_id: sender_staff_id, + session_id: conversation_id, + text, + metadata, + }; + + // 8a. Cross-transport dedupe: if DingTalk also delivered this message via + // the Stream transport (or via a retry), the Stream-side `try_dingtalk_stream_dedupe` + // call already inserted `dt_stream:` into the shared `dedup_cache`. + // Re-check from the HTTP side so a duplicate delivery does not produce + // two replies. Without this guard, an HTTP+Stream hybrid setup would + // double-process every regular robot message. + if let Some(ref mid) = message_id { + let dedup_key = format!("dt_stream:{}", mid); + let dedup_cache = runtime.dedup_cache(); + let is_new = dedup_cache.check_and_insert(&dedup_key).await; + if !is_new { + println!( + "[dingtalk-webhook] dedupe_shared=true msg_id_hash={}", + mid.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .take(12) + .collect::() + .len() + ); + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "deduped", + "reason": "stream_dedupe_hit" + }))); + } + } + + // 9. Check if DingTalk worker is initialized + let is_async_available = runtime.is_dingtalk_worker_initialized().await; + + if is_async_available { + // Queue job for background processing + let text_len = inbound.text.chars().count(); + let queue_len_before = runtime.dingtalk_queue_len().await; + println!( + "[dingtalk-webhook] enqueue_attempt=true text_len={} queue_len={}", + text_len, + queue_len_before + ); + + let job = dingtalk_worker::DingtalkAsyncJob::new(inbound, payload); + let job_id = job.job_id.clone(); + + match runtime.try_send_dingtalk_job(job).await { + Ok(()) => { + let queue_len = runtime.dingtalk_queue_len().await; + println!( + "[dingtalk-webhook] enqueue_success=true queue_len={}", + queue_len + ); + println!( + "[dingtalk-webhook] ack_queued ack_ms={} queue_len={}", + 0, + queue_len + ); + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "queued", + "job_id": job_id + }))); + } + Err(_) => { + // CRITICAL: rollback dedupe reservation so DingTalk can retry without + // permanent dedupe hit. This prevents message loss when queue is full. + if let Some(ref mid) = message_id { + let dedup_key = format!("dt_stream:{}", mid); + runtime.dedup_cache().remove(&dedup_key).await; + } + let queue_len = runtime.dingtalk_queue_len().await; + println!( + "[dingtalk-webhook] enqueue_attempt=true text_len={} queue_len={} result=failed dedupe_rollback=true", + text_len, + queue_len + ); + println!("[dingtalk-worker] queue_full capacity=100"); + return Ok(Json(serde_json::json!({ + "ok": false, + "accepted": false, + "processing": "queue_full", + "retryable": true + }))); + } + } + } + + // 10. Fallback: sync processing via Runtime + outbound + println!("[dingtalk-webhook] sync_mode reason=worker_not_initialized"); + + // Phase 2: short-circuit commands (help/menu/status/ping/monitor) + // even in the sync fallback path. Non-command text continues into + // the agent exactly like Phase 1. + let sync_command_inputs = crate::gateway::dingtalk_commands::DingtalkStatusInputs { + config: &cfg, + worker_initialized: false, + queue_len: runtime.dingtalk_queue_len().await, + }; + let command_decision = crate::gateway::dingtalk_commands::evaluate_dingtalk_command( + &inbound.text, + Some(&payload), + sync_command_inputs, + ); + if let Some((cmd, command_reply)) = command_decision { + println!( + "[dingtalk-webhook] command_reply command={}", + cmd.name() + ); + if command_reply.trim().is_empty() { + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "completed", + "reply": "" + }))); + } + let result = deliver_dingtalk_reply(&cfg, &inbound, &command_reply).await; + match result { + Ok(_) => { + println!( + "[dingtalk-webhook] outbound_ok reply_len={}", + command_reply.len() + ); + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "completed", + "reply": "" + }))); + } + Err(e) => { + println!("[dingtalk-webhook] outbound_failed error={}", e); + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "completed_with_error", + "reply": "", + "platform_error": e + }))); + } + } + } + + let response = match runtime.process_inbound(&inbound).await { + Ok(r) => r, + Err(e) => { + println!("[dingtalk-webhook] runtime_failed error={}", e); + return Ok(Json(serde_json::json!({ + "ok": false, + "error": "agent_runtime_failed", + "message": e.to_string() + }))); + } + }; + + if response.reply.trim().is_empty() { + return Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "completed", + "reply": "" + }))); + } + + // Deliver reply via sessionWebhook + let result = deliver_dingtalk_reply(&cfg, &inbound, &response.reply).await; + match result { + Ok(_) => { + println!("[dingtalk-webhook] outbound_ok reply_len={}", response.reply.len()); + Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "completed", + "reply": "" + }))) + } + Err(e) => { + println!("[dingtalk-webhook] outbound_failed error={}", e); + Ok(Json(serde_json::json!({ + "ok": true, + "accepted": true, + "processing": "completed_with_error", + "reply": "", + "platform_error": e + }))) + } + } +} + +#[derive(Debug, serde::Deserialize)] +struct DingtalkCardActionQuery { + action: Option, +} + +#[derive(Debug, serde::Serialize)] +struct DingtalkCardActionResponse { + ok: bool, + action: Option, + message: String, +} + +async fn http_dingtalk_card_callback_get( + State(runtime): State, + Query(query): Query, +) -> (StatusCode, Json) { + process_dingtalk_card_action(&runtime, query.action.as_deref()).await +} + +async fn http_dingtalk_card_callback_post( + State(runtime): State, + raw_body: String, +) -> (StatusCode, Json) { + let payload = match serde_json::from_str::(&raw_body) { + Ok(payload) => payload, + Err(_) => { + println!("[dingtalk-card-callback] rejected reason=invalid_json"); + return ( + StatusCode::BAD_REQUEST, + Json(DingtalkCardActionResponse { + ok: false, + action: None, + message: "无效的卡片回调数据。".to_string(), + }), + ); + } + }; + let action = agent_menu::extract_dingtalk_agent_menu_action(&payload); + process_dingtalk_card_action(&runtime, action.as_deref()).await +} + +async fn process_dingtalk_card_action( + runtime: &GatewayRuntime, + raw_action: Option<&str>, +) -> (StatusCode, Json) { + let Some(raw_action) = raw_action.map(str::trim).filter(|value| !value.is_empty()) else { + println!("[dingtalk-card-callback] action_present=false"); + return ( + StatusCode::BAD_REQUEST, + Json(DingtalkCardActionResponse { + ok: false, + action: None, + message: "缺少卡片操作。".to_string(), + }), + ); + }; + let Some(action) = agent_menu::canonical_agent_menu_action(raw_action) else { + println!("[dingtalk-card-callback] action_present=true action=unknown"); + return ( + StatusCode::BAD_REQUEST, + Json(DingtalkCardActionResponse { + ok: false, + action: None, + message: "未知操作,已忽略。".to_string(), + }), + ); + }; + + println!( + "[dingtalk-card-callback] action_present=true action={} identifiers_logged=false", + action + ); + let message = match action { + "monitor_30s" => "当前 DingTalk 端暂未启用桌面监控 30 秒操作。".to_string(), + "monitor_60s" => "当前 DingTalk 端暂未启用桌面监控 60 秒操作。".to_string(), + "gateway_status" => { + let config = runtime.get_config().await; + let worker_started = runtime.dingtalk_worker_started().await; + let queue_len = runtime.dingtalk_queue_len().await; + format!( + "Gateway 状态:运行中\nDingTalk:{}\nWorker:{}\n队列任务:{}", + if is_dingtalk_effectively_enabled(&config) { + "已启用" + } else { + "未启用" + }, + if worker_started { + "已启动" + } else { + "未启动" + }, + queue_len + ) + } + "recent_jobs" => match runtime.dingtalk_store() { + Some(store) => { + let jobs = store.get_recent_jobs(5).await; + if jobs.is_empty() { + "暂无最近任务。".to_string() + } else { + let completed = jobs + .iter() + .filter(|job| job.status == dingtalk_store::JobStatus::Completed) + .count(); + let failed = jobs + .iter() + .filter(|job| job.status == dingtalk_store::JobStatus::Failed) + .count(); + format!( + "最近任务:{} 条\n已完成:{}\n失败:{}", + jobs.len(), + completed, + failed + ) + } + } + None => "暂无最近任务。".to_string(), + }, + "help" => agent_menu::render_agent_menu_as_dingtalk_text(), + _ => unreachable!("canonical Agent menu action is exhaustive"), + }; + + ( + StatusCode::OK, + Json(DingtalkCardActionResponse { + ok: true, + action: Some(action.to_string()), + message, + }), + ) } async fn http_channel_webhook( @@ -5588,130 +6872,563 @@ pub async fn deliver_interactive_card( } } -async fn deliver_platform_reply( +async fn deliver_platform_reply( + config: &Config, + inbound: &InboundMessage, + reply: &str, +) -> Option { + let entry = channel_entry_for_outbound(config, &inbound.channel)?; + + // Diagnostic: log outbound config + let channel_name_for_log = format!("{:?}", inbound.channel).to_lowercase(); + let outbound_mode = config_string(entry, "outbound_mode") + .unwrap_or_else(|| "disabled".to_string()) + .to_ascii_lowercase(); + let app_id_present = config_string(entry, "app_id").is_some(); + let app_secret_present = config_string(entry, "app_secret").is_some(); + println!( + "[{}-webhook] outbound_config outbound_mode={} app_id_present={} app_secret_present={}", + channel_name_for_log, outbound_mode, app_id_present, app_secret_present + ); + + if outbound_mode == "disabled" { + println!("[{}-webhook] outbound_skip reason=disabled", channel_name_for_log); + return None; + } + + let provider = match inbound.channel { + ChannelKind::Feishu => "feishu", + ChannelKind::Lark => "lark", + _ => { + println!("[{}-webhook] outbound_skip reason=unsupported_channel", channel_name_for_log); + return None; + } + }; + + if reply.trim().is_empty() { + println!("[{}-webhook] outbound_skip reason=empty_reply", channel_name_for_log); + return Some(OutboundResult::skipped_empty_reply(provider)); + } + + // Diagnostic: log reply target + let target = reply_target_from_inbound(inbound); + println!( + "[{}-webhook] outbound_target chat_id_present={} message_id_present={} user_id_present={}", + channel_name_for_log, + target.as_ref().map(|t| !t.chat_id.is_empty()).unwrap_or(false), + target.as_ref().and_then(|t| t.message_id.as_ref()).map(|s| !s.is_empty()).unwrap_or(false), + target.as_ref().and_then(|t| t.user_id.as_ref()).map(|s| !s.is_empty()).unwrap_or(false) + ); + + let Some(target) = target else { + println!("[{}-webhook] outbound_skip reason=missing_reply_target", channel_name_for_log); + return Some(OutboundResult::failed( + provider, + "missing_reply_target", + "reply target was missing", + )); + }; + + match outbound_mode.as_str() { + "mock" => { + println!("[{}-outbound] selected_sender sender=mock", channel_name_for_log); + let sender = MockOutboundSender::new(); + Some(send_with_sender(&sender, &target, reply).await) + } + "real" => { + let Some(_app_id) = config_string(entry, "app_id") else { + println!("[{}-outbound] outbound_skip reason=missing_app_id", channel_name_for_log); + return Some(OutboundResult::not_configured( + provider, + "app_id was missing", + )); + }; + let Some(_app_secret) = config_string(entry, "app_secret") else { + println!("[{}-outbound] outbound_skip reason=missing_app_secret", channel_name_for_log); + return Some(OutboundResult::not_configured( + provider, + "app_secret was missing", + )); + }; + println!("[{}-outbound] selected_sender provider={}", channel_name_for_log, provider); + let token_cache = outbound_token_cache(); + let result = match inbound.channel { + ChannelKind::Feishu => { + let sender = FeishuOutboundSender::new(_app_id, _app_secret, token_cache); + send_with_sender(&sender, &target, reply).await + } + ChannelKind::Lark => { + let sender = LarkOutboundSender::new(_app_id, _app_secret, token_cache); + send_with_sender(&sender, &target, reply).await + } + _ => OutboundResult::failed( + provider, + "not_implemented", + "sender was not implemented", + ), + }; + println!( + "[{}-outbound] send_result ok={} delivery={:?}", + channel_name_for_log, result.ok, result.delivery + ); + + // Record outbound message_id for self-message filtering + if result.ok { + if let Some(msg_id) = result.platform_message_id.as_ref() { + let channel_str = format!("{:?}", inbound.channel).to_lowercase(); + OutboundMsgCache::global() + .record_outbound(&channel_str, msg_id) + .await; + println!( + "[{}-outbound] recorded_outbound_message_id=true ttl_secs=1800", + channel_name_for_log + ); + } + } + + Some(result) + } + _ => { + println!("[{}-webhook] outbound_skip reason=invalid_mode", channel_name_for_log); + Some(OutboundResult::not_configured( + provider, + &format!("outbound_mode '{}' must be disabled, mock, or real", outbound_mode), + )) + } + } +} + +/// Resolve DingTalk app_secret from channel config (extra or env var) OR +/// the new top-level `gateway.dingtalk` config block. +/// +/// Order (first non-empty wins): +/// 1. Channel entry's `app_secret` field in `extra` +/// 2. `gateway.dingtalk.app_secret` (inline) +/// 3. `gateway.dingtalk.app_secret_env` env var +/// 4. `OMNINOVA_DINGTALK_APP_SECRET` env var directly +fn resolve_dingtalk_secret( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + if let Some(entry) = entry { + if let Some(v) = entry + .extra + .get("app_secret") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(v.to_string()); + } + } + + let cfg = &config.gateway.dingtalk; + if !cfg.app_secret.trim().is_empty() { + return Some(cfg.app_secret.trim().to_string()); + } + + if let Some(ref env_name) = cfg.app_secret_env { + if let Ok(v) = std::env::var(env_name) { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + if let Ok(v) = std::env::var("OMNINOVA_DINGTALK_APP_SECRET") { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + None +} + +/// Deliver reply via DingTalk sessionWebhook (Phase 1 outbound) +async fn deliver_dingtalk_reply( + config: &Config, + inbound: &InboundMessage, + reply: &str, +) -> Result<(), String> { + let entry = config.channels_config.dingtalk.as_ref(); + + // Outbound mode priority: + // 1. `gateway.dingtalk.outbound_mode` (new top-level, default `session_webhook`) + // 2. legacy `channels_config.dingtalk.extra["outbound_mode"]` + // Treat absent / empty as `"disabled"` so the legacy "did not set a mode" + // behavior keeps working. + let outbound_mode = { + let top = config.gateway.dingtalk.outbound_mode.trim(); + if !top.is_empty() { + top.to_string() + } else { + entry + .and_then(|e| e.extra.get("outbound_mode")) + .and_then(|v| v.as_str()) + .unwrap_or("disabled") + .to_string() + } + }; + + if outbound_mode == "disabled" { + println!("[dingtalk-webhook] outbound_skip reason=disabled"); + return Ok(()); + } + + if outbound_mode == "mock" { + println!("[dingtalk-webhook] outbound_selected sender=mock"); + return Ok(()); + } + + // real mode: use sessionWebhook + let session_webhook_present = inbound + .metadata + .get("sessionWebhook") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if !session_webhook_present { + println!( + "[dingtalk-webhook] session_webhook_missing fallback=robot_code_required \ + inbound_session_id_present={}", + inbound.session_id.is_some() + ); + } + + let app_key = resolve_dingtalk_app_key(config, entry) + .ok_or_else(|| "missing_app_key".to_string())?; + let app_secret = resolve_dingtalk_secret(config, entry) + .ok_or_else(|| "missing_app_secret".to_string())?; + + let token = dingtalk_worker::fetch_dingtalk_access_token(&app_key, &app_secret) + .await + .map_err(|e| format!("token_fetch_error: {}", e))?; + + // Fallback robot_code comes from the gateway top-level config so + // the send path can build a valid `sendFromApp` body even when the + // inbound payload does not carry a `robotCode` field. + let fallback_robot_code = resolve_dingtalk_robot_code(config, entry); + + dingtalk_worker::send_dingtalk_text_message( + &token, + inbound, + fallback_robot_code.as_deref(), + reply, + ) + .await +} + +/// Resolve DingTalk app_key from channel config (extra) OR the top-level +/// `gateway.dingtalk` config. Order: channel extra, gateway.dingtalk.app_key, +/// gateway.dingtalk.app_key_env, `OMNINOVA_DINGTALK_APP_KEY` env var. +fn resolve_dingtalk_app_key( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + if let Some(entry) = entry { + if let Some(v) = entry + .extra + .get("app_key") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(v.to_string()); + } + } + + let cfg = &config.gateway.dingtalk; + if !cfg.app_key.trim().is_empty() { + return Some(cfg.app_key.trim().to_string()); + } + + if let Some(ref env_name) = cfg.app_key_env { + if let Ok(v) = std::env::var(env_name) { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + if let Ok(v) = std::env::var("OMNINOVA_DINGTALK_APP_KEY") { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + None +} + +/// Resolve DingTalk robot_code with the same precedence as app_key. +fn resolve_dingtalk_robot_code( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + if let Some(entry) = entry { + if let Some(v) = entry + .extra + .get("robot_code") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(v.to_string()); + } + } + + let cfg = &config.gateway.dingtalk; + if !cfg.robot_code.trim().is_empty() { + return Some(cfg.robot_code.trim().to_string()); + } + + if let Some(ref env_name) = cfg.robot_code_env { + if let Ok(v) = std::env::var(env_name) { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + if let Ok(v) = std::env::var("OMNINOVA_DINGTALK_ROBOT_CODE") { + let trimmed = v.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + None +} + +fn resolve_dingtalk_card_template( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + if let Some(entry) = entry { + if let Some(value) = entry + .extra + .get("card_template_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + } + + let cfg = &config.gateway.dingtalk; + if !cfg.card_template_id.trim().is_empty() { + return Some(cfg.card_template_id.trim().to_string()); + } + if let Some(env_name) = cfg.card_template_id_env.as_deref() { + if let Ok(value) = std::env::var(env_name) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + std::env::var("OMNINOVA_DINGTALK_CARD_TEMPLATE_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn resolve_dingtalk_transport_mode( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> crate::config::schema::DingtalkTransportMode { + entry + .and_then(|entry| entry.extra.get("transport_mode")) + .and_then(serde_json::Value::as_str) + .and_then(crate::config::schema::DingtalkTransportMode::from_config_value) + .unwrap_or(config.gateway.dingtalk.transport_mode) +} + +/// Returns true if the top-level `gateway.dingtalk` block is enabled. +/// Per-channel `channels_config.dingtalk.enabled` continues to be honored +/// by `is_channel_enabled`; this helper is for master-switch checks. +fn is_dingtalk_gateway_enabled(config: &Config) -> bool { + config.gateway.dingtalk.enabled +} + +/// Effective DingTalk enablement: top-level master switch OR legacy +/// channel entry. Either being on is sufficient to accept inbound +/// webhooks; the per-channel flag exists for backwards compatibility with +/// existing config.toml files that only set `channels.dingtalk.enabled`. +/// Both default to disabled, so a Config with neither set yields false. +fn is_dingtalk_effectively_enabled(config: &Config) -> bool { + if is_dingtalk_gateway_enabled(config) { + return true; + } + if is_channel_enabled(config, &ChannelKind::Dingtalk) { + return true; + } + false +} + +/// Sanitized DingTalk configuration snapshot used by desktop diagnostics. +/// It deliberately exposes only presence booleans and non-secret mode/path +/// labels while reusing the same resolvers as the live outbound path. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct DingtalkDiagnosticConfigState { + pub enabled: bool, + pub app_key_present: bool, + pub app_secret_present: bool, + pub robot_code_present: bool, + pub webhook_path: String, + pub outbound_mode: String, +} + +pub fn dingtalk_diagnostic_config_state(config: &Config) -> DingtalkDiagnosticConfigState { + let entry = config.channels_config.dingtalk.as_ref(); + let top_level_mode = config.gateway.dingtalk.outbound_mode.trim(); + let outbound_mode = if top_level_mode.is_empty() { + entry + .and_then(|entry| entry.extra.get("outbound_mode")) + .and_then(serde_json::Value::as_str) + .unwrap_or("disabled") + .to_string() + } else { + top_level_mode.to_string() + }; + + DingtalkDiagnosticConfigState { + enabled: is_dingtalk_effectively_enabled(config), + app_key_present: resolve_dingtalk_app_key(config, entry).is_some(), + app_secret_present: resolve_dingtalk_secret(config, entry).is_some(), + robot_code_present: resolve_dingtalk_robot_code(config, entry).is_some(), + webhook_path: "/api/v1/gateway/dingtalk/events".to_string(), + outbound_mode, + } +} + +// --------------------------------------------------------------------------- +// DingTalk payload parsing helpers (Phase 3 — real-platform compatibility) +// --------------------------------------------------------------------------- +// +// Real DingTalk enterprise app bot v1 callbacks deliver the user-visible +// text under `text.content`, not as a flat `text` string. The same body +// shape, however, also travels through some proxies / SDKs as a flat +// `text` field. Both shapes are accepted here so the same handler can +// process real production callbacks AND the synthetic payloads used in +// tests. + +/// Extract the user-visible text from a DingTalk callback payload, +/// preferring the nested `text.content` shape and falling back to a +/// flat `text` string when only that one is present. +fn extract_dingtalk_text(payload: &serde_json::Value) -> String { + if let Some(content) = payload + .get("text") + .and_then(|v| v.get("content")) + .and_then(|v| v.as_str()) + { + return content.to_string(); + } + if let Some(s) = payload.get("text").and_then(|v| v.as_str()) { + return s.to_string(); + } + String::new() +} + +/// Length of the user-visible text without copying it out — used in +/// log lines that must not contain the message body itself. +fn extract_dingtalk_text_len(payload: &serde_json::Value) -> usize { + extract_dingtalk_text(payload).chars().count() +} + +// --------------------------------------------------------------------------- +// Worker-callable resolvers (used by `dingtalk_worker` at runtime, not only in tests) +// --------------------------------------------------------------------------- + +pub(crate) fn resolve_dingtalk_secret_for_worker( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_secret(config, entry) +} + +pub(crate) fn resolve_dingtalk_app_key_for_worker( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_app_key(config, entry) +} + +pub(crate) fn resolve_dingtalk_robot_code_for_worker( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_robot_code(config, entry) +} + +pub(crate) fn resolve_dingtalk_card_template_for_worker( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_card_template(config, entry) +} + +pub(crate) fn resolve_dingtalk_transport_mode_for_worker( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> crate::config::schema::DingtalkTransportMode { + resolve_dingtalk_transport_mode(config, entry) +} + +// --------------------------------------------------------------------------- +// Test-only re-exports (used by `dingtalk_worker` wrappers in unit tests) +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) fn resolve_dingtalk_secret_for_test( config: &Config, - inbound: &InboundMessage, - reply: &str, -) -> Option { - let entry = channel_entry_for_outbound(config, &inbound.channel)?; - - // Diagnostic: log outbound config - let channel_name_for_log = format!("{:?}", inbound.channel).to_lowercase(); - let outbound_mode = config_string(entry, "outbound_mode") - .unwrap_or_else(|| "disabled".to_string()) - .to_ascii_lowercase(); - let app_id_present = config_string(entry, "app_id").is_some(); - let app_secret_present = config_string(entry, "app_secret").is_some(); - println!( - "[{}-webhook] outbound_config outbound_mode={} app_id_present={} app_secret_present={}", - channel_name_for_log, outbound_mode, app_id_present, app_secret_present - ); + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_secret(config, entry) +} - if outbound_mode == "disabled" { - println!("[{}-webhook] outbound_skip reason=disabled", channel_name_for_log); - return None; - } +#[cfg(test)] +pub(crate) fn resolve_dingtalk_app_key_for_test( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_app_key(config, entry) +} - let provider = match inbound.channel { - ChannelKind::Feishu => "feishu", - ChannelKind::Lark => "lark", - _ => { - println!("[{}-webhook] outbound_skip reason=unsupported_channel", channel_name_for_log); - return None; - } - }; - - if reply.trim().is_empty() { - println!("[{}-webhook] outbound_skip reason=empty_reply", channel_name_for_log); - return Some(OutboundResult::skipped_empty_reply(provider)); - } - - // Diagnostic: log reply target - let target = reply_target_from_inbound(inbound); - println!( - "[{}-webhook] outbound_target chat_id_present={} message_id_present={} user_id_present={}", - channel_name_for_log, - target.as_ref().map(|t| !t.chat_id.is_empty()).unwrap_or(false), - target.as_ref().and_then(|t| t.message_id.as_ref()).map(|s| !s.is_empty()).unwrap_or(false), - target.as_ref().and_then(|t| t.user_id.as_ref()).map(|s| !s.is_empty()).unwrap_or(false) - ); - - let Some(target) = target else { - println!("[{}-webhook] outbound_skip reason=missing_reply_target", channel_name_for_log); - return Some(OutboundResult::failed( - provider, - "missing_reply_target", - "reply target was missing", - )); - }; +#[cfg(test)] +pub(crate) fn resolve_dingtalk_robot_code_for_test( + config: &Config, + entry: Option<&crate::config::schema::ChannelEntry>, +) -> Option { + resolve_dingtalk_robot_code(config, entry) +} - match outbound_mode.as_str() { - "mock" => { - println!("[{}-outbound] selected_sender sender=mock", channel_name_for_log); - let sender = MockOutboundSender::new(); - Some(send_with_sender(&sender, &target, reply).await) - } - "real" => { - let Some(_app_id) = config_string(entry, "app_id") else { - println!("[{}-outbound] outbound_skip reason=missing_app_id", channel_name_for_log); - return Some(OutboundResult::not_configured( - provider, - "app_id was missing", - )); - }; - let Some(_app_secret) = config_string(entry, "app_secret") else { - println!("[{}-outbound] outbound_skip reason=missing_app_secret", channel_name_for_log); - return Some(OutboundResult::not_configured( - provider, - "app_secret was missing", - )); - }; - println!("[{}-outbound] selected_sender provider={}", channel_name_for_log, provider); - let token_cache = outbound_token_cache(); - let result = match inbound.channel { - ChannelKind::Feishu => { - let sender = FeishuOutboundSender::new(_app_id, _app_secret, token_cache); - send_with_sender(&sender, &target, reply).await - } - ChannelKind::Lark => { - let sender = LarkOutboundSender::new(_app_id, _app_secret, token_cache); - send_with_sender(&sender, &target, reply).await - } - _ => OutboundResult::failed( - provider, - "not_implemented", - "sender was not implemented", - ), - }; - println!( - "[{}-outbound] send_result ok={} delivery={:?}", - channel_name_for_log, result.ok, result.delivery - ); - - // Record outbound message_id for self-message filtering - if result.ok { - if let Some(msg_id) = result.platform_message_id.as_ref() { - let channel_str = format!("{:?}", inbound.channel).to_lowercase(); - OutboundMsgCache::global() - .record_outbound(&channel_str, msg_id) - .await; - println!( - "[{}-outbound] recorded_outbound_message_id=true ttl_secs=1800", - channel_name_for_log - ); - } - } - - Some(result) - } - _ => { - println!("[{}-webhook] outbound_skip reason=invalid_mode", channel_name_for_log); - Some(OutboundResult::not_configured( - provider, - &format!("outbound_mode '{}' must be disabled, mock, or real", outbound_mode), - )) - } - } +#[cfg(test)] +pub(crate) fn extract_dingtalk_text_for_test(payload: &serde_json::Value) -> String { + extract_dingtalk_text(payload) +} + +/// List of HTTP paths the DingTalk webhook handler is registered at. +/// Exposed for the Phase 3 integration regression tests so we can +/// confirm both the documented callback URL and the legacy alias are +/// bound to the same handler without spinning up a full router. +#[cfg(test)] +pub(crate) fn dingtalk_known_route_paths_for_test() -> &'static [&'static str] { + &[ + "/webhook/dingtalk", + "/api/v1/gateway/dingtalk/events", + agent_menu::DINGTALK_MENU_CARD_CALLBACK_PATH, + ] +} + +#[cfg(test)] +pub(crate) fn is_dingtalk_effectively_enabled_for_test(config: &Config) -> bool { + is_dingtalk_effectively_enabled(config) } fn signed_webhook_payload( @@ -5958,6 +7675,46 @@ impl FeishuSecurityConfig { } } +/// Sanitized Feishu configuration snapshot used by desktop diagnostics. +/// Secret-bearing values are reduced to presence booleans before the value +/// leaves core, so the renderer never receives credentials. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct FeishuDiagnosticConfigState { + pub enabled: bool, + pub app_id_present: bool, + pub app_secret_present: bool, + pub verification_token_present: bool, + pub encrypt_key_present: bool, + pub security_mode: String, + pub outbound_mode: String, +} + +pub fn feishu_diagnostic_config_state(config: &Config) -> FeishuDiagnosticConfigState { + let entry = config.channels_config.feishu.as_ref(); + let security = FeishuSecurityConfig::from_entry(entry); + let configured_extra = |key: &str| { + entry + .and_then(|entry| entry.extra.get(key)) + .and_then(serde_json::Value::as_str) + .is_some_and(FeishuSecurityConfig::is_configured_secret) + }; + + FeishuDiagnosticConfigState { + enabled: entry.is_some_and(|entry| entry.enabled), + app_id_present: configured_extra("app_id"), + app_secret_present: configured_extra("app_secret"), + verification_token_present: security.verification_token.is_some(), + encrypt_key_present: security.encrypt_key.is_some(), + security_mode: security.mode.as_str().to_string(), + outbound_mode: entry + .and_then(|entry| entry.extra.get("outbound_mode")) + .and_then(serde_json::Value::as_str) + .filter(|mode| !mode.trim().is_empty()) + .unwrap_or("disabled") + .to_string(), + } +} + /// Verify the verification token from the request pub fn verify_feishu_verification_token( security_config: &FeishuSecurityConfig, @@ -7126,18 +8883,23 @@ fn resolve_agent_max_tool_iterations(config: &Config, route_agent_name: &str) -> #[cfg(test)] mod tests { use super::{ - acquire_inbound_slot, acquire_subagent_guard, attach_delegate_tool, create_tools_for_route, - check_gateway_public_health, feishu_public_callback_urls, + acquire_inbound_slot, acquire_subagent_guard, attach_delegate_tool, + check_dingtalk_public_route, check_gateway_public_health, classify_dingtalk_route_response, + create_tools_for_route, dingtalk_diagnostic_config_state, feishu_diagnostic_config_state, + feishu_public_callback_urls, http_dingtalk_card_callback_post, normalize_gateway_public_config, normalize_named_tunnel_hostname, normalize_public_webhook_base_url, resolve_agent_max_tool_iterations, resolve_public_webhook_base_url, split_session_key, DedupCache, GatewayRuntime, - GatewayRuntimeStatus, GatewaySessionTreeQuery, MonitorFlightGuard, - SessionLineageMeta, + GatewayRuntimeStatus, GatewaySessionTreeQuery, MonitorFlightGuard, SessionLineageMeta, }; use crate::channels::{ChannelKind, InboundMessage}; - use crate::config::{Config, DelegateAgentConfig, GatewayPublicConfig, GatewayPublicMode}; + use crate::config::{ + ChannelEntry, Config, DelegateAgentConfig, GatewayPublicConfig, GatewayPublicMode, + }; + use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; + use axum::Json; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -7861,6 +9623,55 @@ mod tests { (status, value) } + #[tokio::test] + async fn feishu_card_url_verification_returns_challenge() { + let challenge = "card-verification-challenge"; + let body = json!({ + "type": "url_verification", + "token": "card-verification-token", + "challenge": challenge + }) + .to_string(); + + let (status, response) = call_feishu_card_http( + GatewayRuntime::new(feishu_security_config("dev", None, None)), + body, + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + response.get("challenge").and_then(serde_json::Value::as_str), + Some(challenge) + ); + assert!(response.get("toast").is_none()); + } + + #[tokio::test] + async fn feishu_card_challenge_does_not_require_action() { + let token = "configured-card-token"; + let body = json!({ + "type": "url_verification", + "token": token, + "challenge": "challenge-without-action" + }) + .to_string(); + + let (status, response) = call_feishu_card_http( + GatewayRuntime::new(feishu_security_config("token", Some(token), None)), + body, + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + response.get("challenge").and_then(serde_json::Value::as_str), + Some("challenge-without-action") + ); + assert!(response.get("ok").is_none()); + assert!(response.get("toast").is_none()); + } + #[test] fn feishu_security_dev_mode_is_explicitly_insecure() { let config = feishu_security_config("dev", None, None); @@ -10215,9 +12026,100 @@ mod tests { normalize_public_webhook_base_url("https://example.test/webhook/feishu"), Some("https://example.test".to_string()) ); + assert_eq!( + normalize_public_webhook_base_url( + "https://example.test/api/v1/gateway/dingtalk/events/" + ), + Some("https://example.test".to_string()) + ); + assert_eq!( + normalize_public_webhook_base_url("https://example.test/webhook/dingtalk"), + Some("https://example.test".to_string()) + ); assert_eq!(normalize_public_webhook_base_url(" "), None); } + #[test] + fn dingtalk_diagnostic_config_state_contains_presence_only() { + let mut config = Config::default(); + config.gateway.dingtalk.enabled = true; + config.gateway.dingtalk.app_key = "diagnostic-app-key".to_string(); + config.gateway.dingtalk.app_secret = "diagnostic-app-secret".to_string(); + config.gateway.dingtalk.robot_code = "diagnostic-robot-code".to_string(); + + let state = dingtalk_diagnostic_config_state(&config); + assert!(state.enabled); + assert!(state.app_key_present); + assert!(state.app_secret_present); + assert!(state.robot_code_present); + assert_eq!(state.webhook_path, "/api/v1/gateway/dingtalk/events"); + + let rendered = serde_json::to_string(&state).expect("serialize diagnostics"); + assert!(!rendered.contains("diagnostic-app-key")); + assert!(!rendered.contains("diagnostic-app-secret")); + assert!(!rendered.contains("diagnostic-robot-code")); + } + + #[test] + fn feishu_diagnostic_config_state_contains_presence_only() { + let mut config = Config::default(); + config.channels_config.feishu = Some(ChannelEntry { + enabled: true, + security_mode: Some("encrypted".to_string()), + verification_token: Some("diagnostic-verification-token".to_string()), + encrypt_key: Some("diagnostic-encrypt-key".to_string()), + extra: HashMap::from([ + ("app_id".to_string(), json!("diagnostic-app-id")), + ("app_secret".to_string(), json!("diagnostic-app-secret")), + ("outbound_mode".to_string(), json!("real")), + ]), + ..Default::default() + }); + + let state = feishu_diagnostic_config_state(&config); + assert!(state.enabled); + assert!(state.app_id_present); + assert!(state.app_secret_present); + assert!(state.verification_token_present); + assert!(state.encrypt_key_present); + assert_eq!(state.security_mode, "encrypted"); + assert_eq!(state.outbound_mode, "real"); + + let rendered = serde_json::to_string(&state).expect("serialize diagnostics"); + for secret in [ + "diagnostic-app-id", + "diagnostic-app-secret", + "diagnostic-verification-token", + "diagnostic-encrypt-key", + ] { + assert!(!rendered.contains(secret)); + } + } + + #[test] + fn dingtalk_route_probe_classifies_expected_unsigned_responses() { + let missing = classify_dingtalk_route_response(401, r#"{"message":"missing_timestamp"}"#); + assert!(missing.reachable); + assert_eq!(missing.result_kind, "missing_timestamp"); + + let mismatch = classify_dingtalk_route_response(403, r#"{"message":"signature_mismatch"}"#); + assert!(mismatch.reachable); + assert_eq!(mismatch.result_kind, "signature_mismatch"); + + let tunnel = classify_dingtalk_route_response(530, "Cloudflare error 1033"); + assert!(!tunnel.reachable); + assert_eq!(tunnel.result_kind, "tunnel_unreachable"); + } + + #[tokio::test] + async fn dingtalk_route_probe_reports_unconfigured_without_network_access() { + let result = check_dingtalk_public_route(" ").await; + assert!(!result.configured); + assert!(!result.reachable); + assert_eq!(result.status_code, None); + assert_eq!(result.result_kind, "not_configured"); + } + #[test] fn external_public_url_generates_both_feishu_callbacks() { let mut config = Config::default(); @@ -10244,6 +12146,28 @@ mod tests { ); } + #[test] + fn feishu_callback_urls_handle_trailing_slash_and_unconfigured_base() { + let empty = Config::default(); + assert_eq!(feishu_public_callback_urls(&empty), (None, None)); + + let mut configured = Config::default(); + configured.gateway_public = GatewayPublicConfig { + mode: GatewayPublicMode::ExternalPublicUrl, + public_webhook_base_url: Some("https://gateway.example.test/".to_string()), + ..GatewayPublicConfig::default() + }; + let (event_url, card_url) = feishu_public_callback_urls(&configured); + assert_eq!( + event_url.as_deref(), + Some("https://gateway.example.test/webhook/feishu") + ); + assert_eq!( + card_url.as_deref(), + Some("https://gateway.example.test/webhook/feishu/card") + ); + } + #[test] fn named_tunnel_hostname_is_used_when_public_base_is_empty() { let mut config = Config::default(); @@ -10463,4 +12387,43 @@ mod tests { assert!(!serialized.contains("verification-token-must-not-leak")); assert!(!serialized.contains("app-secret-must-not-leak")); } + + #[tokio::test] + async fn dingtalk_card_callback_recognizes_gateway_status_action() { + let runtime = GatewayRuntime::new(Config::default()); + let (status, Json(response)) = http_dingtalk_card_callback_post( + State(runtime), + json!({"action": "gateway_status"}).to_string(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(response.ok); + assert_eq!(response.action.as_deref(), Some("gateway_status")); + } + + #[tokio::test] + async fn dingtalk_card_callback_recognizes_recent_tasks_alias() { + let runtime = GatewayRuntime::new(Config::default()); + let (status, Json(response)) = http_dingtalk_card_callback_post( + State(runtime), + json!({"params": {"actionKey": "recent_tasks"}}).to_string(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(response.ok); + assert_eq!(response.action.as_deref(), Some("recent_jobs")); + } + + #[tokio::test] + async fn dingtalk_card_callback_recognizes_help_action() { + let runtime = GatewayRuntime::new(Config::default()); + let (status, Json(response)) = http_dingtalk_card_callback_post( + State(runtime), + json!({"value": {"action": "help"}}).to_string(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(response.ok); + assert_eq!(response.action.as_deref(), Some("help")); + } }