diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000..967c3f71 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,34 @@ +# Ignored advisories — all are transitive dependencies we cannot upgrade directly. +# +# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix +# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained +# paste, proc-macro-error, fxhash: unmaintained transitive deps +# lexical-core: unmaintained, pulled by tauri dep chain +# serde_cbor: unmaintained, pulled by tao (tauri) +# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao + +[advisories] +ignore = [ + "RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys + "RUSTSEC-2024-0370", # proc-macro-error unmaintained + "RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf) + "RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk) + "RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk) + "RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango) + "RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio) + "RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys) + "RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys) + "RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys) + "RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros) + "RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys) + "RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys) + "RUSTSEC-2024-0436", # paste unmaintained + "RUSTSEC-2025-0057", # fxhash unmaintained + "RUSTSEC-2025-0075", # glib unmaintained + "RUSTSEC-2025-0080", # cocoa unmaintained + "RUSTSEC-2025-0081", # cocoa-foundation unmaintained + "RUSTSEC-2025-0098", # lexical-core unmaintained + "RUSTSEC-2025-0100", # gio-sys unmaintained + "RUSTSEC-2026-0002", # serde_cbor unmaintained + "RUSTSEC-2023-0086", # lexopt unmaintained (if present) +] diff --git a/Cargo.lock b/Cargo.lock index f602c686..01164e81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3871,8 +3871,10 @@ dependencies = [ "openfang-types", "regex-lite", "reqwest 0.12.28", + "roxmltree", "serde", "serde_json", + "sha1", "sha2", "tokio", "tokio-stream", @@ -4858,9 +4860,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -5341,6 +5343,12 @@ dependencies = [ "serde", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rusqlite" version = "0.31.0" diff --git a/Cargo.toml b/Cargo.toml index 28024f3b..7009968a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,9 @@ walkdir = "2" # Security sha2 = "0.10" +sha1 = "0.10" +aes = "0.8" +cbc = "0.1" hmac = "0.12" hex = "0.4" subtle = "2" diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 2f275674..938eea68 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -51,6 +51,7 @@ use openfang_channels::linkedin::LinkedInAdapter; use openfang_channels::mumble::MumbleAdapter; use openfang_channels::ntfy::NtfyAdapter; use openfang_channels::webhook::WebhookAdapter; +use openfang_channels::wecom::WeComAdapter; use openfang_kernel::OpenFangKernel; use openfang_types::agent::AgentId; use std::sync::Arc; @@ -551,7 +552,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { match self.kernel.cron_scheduler.remove_job(j.id) { Ok(_) => { let id_str = j.id.0.to_string(); - format!("Job [{}] '{}' removed.", safe_truncate_str(&id_str, 8), j.name) + format!( + "Job [{}] '{}' removed.", + safe_truncate_str(&id_str, 8), + j.name + ) } Err(e) => format!("Failed to remove job: {e}"), } @@ -793,13 +798,17 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { // Wave 5 "mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()), "dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()), - "dingtalk_stream" => channels.dingtalk_stream.as_ref().map(|c| c.overrides.clone()), + "dingtalk_stream" => channels + .dingtalk_stream + .as_ref() + .map(|c| c.overrides.clone()), "discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()), "gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()), "ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()), "gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()), "webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()), "linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()), + "wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()), _ => None, } } @@ -1030,9 +1039,7 @@ fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option { match std::env::var(env_var_or_token) { Ok(t) if !t.is_empty() => Some(t), Ok(_) => { - warn!( - "{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping" - ); + warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping"); None } Err(_) => { @@ -1167,7 +1174,9 @@ pub async fn start_channel_bridge_with_config( // WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL) if let Some(ref wa_config) = config.whatsapp { let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp"); - let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty()); + let gateway_url = std::env::var(&wa_config.gateway_url_env) + .ok() + .filter(|u| !u.is_empty()); if cloud_token.is_some() || gateway_url.is_some() { let token = cloud_token.unwrap_or_default(); @@ -1415,8 +1424,7 @@ pub async fn start_channel_bridge_with_config( // Feishu/Lark if let Some(ref fs_config) = config.feishu { if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") { - let region = - openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region); + let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region); let encrypt_key = fs_config .encrypt_key_env .as_ref() @@ -1443,6 +1451,21 @@ pub async fn start_channel_bridge_with_config( } } + // WeCom/WeChat Work + if let Some(ref wc_config) = config.wecom { + if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") { + let adapter = Arc::new(WeComAdapter::with_verification( + wc_config.corp_id.clone(), + wc_config.agent_id.clone(), + secret, + wc_config.webhook_port, + wc_config.encoding_aes_key.clone(), + wc_config.token.clone(), + )); + adapters.push((adapter, wc_config.default_agent.clone())); + } + } + // ── Wave 4 ────────────────────────────────────────────────── // Nextcloud Talk @@ -1561,9 +1584,12 @@ pub async fn start_channel_bridge_with_config( // DingTalk (stream mode) if let Some(ref ds_config) = config.dingtalk_stream { if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") { - if let Some(app_secret) = read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)") { - let robot_code = read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)") - .unwrap_or_else(|| app_key.clone()); + if let Some(app_secret) = + read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)") + { + let robot_code = + read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)") + .unwrap_or_else(|| app_key.clone()); let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code)); adapters.push((adapter, ds_config.default_agent.clone())); } diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index c9c331af..0d05672d 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -221,9 +221,11 @@ fn extract_session_cookie(request: &Request) -> Option { .get("cookie") .and_then(|v| v.to_str().ok()) .and_then(|cookies| { - cookies - .split(';') - .find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string())) + cookies.split(';').find_map(|c| { + c.trim() + .strip_prefix("openfang_session=") + .map(|v| v.to_string()) + }) }) } diff --git a/crates/openfang-api/src/openai_compat.rs b/crates/openfang-api/src/openai_compat.rs index 723763cc..4fc3d302 100644 --- a/crates/openfang-api/src/openai_compat.rs +++ b/crates/openfang-api/src/openai_compat.rs @@ -202,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec { let blocks: Vec = parts .iter() .filter_map(|part| match part { - OaiContentPart::Text { text } => { - Some(ContentBlock::Text { text: text.clone(), provider_metadata: None }) - } + OaiContentPart::Text { text } => Some(ContentBlock::Text { + text: text.clone(), + provider_metadata: None, + }), OaiContentPart::ImageUrl { image_url } => { // Parse data URI: data:{media_type};base64,{data} if let Some(rest) = image_url.url.strip_prefix("data:") { diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 3fdc3ac9..456d867f 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -73,14 +73,18 @@ pub async fn spawn_agent( Err(_) => { return ( StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("Template '{}' not found", safe_name)})), + Json( + serde_json::json!({"error": format!("Template '{}' not found", safe_name)}), + ), ); } } } else { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"})), + Json( + serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"}), + ), ); } } else { @@ -176,15 +180,13 @@ pub async fn list_agents(State(state): State>) -> impl IntoRespons .into_iter() .map(|e| { // Resolve "default" provider/model to actual kernel defaults - let provider = if e.manifest.model.provider.is_empty() - || e.manifest.model.provider == "default" - { - dm.provider.as_str() - } else { - e.manifest.model.provider.as_str() - }; - let model = if e.manifest.model.model.is_empty() - || e.manifest.model.model == "default" + let provider = + if e.manifest.model.provider.is_empty() || e.manifest.model.provider == "default" { + dm.provider.as_str() + } else { + e.manifest.model.provider.as_str() + }; + let model = if e.manifest.model.model.is_empty() || e.manifest.model.model == "default" { dm.model.as_str() } else { @@ -367,7 +369,13 @@ pub async fn send_message( let kernel_handle: Arc = state.kernel.clone() as Arc; match state .kernel - .send_message_with_handle(agent_id, &req.message, Some(kernel_handle), req.sender_id, req.sender_name) + .send_message_with_handle( + agent_id, + &req.message, + Some(kernel_handle), + req.sender_id, + req.sender_name, + ) .await { Ok(result) => { @@ -473,20 +481,19 @@ pub async fn get_agent_session( // Persist image to upload dir so it can be // served back when loading session history. let file_id = uuid::Uuid::new_v4().to_string(); - let upload_dir = - std::env::temp_dir().join("openfang_uploads"); + let upload_dir = std::env::temp_dir().join("openfang_uploads"); let _ = std::fs::create_dir_all(&upload_dir); if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) { - let _ = std::fs::write( - upload_dir.join(&file_id), - &bytes, - ); + let _ = std::fs::write(upload_dir.join(&file_id), &bytes); UPLOAD_REGISTRY.insert( file_id.clone(), UploadMeta { - filename: format!("image.{}", media_type.rsplit('/').next().unwrap_or("png")), + filename: format!( + "image.{}", + media_type.rsplit('/').next().unwrap_or("png") + ), content_type: media_type.clone(), }, ); @@ -510,8 +517,7 @@ pub async fn get_agent_session( "expanded": false, })); // Will be filled after this loop when we know msg_idx - tool_use_index - .insert(id.clone(), (usize::MAX, tool_idx)); + tool_use_index.insert(id.clone(), (usize::MAX, tool_idx)); } // ToolResult blocks are handled in pass 2 openfang_types::message::ContentBlock::ToolResult { .. } => {} @@ -556,9 +562,7 @@ pub async fn get_agent_session( .. } = b { - if let Some(&(msg_idx, tool_idx)) = - tool_use_index.get(tool_use_id) - { + if let Some(&(msg_idx, tool_idx)) = tool_use_index.get(tool_use_id) { if let Some(msg) = built_messages.get_mut(msg_idx) { if let Some(tools_arr) = msg.get_mut("tools").and_then(|v| v.as_array_mut()) @@ -566,8 +570,7 @@ pub async fn get_agent_session( if let Some(tool_obj) = tools_arr.get_mut(tool_idx) { let preview: String = result.chars().take(2000).collect(); - tool_obj["result"] = - serde_json::Value::String(preview); + tool_obj["result"] = serde_json::Value::String(preview); tool_obj["is_error"] = serde_json::Value::Bool(*is_error); } @@ -676,10 +679,7 @@ pub async fn restart_agent( drop(entry); // Cancel any running task - let was_running = state - .kernel - .stop_agent_run(agent_id) - .unwrap_or(false); + let was_running = state.kernel.stop_agent_run(agent_id).unwrap_or(false); // Reset state to Running (also updates last_active) let _ = state @@ -1078,12 +1078,7 @@ pub async fn delete_workflow( } }); - if state - .kernel - .workflows - .remove_workflow(workflow_id) - .await - { + if state.kernel.workflows.remove_workflow(workflow_id).await { ( StatusCode::OK, Json(serde_json::json!({"status": "removed", "workflow_id": id})), @@ -1411,21 +1406,23 @@ pub async fn send_message_stream( } let kernel_handle: Arc = state.kernel.clone() as Arc; - let (rx, _handle) = - match state - .kernel - .send_message_streaming(agent_id, &req.message, Some(kernel_handle), req.sender_id, req.sender_name) - { - Ok(pair) => pair, - Err(e) => { - tracing::warn!("Streaming message failed for agent {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "Streaming message failed"})), - ) - .into_response(); - } - }; + let (rx, _handle) = match state.kernel.send_message_streaming( + agent_id, + &req.message, + Some(kernel_handle), + req.sender_id, + req.sender_name, + ) { + Ok(pair) => pair, + Err(e) => { + tracing::warn!("Streaming message failed for agent {id}: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Streaming message failed"})), + ) + .into_response(); + } + }; let sse_stream = stream::unfold(rx, |mut rx| async move { match rx.recv().await { @@ -2162,6 +2159,24 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[ setup_steps: &["Enter host and username below", "Optionally add a password"], config_template: "[channels.mumble]\nhost = \"\"\nusername = \"openfang\"", }, + ChannelMeta { + name: "wecom", display_name: "WeCom", icon: "WC", + description: "WeCom (WeChat Work) adapter", + category: "messaging", difficulty: "Easy", setup_time: "~3 min", + quick_setup: "Enter your Corp ID, Agent ID, and Secret", + setup_type: "form", + fields: &[ + ChannelField { key: "corp_id", label: "Corp ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "wwxxxxx", advanced: false }, + ChannelField { key: "agent_id", label: "Agent ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "wwxxxxx", advanced: false }, + ChannelField { key: "secret_env", label: "Secret", field_type: FieldType::Secret, env_var: Some("WECOM_SECRET"), required: true, placeholder: "secret", advanced: false }, + ChannelField { key: "token", label: "Callback Token", field_type: FieldType::Text, env_var: None, required: false, placeholder: "callback_token", advanced: true }, + ChannelField { key: "encoding_aes_key", label: "Encoding AES Key", field_type: FieldType::Text, env_var: None, required: false, placeholder: "encoding_aes_key", advanced: true }, + ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8454", advanced: true }, + ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, + ], + setup_steps: &["Create a WeCom application at work.weixin.qq.com", "Get Corp ID, Agent ID, and Secret", "Configure callback URL to your webhook endpoint"], + config_template: "[channels.wecom]\ncorp_id = \"\"\nagent_id = \"\"\nsecret_env = \"WECOM_SECRET\"", + }, ]; /// Check if a channel is configured (has a `[channels.xxx]` section in config). @@ -2208,6 +2223,7 @@ fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name: "gotify" => config.gotify.is_some(), "webhook" => config.webhook.is_some(), "mumble" => config.mumble.is_some(), + "wecom" => config.wecom.is_some(), _ => false, } } @@ -2257,9 +2273,7 @@ fn build_field_json( val.clone() }; field["value"] = display_val; - if !val.is_null() - && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) - { + if !val.is_null() && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) { field["has_value"] = serde_json::Value::Bool(true); } } @@ -2279,47 +2293,174 @@ fn channel_config_values( name: &str, ) -> Option { match name { - "telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "dingtalk_stream" => config.dingtalk_stream.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()), - "linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()), + "telegram" => config + .telegram + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "discord" => config + .discord + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "slack" => config + .slack + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "whatsapp" => config + .whatsapp + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "signal" => config + .signal + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "matrix" => config + .matrix + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "email" => config + .email + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "teams" => config + .teams + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mattermost" => config + .mattermost + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "irc" => config + .irc + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "google_chat" => config + .google_chat + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "twitch" => config + .twitch + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "rocketchat" => config + .rocketchat + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "zulip" => config + .zulip + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "xmpp" => config + .xmpp + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "line" => config + .line + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "viber" => config + .viber + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "messenger" => config + .messenger + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "reddit" => config + .reddit + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mastodon" => config + .mastodon + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "bluesky" => config + .bluesky + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "feishu" => config + .feishu + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "revolt" => config + .revolt + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "nextcloud" => config + .nextcloud + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "guilded" => config + .guilded + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "keybase" => config + .keybase + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "threema" => config + .threema + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "nostr" => config + .nostr + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "webex" => config + .webex + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "pumble" => config + .pumble + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "flock" => config + .flock + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "twist" => config + .twist + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "mumble" => config + .mumble + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "dingtalk" => config + .dingtalk + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "dingtalk_stream" => config + .dingtalk_stream + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "discourse" => config + .discourse + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "gitter" => config + .gitter + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "ntfy" => config + .ntfy + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "gotify" => config + .gotify + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "webhook" => config + .webhook + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "linkedin" => config + .linkedin + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + "wecom" => config + .wecom + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), _ => None, } } @@ -2441,7 +2582,10 @@ pub async fn configure_channel( ); } else { // Config field — collect for TOML write with type info - config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type)); + config_fields.insert( + field_def.key.to_string(), + (value.to_string(), field_def.field_type), + ); } } @@ -3129,9 +3273,7 @@ pub async fn health(State(state): State>) -> impl IntoResponse { let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ])); - memory - .structured_get(shared_id, "__health_check__") - .is_ok() + memory.structured_get(shared_id, "__health_check__").is_ok() }) .await .unwrap_or(false); @@ -3153,9 +3295,7 @@ pub async fn health_detail(State(state): State>) -> impl IntoRespo let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, ])); - memory - .structured_get(shared_id, "__health_check__") - .is_ok() + memory.structured_get(shared_id, "__health_check__").is_ok() }) .await .unwrap_or(false); @@ -3457,7 +3597,9 @@ pub async fn clawhub_search( "items": items, "next_cursor": null, }); - state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone())); + state + .clawhub_cache + .insert(cache_key, (Instant::now(), resp.clone())); (StatusCode::OK, Json(resp)) } Err(e) => { @@ -3470,9 +3612,7 @@ pub async fn clawhub_search( }; ( status, - Json( - serde_json::json!({"items": [], "next_cursor": null, "error": msg}), - ), + Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})), ) } } @@ -3531,7 +3671,9 @@ pub async fn clawhub_browse( "items": items, "next_cursor": results.next_cursor, }); - state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone())); + state + .clawhub_cache + .insert(cache_key, (Instant::now(), resp.clone())); (StatusCode::OK, Json(resp)) } Err(e) => { @@ -3544,9 +3686,7 @@ pub async fn clawhub_browse( }; ( status, - Json( - serde_json::json!({"items": [], "next_cursor": null, "error": msg}), - ), + Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})), ) } } @@ -3776,7 +3916,10 @@ pub async fn list_hands(State(state): State>) -> impl IntoResponse .check_requirements(&d.id) .unwrap_or_default(); let readiness = state.kernel.hand_registry.readiness(&d.id); - let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false); + let requirements_met = readiness + .as_ref() + .map(|r| r.requirements_met) + .unwrap_or(false); let active = readiness.as_ref().map(|r| r.active).unwrap_or(false); let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false); serde_json::json!({ @@ -3839,7 +3982,10 @@ pub async fn get_hand( .check_requirements(&hand_id) .unwrap_or_default(); let readiness = state.kernel.hand_registry.readiness(&hand_id); - let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false); + let requirements_met = readiness + .as_ref() + .map(|r| r.requirements_met) + .unwrap_or(false); let active = readiness.as_ref().map(|r| r.active).unwrap_or(false); let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false); let settings_status = state @@ -3916,7 +4062,10 @@ pub async fn check_hand_deps( .check_requirements(&hand_id) .unwrap_or_default(); let readiness = state.kernel.hand_registry.readiness(&hand_id); - let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false); + let requirements_met = readiness + .as_ref() + .map(|r| r.requirements_met) + .unwrap_or(false); let active = readiness.as_ref().map(|r| r.active).unwrap_or(false); let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false); ( @@ -4281,7 +4430,12 @@ pub async fn activate_hand( // If the hand agent has a non-reactive schedule (autonomous hands), // start its background loop so it begins running immediately. if let Some(agent_id) = instance.agent_id { - let entry = state.kernel.registry.list().into_iter().find(|e| e.id == agent_id); + let entry = state + .kernel + .registry + .list() + .into_iter() + .find(|e| e.id == agent_id); if let Some(entry) = entry { if !matches!( entry.manifest.schedule, @@ -4437,7 +4591,9 @@ pub async fn update_hand_settings( }, None => ( StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")})), + Json( + serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")}), + ), ), } } @@ -4574,7 +4730,10 @@ pub async fn hand_instance_browser( content = data["content"].as_str().unwrap_or("").to_string(); // Truncate content to avoid huge payloads (UTF-8 safe) if content.len() > 2000 { - content = format!("{}... (truncated)", openfang_types::truncate_str(&content, 2000)); + content = format!( + "{}... (truncated)", + openfang_types::truncate_str(&content, 2000) + ); } } } @@ -5244,7 +5403,9 @@ pub async fn update_agent_budget( if hourly.is_none() && daily.is_none() && monthly.is_none() && tokens.is_none() { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd, max_llm_tokens_per_hour"})), + Json( + serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd, max_llm_tokens_per_hour"}), + ), ); } @@ -5536,7 +5697,10 @@ pub async fn patch_agent( } if let Some(model) = body.get("model").and_then(|v| v.as_str()) { let explicit_provider = body.get("provider").and_then(|v| v.as_str()); - if let Err(e) = state.kernel.set_agent_model(agent_id, model, explicit_provider) { + if let Err(e) = state + .kernel + .set_agent_model(agent_id, model, explicit_provider) + { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("{e}")})), @@ -5561,7 +5725,9 @@ pub async fn patch_agent( let _ = state.kernel.memory.save_agent(&entry); ( StatusCode::OK, - Json(serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name})), + Json( + serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name}), + ), ) } else { ( @@ -6079,7 +6245,9 @@ pub async fn add_custom_model( if !catalog.add_custom_model(entry) { return ( StatusCode::CONFLICT, - Json(serde_json::json!({"error": format!("Model '{}' already exists for provider '{}'", id, provider)})), + Json( + serde_json::json!({"error": format!("Model '{}' already exists for provider '{}'", id, provider)}), + ), ); } @@ -6784,7 +6952,10 @@ pub async fn set_model( } }; let explicit_provider = body["provider"].as_str(); - match state.kernel.set_agent_model(agent_id, model, explicit_provider) { + match state + .kernel + .set_agent_model(agent_id, model, explicit_provider) + { Ok(()) => { // Return the resolved model+provider so frontend stays in sync. // The model name may have been normalized (provider prefix stripped), @@ -6793,11 +6964,18 @@ pub async fn set_model( .kernel .registry .get(agent_id) - .map(|e| (e.manifest.model.model.clone(), e.manifest.model.provider.clone())) + .map(|e| { + ( + e.manifest.model.model.clone(), + e.manifest.model.provider.clone(), + ) + }) .unwrap_or_else(|| (model.to_string(), String::new())); ( StatusCode::OK, - Json(serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider})), + Json( + serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider}), + ), ) } Err(e) => ( @@ -6882,10 +7060,7 @@ pub async fn set_agent_tools( .kernel .set_agent_tool_filters(agent_id, allowlist, blocklist) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok"})), - ), + Ok(()) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("{e}")})), @@ -7095,10 +7270,7 @@ pub async fn set_provider_key( .map(|p| p.api_key_env.clone()) .unwrap_or_else(|| { // Custom provider — derive env var: MY_PROVIDER → MY_PROVIDER_API_KEY - format!( - "{}_API_KEY", - name.to_uppercase().replace('-', "_") - ) + format!("{}_API_KEY", name.to_uppercase().replace('-', "_")) }) }; @@ -7157,7 +7329,11 @@ pub async fn set_provider_key( let switched = if !current_has_key && current_provider != name { // Find a default model for the newly-keyed provider let default_model = { - let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner()); + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); catalog.default_model_for_provider(&name) }; if let Some(model_id) = default_model { @@ -7170,7 +7346,8 @@ pub async fn set_provider_key( backup_config(&config_path); if let Ok(existing) = std::fs::read_to_string(&config_path) { let cleaned = remove_toml_section(&existing, "default_model"); - let _ = std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml)); + let _ = + std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml)); } else { let _ = std::fs::write(&config_path, update_toml); } @@ -7232,9 +7409,10 @@ pub async fn set_provider_key( let mut resp = serde_json::json!({"status": "saved", "provider": name}); if switched { resp["switched_default"] = serde_json::json!(true); - resp["message"] = serde_json::json!( - format!("API key saved and default provider switched to '{}'.", name) - ); + resp["message"] = serde_json::json!(format!( + "API key saved and default provider switched to '{}'.", + name + )); } (StatusCode::OK, Json(resp)) @@ -7441,8 +7619,7 @@ pub async fn set_provider_url( } // Probe reachability at the new URL - let probe = - openfang_runtime::provider_health::probe_provider(&name, &base_url).await; + let probe = openfang_runtime::provider_health::probe_provider(&name, &base_url).await; // Merge discovered models into catalog if !probe.discovered_models.is_empty() { @@ -8336,7 +8513,11 @@ pub async fn run_schedule( ); let kernel_handle: Arc = state.kernel.clone() as Arc; - match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle), None, None).await { + match state + .kernel + .send_message_with_handle(target_agent, &run_message, Some(kernel_handle), None, None) + .await + { Ok(result) => ( StatusCode::OK, Json(serde_json::json!({ @@ -8490,7 +8671,9 @@ pub async fn patch_agent_config( if name.len() > MAX_NAME_LEN { return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})), + Json( + serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")}), + ), ); } } @@ -8498,7 +8681,9 @@ pub async fn patch_agent_config( if desc.len() > MAX_DESC_LEN { return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})), + Json( + serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")}), + ), ); } } @@ -8506,7 +8691,9 @@ pub async fn patch_agent_config( if prompt.len() > MAX_PROMPT_LEN { return ( StatusCode::PAYLOAD_TOO_LARGE, - Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})), + Json( + serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")}), + ), ); } } @@ -9503,12 +9690,18 @@ pub async fn config_reload(State(state): State>) -> impl IntoRespo // --------------------------------------------------------------------------- /// GET /api/config/schema — Return a simplified JSON description of the config structure. -pub async fn config_schema( - State(state): State>, -) -> impl IntoResponse { +pub async fn config_schema(State(state): State>) -> impl IntoResponse { // Build provider/model options from model catalog for dropdowns - let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner()); - let provider_options: Vec = catalog.list_providers().iter().map(|p| p.id.clone()).collect(); + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + let provider_options: Vec = catalog + .list_providers() + .iter() + .map(|p| p.id.clone()) + .collect(); let model_options: Vec = catalog .list_models() .iter() @@ -10362,8 +10555,7 @@ pub async fn copilot_oauth_start() -> impl IntoResponse { CopilotFlowState { device_code: resp.device_code, interval: resp.interval, - expires_at: Instant::now() - + std::time::Duration::from_secs(resp.expires_in), + expires_at: Instant::now() + std::time::Duration::from_secs(resp.expires_in), }, ); @@ -10430,7 +10622,9 @@ pub async fn copilot_oauth_poll( if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) { return ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})), + Json( + serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")}), + ), ); } @@ -10634,15 +10828,30 @@ fn audit_to_comms_event( // Format detail: "tokens_in=X, tokens_out=Y" → readable summary let detail = if entry.detail.starts_with("tokens_in=") { let parts: Vec<&str> = entry.detail.split(", ").collect(); - let in_tok = parts.first().and_then(|p| p.strip_prefix("tokens_in=")).unwrap_or("?"); - let out_tok = parts.get(1).and_then(|p| p.strip_prefix("tokens_out=")).unwrap_or("?"); + let in_tok = parts + .first() + .and_then(|p| p.strip_prefix("tokens_in=")) + .unwrap_or("?"); + let out_tok = parts + .get(1) + .and_then(|p| p.strip_prefix("tokens_out=")) + .unwrap_or("?"); if entry.outcome == "ok" { format!("{} in / {} out tokens", in_tok, out_tok) } else { - format!("{} in / {} out — {}", in_tok, out_tok, openfang_types::truncate_str(&entry.outcome, 80)) + format!( + "{} in / {} out — {}", + in_tok, + out_tok, + openfang_types::truncate_str(&entry.outcome, 80) + ) } } else if entry.outcome != "ok" { - format!("{} — {}", openfang_types::truncate_str(&entry.detail, 80), openfang_types::truncate_str(&entry.outcome, 80)) + format!( + "{} — {}", + openfang_types::truncate_str(&entry.detail, 80), + openfang_types::truncate_str(&entry.outcome, 80) + ) } else { openfang_types::truncate_str(&entry.detail, 200).to_string() }; @@ -10650,12 +10859,18 @@ fn audit_to_comms_event( } "AgentSpawn" => ( CommsEventKind::AgentSpawned, - format!("Agent spawned: {}", openfang_types::truncate_str(&entry.detail, 100)), + format!( + "Agent spawned: {}", + openfang_types::truncate_str(&entry.detail, 100) + ), "", ), "AgentKill" => ( CommsEventKind::AgentTerminated, - format!("Agent killed: {}", openfang_types::truncate_str(&entry.detail, 100)), + format!( + "Agent killed: {}", + openfang_types::truncate_str(&entry.detail, 100) + ), "", ), _ => return None, @@ -10667,8 +10882,16 @@ fn audit_to_comms_event( kind, source_id: entry.agent_id.clone(), source_name: resolve_name(&entry.agent_id), - target_id: if target_label.is_empty() { String::new() } else { target_label.to_string() }, - target_name: if target_label.is_empty() { String::new() } else { target_label.to_string() }, + target_id: if target_label.is_empty() { + String::new() + } else { + target_label.to_string() + }, + target_name: if target_label.is_empty() { + String::new() + } else { + target_label.to_string() + }, detail, }) } @@ -10719,9 +10942,7 @@ pub async fn comms_events( /// GET /api/comms/events/stream — SSE stream of inter-agent communication events. /// /// Polls the audit log every 500ms for new inter-agent events. -pub async fn comms_events_stream( - State(state): State>, -) -> axum::response::Response { +pub async fn comms_events_stream(State(state): State>) -> axum::response::Response { use axum::response::sse::{Event, KeepAlive, Sse}; let (tx, rx) = tokio::sync::mpsc::channel::< @@ -10876,15 +11097,17 @@ pub async fn auth_login( State(state): State>, Json(req): Json, ) -> axum::response::Response { - use axum::response::Response; use axum::body::Body; + use axum::response::Response; let auth_cfg = &state.kernel.config.auth; if !auth_cfg.enabled { return Response::builder() .status(StatusCode::NOT_FOUND) .header("content-type", "application/json") - .body(Body::from(serde_json::json!({"error": "Auth not enabled"}).to_string())) + .body(Body::from( + serde_json::json!({"error": "Auth not enabled"}).to_string(), + )) .unwrap(); } @@ -10914,7 +11137,9 @@ pub async fn auth_login( return Response::builder() .status(StatusCode::UNAUTHORIZED) .header("content-type", "application/json") - .body(Body::from(serde_json::json!({"error": "Invalid credentials"}).to_string())) + .body(Body::from( + serde_json::json!({"error": "Invalid credentials"}).to_string(), + )) .unwrap(); } @@ -10929,9 +11154,8 @@ pub async fn auth_login( let token = crate::session_auth::create_session_token(username, &secret, auth_cfg.session_ttl_hours); let ttl_secs = auth_cfg.session_ttl_hours * 3600; - let cookie = format!( - "openfang_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl_secs}" - ); + let cookie = + format!("openfang_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl_secs}"); state.kernel.audit_log.record( "system", @@ -10944,11 +11168,14 @@ pub async fn auth_login( .status(StatusCode::OK) .header("content-type", "application/json") .header("set-cookie", &cookie) - .body(Body::from(serde_json::json!({ - "status": "ok", - "token": token, - "username": username, - }).to_string())) + .body(Body::from( + serde_json::json!({ + "status": "ok", + "token": token, + "username": username, + }) + .to_string(), + )) .unwrap() } @@ -10989,9 +11216,11 @@ pub async fn auth_check( .get("cookie") .and_then(|v| v.to_str().ok()) .and_then(|cookies| { - cookies - .split(';') - .find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string())) + cookies.split(';').find_map(|c| { + c.trim() + .strip_prefix("openfang_session=") + .map(|v| v.to_string()) + }) }) .and_then(|token| crate::session_auth::verify_session_token(&token, &secret)); @@ -11036,3 +11265,53 @@ fn remove_toml_section(content: &str, section: &str) -> String { } result } + +#[cfg(test)] +mod channel_config_tests { + use super::*; + + #[test] + fn test_is_channel_configured_wecom_none() { + let config = openfang_types::config::ChannelsConfig::default(); + assert!(!is_channel_configured(&config, "wecom")); + } + + #[test] + fn test_is_channel_configured_wecom_some() { + let mut config = openfang_types::config::ChannelsConfig::default(); + config.wecom = Some(openfang_types::config::WeComConfig { + corp_id: "test_corp".to_string(), + agent_id: "test_agent".to_string(), + secret_env: "WECOM_SECRET".to_string(), + webhook_port: 8454, + token: Some("token".to_string()), + encoding_aes_key: Some("aes_key".to_string()), + default_agent: Some("assistant".to_string()), + overrides: openfang_types::config::ChannelOverrides::default(), + }); + assert!(is_channel_configured(&config, "wecom")); + } + + #[test] + fn test_wecom_in_channel_registry() { + let wecom_meta = CHANNEL_REGISTRY.iter().find(|c| c.name == "wecom"); + assert!(wecom_meta.is_some()); + let meta = wecom_meta.unwrap(); + assert_eq!(meta.display_name, "WeCom"); + assert_eq!(meta.category, "messaging"); + assert!( + meta.fields + .iter() + .find(|f| f.key == "corp_id") + .unwrap() + .required + ); + assert!( + meta.fields + .iter() + .find(|f| f.key == "secret_env") + .unwrap() + .required + ); + } +} diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 83aca94e..d6a5b6be 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -139,7 +139,9 @@ pub async fn build_router( ) .route( "/api/agents/{id}", - axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent), + axum::routing::get(routes::get_agent) + .delete(routes::kill_agent) + .patch(routes::patch_agent), ) .route( "/api/agents/{id}/mode", @@ -309,7 +311,9 @@ pub async fn build_router( ) .route( "/api/workflows/{id}", - axum::routing::get(routes::get_workflow).put(routes::update_workflow).delete(routes::delete_workflow), + axum::routing::get(routes::get_workflow) + .put(routes::update_workflow) + .delete(routes::delete_workflow), ) .route( "/api/workflows/{id}/run", @@ -383,8 +387,7 @@ pub async fn build_router( ) .route( "/api/hands/{hand_id}/settings", - axum::routing::get(routes::get_hand_settings) - .put(routes::update_hand_settings), + axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings), ) .route( "/api/hands/instances/{id}/pause", @@ -441,15 +444,8 @@ pub async fn build_router( "/api/comms/events/stream", axum::routing::get(routes::comms_events_stream), ) - .route( - "/api/comms/send", - axum::routing::post(routes::comms_send), - ) - .route( - "/api/comms/task", - axum::routing::post(routes::comms_task), - ) - ; + .route("/api/comms/send", axum::routing::post(routes::comms_send)) + .route("/api/comms/task", axum::routing::post(routes::comms_task)); // Split into a second router chunk to stay within axum's type nesting limit. let app = app @@ -497,8 +493,7 @@ pub async fn build_router( ) .route( "/api/budget/agents/{id}", - axum::routing::get(routes::agent_budget_status) - .put(routes::update_agent_budget), + axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget), ) // Session endpoints .route("/api/sessions", axum::routing::get(routes::list_sessions)) @@ -703,18 +698,9 @@ pub async fn build_router( axum::routing::get(crate::openai_compat::list_models), ) // Dashboard authentication endpoints - .route( - "/api/auth/login", - axum::routing::post(routes::auth_login), - ) - .route( - "/api/auth/logout", - axum::routing::post(routes::auth_logout), - ) - .route( - "/api/auth/check", - axum::routing::get(routes::auth_check), - ) + .route("/api/auth/login", axum::routing::post(routes::auth_login)) + .route("/api/auth/logout", axum::routing::post(routes::auth_logout)) + .route("/api/auth/check", axum::routing::get(routes::auth_check)) .layer(axum::middleware::from_fn_with_state( auth_state, middleware::auth, @@ -833,8 +819,7 @@ pub async fn run_daemon( socket.set_nonblocking(true)?; socket.bind(&addr.into())?; socket.listen(1024)?; - let listener = - tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?; + let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?; // Run server with graceful shutdown. // SECURITY: `into_make_service_with_connect_info` injects the peer @@ -965,11 +950,8 @@ fn is_daemon_responding(addr: &str) -> bool { .or_else(|| addr.strip_prefix("https://")) .unwrap_or(addr); if let Ok(sock_addr) = addr_only.parse::() { - std::net::TcpStream::connect_timeout( - &sock_addr, - std::time::Duration::from_millis(500), - ) - .is_ok() + std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500)) + .is_ok() } else { // Fallback: try connecting to hostname std::net::TcpStream::connect(addr_only) diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index 2624ac9a..8907b979 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -502,10 +502,13 @@ async fn handle_text_message( // Send message to agent with streaming let kernel_handle: Arc = state.kernel.clone() as Arc; - match state - .kernel - .send_message_streaming(agent_id, &content, Some(kernel_handle), None, None) - { + match state.kernel.send_message_streaming( + agent_id, + &content, + Some(kernel_handle), + None, + None, + ) { Ok((mut rx, handle)) => { // Forward stream events to WebSocket with debouncing. // @@ -712,7 +715,8 @@ async fn handle_text_message( }; // Estimate context pressure - let ctx_pct = (usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0); + let ctx_pct = + (usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0); let pressure = if ctx_pct > 85.0 { "critical" } else if ctx_pct > 70.0 { @@ -1183,7 +1187,10 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String if inner.contains("localhost:11434") || inner.contains("ollama") { "Model not found on Ollama. Run `ollama pull ` first. Use /model to see options.".to_string() } else { - format!("{}. Use /model to see options.", classified.sanitized_message) + format!( + "{}. Use /model to see options.", + classified.sanitized_message + ) } } llm_errors::LlmErrorCategory::Format => { diff --git a/crates/openfang-channels/Cargo.toml b/crates/openfang-channels/Cargo.toml index 71e10d1e..d61b21c4 100644 --- a/crates/openfang-channels/Cargo.toml +++ b/crates/openfang-channels/Cargo.toml @@ -24,12 +24,14 @@ zeroize = { workspace = true } axum = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } +sha1 = { workspace = true } +aes = "0.8" +cbc = "0.1" base64 = { workspace = true } hex = { workspace = true } html-escape = { workspace = true } -aes = "0.8" -cbc = "0.1" regex-lite = "0.1" +roxmltree = "0.20" lettre = { workspace = true } imap = { workspace = true } diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 2810d991..cf1b383f 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -11,10 +11,10 @@ use crate::types::{ }; use async_trait::async_trait; use dashmap::DashMap; -use openfang_types::message::ContentBlock; use futures::StreamExt; use openfang_types::agent::AgentId; use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat}; +use openfang_types::message::ContentBlock; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::watch; @@ -407,7 +407,11 @@ async fn send_response( thread_id: Option<&str>, output_format: OutputFormat, ) { - let formatted = formatter::format_for_channel(&text, output_format); + let formatted = if adapter.name() == "wecom" { + formatter::format_for_wecom(&text, output_format) + } else { + formatter::format_for_channel(&text, output_format) + }; let content = ChannelContent::Text(formatted); let result = if let Some(tid) = thread_id { @@ -421,6 +425,15 @@ async fn send_response( } } +fn default_output_format_for_channel(channel_type: &str) -> OutputFormat { + match channel_type { + "telegram" => OutputFormat::TelegramHtml, + "slack" => OutputFormat::SlackMrkdwn, + "wecom" => OutputFormat::PlainText, + _ => OutputFormat::Markdown, + } +} + /// Send a lifecycle reaction (best-effort, non-blocking for supported adapters). /// /// Silently ignores errors — reactions are non-critical UX polish. @@ -525,17 +538,16 @@ async fn dispatch_message( // Fetch per-channel overrides (if configured) let overrides = handle.channel_overrides(ct_str).await; - let channel_default_format = match ct_str { - "telegram" => OutputFormat::TelegramHtml, - "slack" => OutputFormat::SlackMrkdwn, - _ => OutputFormat::Markdown, - }; + let channel_default_format = default_output_format_for_channel(ct_str); let output_format = overrides .as_ref() .and_then(|o| o.output_format) .unwrap_or(channel_default_format); let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false); - let lifecycle_reactions = overrides.as_ref().map(|o| o.lifecycle_reactions).unwrap_or(true); + let lifecycle_reactions = overrides + .as_ref() + .map(|o| o.lifecycle_reactions) + .unwrap_or(true); let thread_id = if threading_enabled { message.thread_id.as_deref() } else { @@ -561,7 +573,9 @@ async fn dispatch_message( } GroupPolicy::MentionOnly => { // Only allow messages where the bot was @mentioned or commands. - let was_mentioned = message.metadata.get("was_mentioned") + let was_mentioned = message + .metadata + .get("was_mentioned") .and_then(|v| v.as_bool()) .unwrap_or(false); let is_command = matches!(&message.content, ChannelContent::Command { .. }); @@ -607,9 +621,16 @@ async fn dispatch_message( } // For images: download, base64 encode, and send as multimodal content blocks - if let ChannelContent::Image { ref url, ref caption } = message.content { + if let ChannelContent::Image { + ref url, + ref caption, + } = message.content + { let blocks = download_image_to_blocks(url, caption.as_deref()).await; - if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) { + if blocks + .iter() + .any(|b| matches!(b, ContentBlock::Image { .. })) + { // We have actual image data — send as structured blocks for vision dispatch_with_blocks( blocks, @@ -632,17 +653,26 @@ async fn dispatch_message( let text = match &message.content { ChannelContent::Text(t) => t.clone(), ChannelContent::Command { .. } => unreachable!(), // handled above - ChannelContent::Image { ref url, ref caption } => { + ChannelContent::Image { + ref url, + ref caption, + } => { // Fallback when image download failed match caption { Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"), None => format!("[User sent a photo: {url}]"), } } - ChannelContent::File { ref url, ref filename } => { + ChannelContent::File { + ref url, + ref filename, + } => { format!("[User sent a file ({filename}): {url}]") } - ChannelContent::Voice { ref url, duration_seconds } => { + ChannelContent::Voice { + ref url, + duration_seconds, + } => { format!("[User sent a voice message ({duration_seconds}s): {url}]") } ChannelContent::Location { lat, lon } => { @@ -834,7 +864,14 @@ async fn dispatch_message( if let Some(reply) = handle.check_auto_reply(agent_id, &text).await { send_response(adapter, &message.sender, reply, thread_id, output_format).await; handle - .record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id) + .record_delivery( + agent_id, + ct_str, + &message.sender.platform_id, + true, + None, + thread_id, + ) .await; return; } @@ -866,16 +903,20 @@ async fn dispatch_message( } send_response(adapter, &message.sender, response, thread_id, output_format).await; handle - .record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id) + .record_delivery( + agent_id, + ct_str, + &message.sender.platform_id, + true, + None, + thread_id, + ) .await; } Err(e) => { // Try re-resolution before reporting error - if let Some(new_id) = - try_reresolution(&e, &channel_key, handle, router).await - { - let typing_task2 = - spawn_typing_loop(adapter_arc.clone(), message.sender.clone()); + if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await { + let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone()); let retry = handle.send_message(new_id, &text).await; typing_task2.abort(); match retry { @@ -889,14 +930,8 @@ async fn dispatch_message( ) .await; } - send_response( - adapter, - &message.sender, - response, - thread_id, - output_format, - ) - .await; + send_response(adapter, &message.sender, response, thread_id, output_format) + .await; handle .record_delivery( new_id, @@ -1057,7 +1092,9 @@ fn detect_image_magic(bytes: &[u8]) -> Option { if bytes.len() >= 4 && bytes[..4] == [0x47, 0x49, 0x46, 0x38] { return Some("image/gif".to_string()); } - if bytes.len() >= 12 && bytes[..4] == [0x52, 0x49, 0x46, 0x46] && bytes[8..12] == [0x57, 0x45, 0x42, 0x50] + if bytes.len() >= 12 + && bytes[..4] == [0x52, 0x49, 0x46, 0x46] + && bytes[8..12] == [0x57, 0x45, 0x42, 0x50] { return Some("image/webp".to_string()); } @@ -1126,9 +1163,8 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec MAX_IMAGE_BYTES { warn!( @@ -1136,10 +1172,16 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec format!("[Image too large for vision ({} KB)]\nCaption: {c}", bytes.len() / 1024), + Some(c) => format!( + "[Image too large for vision ({} KB)]\nCaption: {c}", + bytes.len() / 1024 + ), None => format!("[Image too large for vision ({} KB)]", bytes.len() / 1024), }; - return vec![ContentBlock::Text { text: desc, provider_metadata: None }]; + return vec![ContentBlock::Text { + text: desc, + provider_metadata: None, + }]; } let data = base64::engine::general_purpose::STANDARD.encode(&bytes); @@ -1258,19 +1300,21 @@ async fn dispatch_with_blocks( } send_response(adapter, &message.sender, response, thread_id, output_format).await; handle - .record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id) + .record_delivery( + agent_id, + ct_str, + &message.sender.platform_id, + true, + None, + thread_id, + ) .await; } Err(e) => { // Try re-resolution before reporting error - if let Some(new_id) = - try_reresolution(&e, &channel_key, handle, router).await - { - let typing_task2 = - spawn_typing_loop(adapter_arc.clone(), message.sender.clone()); - let retry = handle - .send_message_with_blocks(new_id, blocks) - .await; + if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await { + let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone()); + let retry = handle.send_message_with_blocks(new_id, blocks).await; typing_task2.abort(); match retry { Ok(response) => { @@ -1283,14 +1327,8 @@ async fn dispatch_with_blocks( ) .await; } - send_response( - adapter, - &message.sender, - response, - thread_id, - output_format, - ) - .await; + send_response(adapter, &message.sender, response, thread_id, output_format) + .await; handle .record_delivery( new_id, @@ -1808,6 +1846,26 @@ mod tests { ); } + #[test] + fn test_default_output_format_for_channel() { + assert_eq!( + default_output_format_for_channel("telegram"), + OutputFormat::TelegramHtml + ); + assert_eq!( + default_output_format_for_channel("slack"), + OutputFormat::SlackMrkdwn + ); + assert_eq!( + default_output_format_for_channel("wecom"), + OutputFormat::PlainText + ); + assert_eq!( + default_output_format_for_channel("discord"), + OutputFormat::Markdown + ); + } + #[tokio::test] async fn test_send_message_with_blocks_default_fallback() { // The default implementation of send_message_with_blocks extracts text @@ -1898,11 +1956,26 @@ mod tests { #[test] fn test_media_type_from_url() { - assert_eq!(media_type_from_url("https://example.com/photo.png"), "image/png"); - assert_eq!(media_type_from_url("https://example.com/anim.gif"), "image/gif"); - assert_eq!(media_type_from_url("https://example.com/img.webp"), "image/webp"); - assert_eq!(media_type_from_url("https://example.com/photo.jpg"), "image/jpeg"); + assert_eq!( + media_type_from_url("https://example.com/photo.png"), + "image/png" + ); + assert_eq!( + media_type_from_url("https://example.com/anim.gif"), + "image/gif" + ); + assert_eq!( + media_type_from_url("https://example.com/img.webp"), + "image/webp" + ); + assert_eq!( + media_type_from_url("https://example.com/photo.jpg"), + "image/jpeg" + ); // No extension — defaults to JPEG - assert_eq!(media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"), "image/jpeg"); + assert_eq!( + media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"), + "image/jpeg" + ); } } diff --git a/crates/openfang-channels/src/dingtalk_stream.rs b/crates/openfang-channels/src/dingtalk_stream.rs index d5c96b88..dd854b43 100644 --- a/crates/openfang-channels/src/dingtalk_stream.rs +++ b/crates/openfang-channels/src/dingtalk_stream.rs @@ -97,7 +97,10 @@ impl DingTalkStreamAdapter { user_ids: &[&str], content: ChannelContent, ) -> Result<(), Box> { - let token = self.get_token().await.map_err(|e| -> Box { e })?; + let token = self + .get_token() + .await + .map_err(|e| -> Box { e })?; let (msg_key, _msg_param) = match &content { ChannelContent::Text(t) => ( @@ -180,30 +183,28 @@ impl ChannelAdapter for DingTalkStreamAdapter { } // 1. Get access token - let token = match get_access_token(&client, &app_key, &app_secret, &token_cache) - .await - { - Ok(t) => t, - Err(e) => { - warn!("DingTalk Stream: token fetch failed: {e}"); - attempt += 1; - tokio::time::sleep(backoff(attempt)).await; - continue; - } - }; - - // 2. Get WebSocket endpoint - let ws_url = - match get_ws_endpoint(&client, &app_key, &app_secret, &token).await { - Ok(u) => u, + let token = + match get_access_token(&client, &app_key, &app_secret, &token_cache).await { + Ok(t) => t, Err(e) => { - warn!("DingTalk Stream: endpoint fetch failed: {e}"); + warn!("DingTalk Stream: token fetch failed: {e}"); attempt += 1; tokio::time::sleep(backoff(attempt)).await; continue; } }; + // 2. Get WebSocket endpoint + let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await { + Ok(u) => u, + Err(e) => { + warn!("DingTalk Stream: endpoint fetch failed: {e}"); + attempt += 1; + tokio::time::sleep(backoff(attempt)).await; + continue; + } + }; + info!( "DingTalk Stream: connecting to {}...", &ws_url[..ws_url.len().min(60)] @@ -379,7 +380,11 @@ async fn get_ws_endpoint( .error_for_status()? .json() .await?; - let sep = if resp.endpoint.contains('?') { "&" } else { "?" }; + let sep = if resp.endpoint.contains('?') { + "&" + } else { + "?" + }; Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket)) } @@ -481,13 +486,11 @@ where "CALLBACK" | "EVENT" => { let data_str = frame.data.to_string(); // Try direct parse, then try unwrapping double-encoded string - let cb: Option = serde_json::from_str(&data_str) - .ok() - .or_else(|| { - serde_json::from_str::(&data_str) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - }); + let cb: Option = serde_json::from_str(&data_str).ok().or_else(|| { + serde_json::from_str::(&data_str) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + }); if let Some(cb) = cb { if cb.msg_type == "text" { @@ -499,9 +502,7 @@ where let cmd = parts[0].trim_start_matches('/'); let args: Vec = parts .get(1) - .map(|a| { - a.split_whitespace().map(String::from).collect() - }) + .map(|a| a.split_whitespace().map(String::from).collect()) .unwrap_or_default(); ChannelContent::Command { name: cmd.to_string(), diff --git a/crates/openfang-channels/src/discord.rs b/crates/openfang-channels/src/discord.rs index a9171696..a3a24243 100644 --- a/crates/openfang-channels/src/discord.rs +++ b/crates/openfang-channels/src/discord.rs @@ -318,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter { } "MESSAGE_CREATE" | "MESSAGE_UPDATE" => { - if let Some(msg) = - parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots) - .await + if let Some(msg) = parse_discord_message( + d, + &bot_user_id, + &allowed_guilds, + &allowed_users, + ignore_bots, + ) + .await { debug!( "Discord {event_name} from {}: {:?}", @@ -517,8 +522,8 @@ async fn parse_discord_message( .map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str()))) .unwrap_or(false); // Also check content for <@bot_id> or <@!bot_id> patterns - let mentioned_in_content = - content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>")); + let mentioned_in_content = content_text.contains(&format!("<@{bid}>")) + || content_text.contains(&format!("<@!{bid}>")); mentioned_in_array || mentioned_in_content } else { false @@ -566,7 +571,9 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[], true) + .await + .unwrap(); assert_eq!(msg.channel, ChannelType::Discord); assert_eq!(msg.sender.display_name, "alice"); assert_eq!(msg.sender.platform_id, "ch1"); @@ -674,7 +681,8 @@ mod tests { }); // Not in allowed guilds - let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await; + let msg = + parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await; assert!(msg.is_none()); // In allowed guilds @@ -697,7 +705,9 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[], true) + .await + .unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agent"); @@ -741,7 +751,9 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[], true) + .await + .unwrap(); assert_eq!(msg.sender.display_name, "alice#1234"); } @@ -763,7 +775,9 @@ mod tests { }); // MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE - let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[], true) + .await + .unwrap(); assert_eq!(msg.channel, ChannelType::Discord); assert!( matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content") @@ -786,7 +800,14 @@ mod tests { }); // Not in allowed users - let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await; + let msg = parse_discord_message( + &d, + &bot_id, + &[], + &["user111".into(), "user222".into()], + true, + ) + .await; assert!(msg.is_none()); // In allowed users @@ -817,9 +838,14 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[], true) + .await + .unwrap(); assert!(msg.is_group); - assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), + Some(true) + ); // Message without mention in group let d2 = serde_json::json!({ @@ -835,7 +861,9 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap(); + let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true) + .await + .unwrap(); assert!(msg2.is_group); assert!(!msg2.metadata.contains_key("was_mentioned")); } @@ -855,13 +883,21 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[], true) + .await + .unwrap(); assert!(!msg.is_group); } #[test] fn test_discord_adapter_creation() { - let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376); + let adapter = DiscordAdapter::new( + "test-token".to_string(), + vec!["123".to_string(), "456".to_string()], + vec![], + true, + 37376, + ); assert_eq!(adapter.name(), "discord"); assert_eq!(adapter.channel_type(), ChannelType::Discord); } diff --git a/crates/openfang-channels/src/email.rs b/crates/openfang-channels/src/email.rs index 009ba0de..d76b414a 100644 --- a/crates/openfang-channels/src/email.rs +++ b/crates/openfang-channels/src/email.rs @@ -139,8 +139,7 @@ impl EmailAdapter { async fn build_smtp_transport( &self, ) -> Result, Box> { - let creds = - Credentials::new(self.username.clone(), self.password.as_str().to_string()); + let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string()); let transport = if self.smtp_port == 465 { // Implicit TLS (port 465) @@ -215,8 +214,8 @@ fn fetch_unseen_emails( .build() .map_err(|e| format!("TLS connector error: {e}"))?; - let client = imap::connect((host, port), host, &tls) - .map_err(|e| format!("IMAP connect failed: {e}"))?; + let client = + imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?; // Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark // that reject LOGIN and only support AUTH=PLAIN (SASL). @@ -390,8 +389,7 @@ impl ChannelAdapter for EmailAdapter { } // Extract target agent from subject brackets (stored in metadata for router) - let _target_agent = - EmailAdapter::extract_agent_from_subject(&subject); + let _target_agent = EmailAdapter::extract_agent_from_subject(&subject); let clean_subject = EmailAdapter::strip_agent_tag(&subject); // Build the message body: prepend subject context diff --git a/crates/openfang-channels/src/feishu.rs b/crates/openfang-channels/src/feishu.rs index ea8d4a4b..2c6ea17f 100644 --- a/crates/openfang-channels/src/feishu.rs +++ b/crates/openfang-channels/src/feishu.rs @@ -432,11 +432,7 @@ fn extract_text_from_post(content: &serde_json::Value) -> Option { } /// Check whether the bot should respond to a group message. -fn should_respond_in_group( - text: &str, - mentions: &serde_json::Value, - bot_names: &[String], -) -> bool { +fn should_respond_in_group(text: &str, mentions: &serde_json::Value, bot_names: &[String]) -> bool { if let Some(arr) = mentions.as_array() { if !arr.is_empty() { return true; @@ -522,7 +518,11 @@ fn parse_event( let text = match msg_type { "text" => { - let t = content_json["text"].as_str().unwrap_or("").trim().to_string(); + let t = content_json["text"] + .as_str() + .unwrap_or("") + .trim() + .to_string(); if t.is_empty() { return None; } @@ -677,8 +677,7 @@ impl ChannelAdapter for FeishuAdapter { let mut event_data = body.0.clone(); // Step 1: Decrypt if encrypted - if let Some(encrypted) = - body.0.get("encrypt").and_then(|v| v.as_str()) + if let Some(encrypted) = body.0.get("encrypt").and_then(|v| v.as_str()) { if let Some(ref key) = *ek { match decrypt_event(encrypted, key) { @@ -703,8 +702,7 @@ impl ChannelAdapter for FeishuAdapter { == Some("url_verification") { if let Some(ref expected_token) = *vt { - let token = - event_data["token"].as_str().unwrap_or(""); + let token = event_data["token"].as_str().unwrap_or(""); if token != expected_token { warn!("{region_label}: invalid verification token"); return ( @@ -754,16 +752,13 @@ impl ChannelAdapter for FeishuAdapter { if let Some(msg) = parse_event(&event_data, &bot_names, &channel_name) { - if !message_dedup - .check_and_insert(&msg.platform_message_id) - { + if !message_dedup.check_and_insert(&msg.platform_message_id) { let _ = tx.send(msg).await; } } } else { // V1 legacy event format - let event_type = - event_data["event"]["type"].as_str().unwrap_or(""); + let event_type = event_data["event"]["type"].as_str().unwrap_or(""); if event_type == "message" { let event = &event_data["event"]; let text = event["text"].as_str().unwrap_or(""); @@ -778,10 +773,8 @@ impl ChannelAdapter for FeishuAdapter { .as_str() .unwrap_or("") .to_string(); - let is_group = event["chat_type"] - .as_str() - .unwrap_or("") - == "group"; + let is_group = + event["chat_type"].as_str().unwrap_or("") == "group"; if !message_dedup.check_and_insert(&msg_id) { let content = if text.starts_with('/') { @@ -936,7 +929,10 @@ mod tests { assert_eq!(FeishuRegion::parse_region("cn"), FeishuRegion::Cn); assert_eq!(FeishuRegion::parse_region("intl"), FeishuRegion::Intl); assert_eq!(FeishuRegion::parse_region("lark"), FeishuRegion::Intl); - assert_eq!(FeishuRegion::parse_region("international"), FeishuRegion::Intl); + assert_eq!( + FeishuRegion::parse_region("international"), + FeishuRegion::Intl + ); assert_eq!(FeishuRegion::parse_region("anything"), FeishuRegion::Cn); } @@ -1076,10 +1072,7 @@ mod tests { strip_mention_placeholders("@_user_1 hello world"), "hello world" ); - assert_eq!( - strip_mention_placeholders("@_user_1 @_user_2 hi"), - "hi" - ); + assert_eq!(strip_mention_placeholders("@_user_1 @_user_2 hi"), "hi"); assert_eq!(strip_mention_placeholders("no mentions"), "no mentions"); } diff --git a/crates/openfang-channels/src/formatter.rs b/crates/openfang-channels/src/formatter.rs index ac5957fe..4480f4a9 100644 --- a/crates/openfang-channels/src/formatter.rs +++ b/crates/openfang-channels/src/formatter.rs @@ -17,6 +17,15 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String { } } +/// Format a message for WeCom, using a stronger plain-text conversion to avoid +/// leaking Markdown syntax into enterprise chat replies. +pub fn format_for_wecom(text: &str, format: OutputFormat) -> String { + match format { + OutputFormat::PlainText => markdown_to_wecom_plain(text), + _ => format_for_channel(text, format), + } +} + /// Convert Markdown to Telegram HTML subset. /// /// Supported tags: ``, ``, ``, `
`, ``, `
`. @@ -68,14 +77,14 @@ fn markdown_to_telegram_html(text: &str) -> String { if current.is_empty() || !current.starts_with('>') { break; } - let content = current - .strip_prefix('>') - .unwrap_or(current) - .trim_start(); + let content = current.strip_prefix('>').unwrap_or(current).trim_start(); quote_lines.push(render_inline_markdown(content)); i += 1; } - blocks.push(format!("
{}
", quote_lines.join("\n"))); + blocks.push(format!( + "
{}
", + quote_lines.join("\n") + )); continue; } @@ -107,7 +116,11 @@ fn markdown_to_telegram_html(text: &str) -> String { while i < lines.len() { let current = lines[i].trim(); if let Some(next_item) = ordered_list_item(current) { - items.push(format!("{}. {}", counter, render_inline_markdown(next_item.trim()))); + items.push(format!( + "{}. {}", + counter, + render_inline_markdown(next_item.trim()) + )); counter += 1; i += 1; } else if current.is_empty() { @@ -313,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String { result } +fn strip_atx_heading(line: &str) -> String { + let trimmed = line.trim_start(); + let heading_level = trimmed.chars().take_while(|c| *c == '#').count(); + if !(1..=6).contains(&heading_level) { + return line.to_string(); + } + + if trimmed.chars().nth(heading_level) != Some(' ') { + return line.to_string(); + } + + trimmed[heading_level..] + .trim() + .trim_end_matches('#') + .trim_end() + .to_string() +} + +fn strip_blockquote_prefix(line: &str) -> String { + let mut trimmed = line.trim_start(); + while let Some(rest) = trimmed.strip_prefix('>') { + trimmed = rest.trim_start(); + } + trimmed.to_string() +} + +fn strip_task_list_prefix(line: &str) -> String { + let trimmed = line.trim_start(); + for prefix in [ + "- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ", + ] { + if let Some(rest) = trimmed.strip_prefix(prefix) { + return rest.to_string(); + } + } + line.to_string() +} + +fn is_fenced_code_marker(line: &str) -> bool { + let trimmed = line.trim(); + let mut chars = trimmed.chars(); + let Some(marker) = chars.next() else { + return false; + }; + if marker != '`' && marker != '~' { + return false; + } + chars.all(|c| c == marker || c.is_ascii_alphanumeric()) +} + +fn is_setext_heading_underline(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.len() < 3 { + return false; + } + trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-']) +} + +fn is_table_divider(line: &str) -> bool { + let trimmed = line.trim(); + !trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' ')) +} + +fn strip_inline_markdown(mut text: String) -> String { + while let Some(start) = text.find("![") { + if let Some(mid) = text[start..].find("](") { + let mid = start + mid; + if let Some(end) = text[mid + 2..].find(')') { + let end = mid + 2 + end; + let alt = &text[start + 2..mid]; + let url = &text[mid + 2..end]; + let replacement = if alt.is_empty() { + url.to_string() + } else { + format!("{alt} ({url})") + }; + text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]); + continue; + } + } + break; + } + + while let Some(start) = text.find('[') { + if let Some(mid) = text[start..].find("](") { + let mid = start + mid; + if let Some(end) = text[mid + 2..].find(')') { + let end = mid + 2 + end; + let label = &text[start + 1..mid]; + let url = &text[mid + 2..end]; + text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]); + continue; + } + } + break; + } + + while let Some(start) = text.find('<') { + if let Some(end) = text[start + 1..].find('>') { + let end = start + 1 + end; + let inner = &text[start + 1..end]; + if inner.starts_with("http://") + || inner.starts_with("https://") + || inner.starts_with("mailto:") + { + text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]); + continue; + } + } + break; + } + + text = text.replace("**", ""); + text = text.replace("__", ""); + text = text.replace("~~", ""); + text = text.replace('`', ""); + + let mut out = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch == '*' + && (i == 0 || chars[i - 1] != '*') + && (i + 1 >= chars.len() || chars[i + 1] != '*') + { + continue; + } + out.push(ch); + } + out +} + +/// Strip common Markdown blocks for WeCom plain-text replies. +fn markdown_to_wecom_plain(text: &str) -> String { + let mut result_lines = Vec::new(); + let mut in_fenced_code = false; + + for raw_line in text.replace("\r\n", "\n").lines() { + let trimmed = raw_line.trim(); + + if is_fenced_code_marker(trimmed) { + in_fenced_code = !in_fenced_code; + continue; + } + + if in_fenced_code { + result_lines.push(raw_line.trim_end().to_string()); + continue; + } + + if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) { + continue; + } + + let mut line = strip_atx_heading(raw_line); + line = strip_blockquote_prefix(&line); + line = strip_task_list_prefix(&line); + + let trimmed_line = line.trim(); + if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 { + line = trimmed_line + .trim_matches('|') + .split('|') + .map(|cell| cell.trim()) + .collect::>() + .join(" "); + } + + line = strip_inline_markdown(line); + result_lines.push(line.trim().to_string()); + } + + let mut collapsed = Vec::new(); + for line in result_lines { + if line.is_empty() + && collapsed + .last() + .is_some_and(|prev: &String| prev.is_empty()) + { + continue; + } + collapsed.push(line); + } + + collapsed.join("\n").trim().to_string() +} + /// Strip all Markdown formatting, producing plain text. fn markdown_to_plain(text: &str) -> String { let mut result = text.to_string(); @@ -451,4 +650,26 @@ mod tests { let result = markdown_to_plain("[click](https://example.com)"); assert_eq!(result, "click (https://example.com)"); } + + #[test] + fn test_wecom_plain_text_strips_common_markdown_blocks() { + let result = markdown_to_wecom_plain( + "# Title\n\ + \n\ + > quoted text\n\ + \n\ + - [x] done item\n\ + - [ ] todo item\n\ + \n\ + ```rust\n\ + let value = 1;\n\ + ```\n\ + \n\ + [docs](https://example.com)\n", + ); + assert_eq!( + result, + "Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)" + ); + } } diff --git a/crates/openfang-channels/src/lib.rs b/crates/openfang-channels/src/lib.rs index 97db72e1..08628017 100644 --- a/crates/openfang-channels/src/lib.rs +++ b/crates/openfang-channels/src/lib.rs @@ -51,3 +51,4 @@ pub mod linkedin; pub mod mumble; pub mod ntfy; pub mod webhook; +pub mod wecom; diff --git a/crates/openfang-channels/src/matrix.rs b/crates/openfang-channels/src/matrix.rs index 51865f1f..be758e68 100644 --- a/crates/openfang-channels/src/matrix.rs +++ b/crates/openfang-channels/src/matrix.rs @@ -160,9 +160,7 @@ async fn get_room_member_count( access_token: &str, room_id: &str, ) -> Option { - let url = format!( - "{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members" - ); + let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members"); let resp = client .get(&url) .bearer_auth(access_token) @@ -296,10 +294,13 @@ impl ChannelAdapter for MatrixAdapter { if !allowed_rooms.is_empty() && !allowed_rooms.iter().any(|r| r == room_id) { - debug!("Matrix: ignoring invite to {room_id} (not in allowed_rooms)"); + debug!( + "Matrix: ignoring invite to {room_id} (not in allowed_rooms)" + ); continue; } - accept_invite(&client, &homeserver, access_token.as_str(), room_id).await; + accept_invite(&client, &homeserver, access_token.as_str(), room_id) + .await; } } } @@ -372,10 +373,7 @@ impl ChannelAdapter for MatrixAdapter { "was_mentioned".to_string(), serde_json::json!(true), ); - metadata.insert( - "is_dm".to_string(), - serde_json::json!(true), - ); + metadata.insert("is_dm".to_string(), serde_json::json!(true)); } let channel_msg = ChannelMessage { diff --git a/crates/openfang-channels/src/nostr.rs b/crates/openfang-channels/src/nostr.rs index 18a43275..ec54ade4 100644 --- a/crates/openfang-channels/src/nostr.rs +++ b/crates/openfang-channels/src/nostr.rs @@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter { ) -> Result + Send>>, Box> { let pubkey = self.derive_pubkey(); - info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16)); + info!( + "Nostr adapter starting (pubkey: {}...)", + openfang_types::truncate_str(&pubkey, 16) + ); if self.relays.is_empty() { return Err("Nostr: no relay URLs configured".into()); diff --git a/crates/openfang-channels/src/router.rs b/crates/openfang-channels/src/router.rs index 941e8173..18e10839 100644 --- a/crates/openfang-channels/src/router.rs +++ b/crates/openfang-channels/src/router.rs @@ -78,8 +78,7 @@ impl AgentRouter { agent_name: String, ) { self.channel_defaults.insert(channel_key.clone(), agent_id); - self.channel_default_names - .insert(channel_key, agent_name); + self.channel_default_names.insert(channel_key, agent_name); } /// Retrieve the stored agent name for a channel default (if any). diff --git a/crates/openfang-channels/src/slack.rs b/crates/openfang-channels/src/slack.rs index b0915530..f39d5e70 100644 --- a/crates/openfang-channels/src/slack.rs +++ b/crates/openfang-channels/src/slack.rs @@ -290,15 +290,14 @@ impl ChannelAdapter for SlackAdapter { // Extract the event let event = &payload["payload"]["event"]; - if let Some(msg) = - parse_slack_event( - event, - &bot_user_id, - &allowed_channels, - &active_threads, - auto_thread_reply, - ) - .await + if let Some(msg) = parse_slack_event( + event, + &bot_user_id, + &allowed_channels, + &active_threads, + auto_thread_reply, + ) + .await { debug!( "Slack message from {}: {:?}", @@ -369,12 +368,8 @@ impl ChannelAdapter for SlackAdapter { .await?; } _ => { - self.api_send_message( - channel_id, - "(Unsupported content type)", - Some(thread_id), - ) - .await?; + self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id)) + .await?; } } Ok(()) @@ -572,7 +567,9 @@ mod tests { "ts": "1700000000.000100" }); - let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap(); + let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true) + .await + .unwrap(); assert_eq!(msg.channel, ChannelType::Slack); assert_eq!(msg.sender.platform_id, "C789"); assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!")); @@ -621,12 +618,25 @@ mod tests { }); // Not in allowed channels - let msg = - parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()], &Arc::new(DashMap::new()), true).await; + let msg = parse_slack_event( + &event, + &bot_id, + &["C111".to_string(), "C222".to_string()], + &Arc::new(DashMap::new()), + true, + ) + .await; assert!(msg.is_none()); // In allowed channels - let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()], &Arc::new(DashMap::new()), true).await; + let msg = parse_slack_event( + &event, + &bot_id, + &["C789".to_string()], + &Arc::new(DashMap::new()), + true, + ) + .await; assert!(msg.is_some()); } @@ -658,7 +668,9 @@ mod tests { "ts": "1700000000.000100" }); - let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap(); + let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true) + .await + .unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agent"); @@ -683,7 +695,9 @@ mod tests { "ts": "1700000001.000200" }); - let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap(); + let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true) + .await + .unwrap(); assert_eq!(msg.channel, ChannelType::Slack); assert_eq!(msg.sender.platform_id, "C789"); assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text")); diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index a51a1f14..670d038e 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -147,11 +147,7 @@ impl TelegramAdapter { caption: Option<&str>, thread_id: Option, ) -> Result<(), Box> { - let url = format!( - "{}/bot{}/sendPhoto", - self.api_base_url, - self.token.as_str() - ); + let url = format!("{}/bot{}/sendPhoto", self.api_base_url, self.token.as_str()); let mut body = serde_json::json!({ "chat_id": chat_id, "photo": photo_url, @@ -245,11 +241,7 @@ impl TelegramAdapter { voice_url: &str, thread_id: Option, ) -> Result<(), Box> { - let url = format!( - "{}/bot{}/sendVoice", - self.api_base_url, - self.token.as_str() - ); + let url = format!("{}/bot{}/sendVoice", self.api_base_url, self.token.as_str()); let mut body = serde_json::json!({ "chat_id": chat_id, "voice": voice_url, @@ -391,8 +383,7 @@ impl TelegramAdapter { self.api_send_voice(chat_id, &url, thread_id).await?; } ChannelContent::Location { lat, lon } => { - self.api_send_location(chat_id, lat, lon, thread_id) - .await?; + self.api_send_location(chat_id, lat, lon, thread_id).await?; } ChannelContent::Command { name, args } => { let text = format!("/{name} {}", args.join(" ")); @@ -568,7 +559,16 @@ impl ChannelAdapter for TelegramAdapter { // Parse the message let bot_uname = bot_username.read().await.clone(); - let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client, &api_base_url, bot_uname.as_deref()).await { + let msg = match parse_telegram_update( + update, + &allowed_users, + token.as_str(), + &client, + &api_base_url, + bot_uname.as_deref(), + ) + .await + { Some(m) => m, None => continue, // filtered out or unparseable }; @@ -665,9 +665,7 @@ async fn telegram_get_file_url( return None; } let file_path = body["result"]["file_path"].as_str()?; - Some(format!( - "{api_base_url}/file/bot{token}/{file_path}" - )) + Some(format!("{api_base_url}/file/bot{token}/{file_path}")) } async fn parse_telegram_update( @@ -679,7 +677,10 @@ async fn parse_telegram_update( bot_username: Option<&str>, ) -> Option { let update_id = update["update_id"].as_i64().unwrap_or(0); - let message = match update.get("message").or_else(|| update.get("edited_message")) { + let message = match update + .get("message") + .or_else(|| update.get("edited_message")) + { Some(m) => m, None => { debug!("Telegram: dropping update {update_id} — no message or edited_message field"); @@ -713,9 +714,7 @@ async fn parse_telegram_update( return None; } }; - let title = sender_chat["title"] - .as_str() - .unwrap_or("Unknown Channel"); + let title = sender_chat["title"].as_str().unwrap_or("Unknown Channel"); (uid, title.to_string()) } else { debug!("Telegram: dropping update {update_id} — no from or sender_chat field"); @@ -782,7 +781,10 @@ async fn parse_telegram_update( Some(url) => ChannelContent::Image { url, caption }, None => ChannelContent::Text(format!( "[Photo received{}]", - caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default() + caption + .as_deref() + .map(|c| format!(": {c}")) + .unwrap_or_default() )), } } else if message.get("document").is_some() { @@ -834,7 +836,10 @@ async fn parse_telegram_update( // so the agent sees the reply context without breaking command parsing. let mut new_args = vec![format!("{prefix}{}", args.join(" "))]; new_args.retain(|a| !a.trim().is_empty()); - ChannelContent::Command { name, args: new_args } + ChannelContent::Command { + name, + args: new_args, + } } other => other, // Image/File/Voice/Location — no text to prepend } @@ -936,7 +941,17 @@ pub fn calculate_backoff(current: Duration) -> Duration { /// Everything else (e.g. ``, ``) gets escaped to `<...>`. fn sanitize_telegram_html(text: &str) -> String { const ALLOWED: &[&str] = &[ - "b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler", + "b", + "i", + "u", + "s", + "em", + "strong", + "a", + "code", + "pre", + "blockquote", + "tg-spoiler", "tg-emoji", ]; @@ -1015,7 +1030,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert_eq!(msg.channel, ChannelType::Telegram); assert_eq!(msg.sender.display_name, "Alice Smith"); assert_eq!(msg.sender.platform_id, "111222333"); @@ -1047,7 +1064,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agent"); @@ -1079,17 +1098,34 @@ mod tests { let client = test_client(); // Empty allowed_users = allow all - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await; + let msg = + parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await; assert!(msg.is_some()); // Non-matching allowed_users = filter out let blocked: Vec = vec!["111".to_string(), "222".to_string()]; - let msg = parse_telegram_update(&update, &blocked, "fake:token", &client, DEFAULT_API_URL, None).await; + let msg = parse_telegram_update( + &update, + &blocked, + "fake:token", + &client, + DEFAULT_API_URL, + None, + ) + .await; assert!(msg.is_none()); // Matching allowed_users = allow let allowed: Vec = vec!["999".to_string()]; - let msg = parse_telegram_update(&update, &allowed, "fake:token", &client, DEFAULT_API_URL, None).await; + let msg = parse_telegram_update( + &update, + &allowed, + "fake:token", + &client, + DEFAULT_API_URL, + None, + ) + .await; assert!(msg.is_some()); } @@ -1115,7 +1151,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert_eq!(msg.channel, ChannelType::Telegram); assert_eq!(msg.sender.display_name, "Alice Smith"); assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!")); @@ -1151,7 +1189,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agents"); @@ -1175,7 +1215,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert!(matches!(msg.content, ChannelContent::Location { .. })); } @@ -1199,7 +1241,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); // With a fake token, getFile will fail, so we get a text fallback match &msg.content { ChannelContent::Text(t) => { @@ -1234,7 +1278,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert!(t.contains("Document received")); @@ -1265,13 +1311,17 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert!(t.contains("Voice message")); assert!(t.contains("15s")); } - ChannelContent::Voice { duration_seconds, .. } => { + ChannelContent::Voice { + duration_seconds, .. + } => { assert_eq!(*duration_seconds, 15); } other => panic!("Expected Text or Voice for voice message, got {other:?}"), @@ -1294,7 +1344,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert_eq!(msg.thread_id, Some("42".to_string())); assert!(msg.is_group); } @@ -1314,7 +1366,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert_eq!(msg.thread_id, None); assert!(!msg.is_group); } @@ -1336,7 +1390,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert_eq!(msg.thread_id, Some("99".to_string())); } @@ -1359,10 +1415,14 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); assert_eq!(msg.sender.display_name, "My Channel"); assert_eq!(msg.sender.platform_id, "-1001234567890"); - assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Forwarded from channel")); + assert!( + matches!(msg.content, ChannelContent::Text(ref t) if t == "Forwarded from channel") + ); } #[tokio::test] @@ -1379,7 +1439,8 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await; + let msg = + parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await; assert!(msg.is_none()); } @@ -1403,9 +1464,21 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap(); + let msg = parse_telegram_update( + &update, + &[], + "fake:token", + &client, + DEFAULT_API_URL, + Some("testbot"), + ) + .await + .unwrap(); assert!(msg.is_group); - assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), + Some(true) + ); } #[tokio::test] @@ -1423,7 +1496,16 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap(); + let msg = parse_telegram_update( + &update, + &[], + "fake:token", + &client, + DEFAULT_API_URL, + Some("testbot"), + ) + .await + .unwrap(); assert!(msg.is_group); assert!(!msg.metadata.contains_key("was_mentioned")); } @@ -1448,7 +1530,16 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap(); + let msg = parse_telegram_update( + &update, + &[], + "fake:token", + &client, + DEFAULT_API_URL, + Some("testbot"), + ) + .await + .unwrap(); assert!(msg.is_group); assert!(!msg.metadata.contains_key("was_mentioned")); } @@ -1476,9 +1567,21 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap(); + let msg = parse_telegram_update( + &update, + &[], + "fake:token", + &client, + DEFAULT_API_URL, + Some("testbot"), + ) + .await + .unwrap(); assert!(msg.is_group); - assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), + Some(true) + ); } #[tokio::test] @@ -1501,8 +1604,20 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap(); - assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true)); + let msg = parse_telegram_update( + &update, + &[], + "fake:token", + &client, + DEFAULT_API_URL, + Some("testbot"), + ) + .await + .unwrap(); + assert_eq!( + msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), + Some(true) + ); } #[tokio::test] @@ -1525,7 +1640,16 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap(); + let msg = parse_telegram_update( + &update, + &[], + "fake:token", + &client, + DEFAULT_API_URL, + Some("testbot"), + ) + .await + .unwrap(); assert!(!msg.is_group); // In private chats, mention detection is skipped — no metadata set assert!(!msg.metadata.contains_key("was_mentioned")); @@ -1576,7 +1700,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert!(t.starts_with("[Replying to Bob: We should use Rust]\n\n")); @@ -1586,7 +1712,9 @@ mod tests { } // reply_to_message_id should be stored in metadata assert_eq!( - msg.metadata.get("reply_to_message_id").and_then(|v| v.as_i64()), + msg.metadata + .get("reply_to_message_id") + .and_then(|v| v.as_i64()), Some(99) ); } @@ -1614,7 +1742,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert!(t.starts_with("[Replying to Carol: Sunset view]\n\n")); @@ -1623,7 +1753,9 @@ mod tests { other => panic!("Expected Text, got {other:?}"), } assert_eq!( - msg.metadata.get("reply_to_message_id").and_then(|v| v.as_i64()), + msg.metadata + .get("reply_to_message_id") + .and_then(|v| v.as_i64()), Some(98) ); } @@ -1651,7 +1783,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert_eq!(t, "What was that?"); @@ -1659,7 +1793,9 @@ mod tests { other => panic!("Expected Text, got {other:?}"), } assert_eq!( - msg.metadata.get("reply_to_message_id").and_then(|v| v.as_i64()), + msg.metadata + .get("reply_to_message_id") + .and_then(|v| v.as_i64()), Some(97) ); } @@ -1685,7 +1821,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert!(t.starts_with("[Replying to Unknown: Anonymous message]\n\n")); @@ -1710,7 +1848,9 @@ mod tests { }); let client = test_client(); - let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); match &msg.content { ChannelContent::Text(t) => { assert_eq!(t, "Just a normal message"); diff --git a/crates/openfang-channels/src/wecom.rs b/crates/openfang-channels/src/wecom.rs new file mode 100644 index 00000000..12afadb4 --- /dev/null +++ b/crates/openfang-channels/src/wecom.rs @@ -0,0 +1,691 @@ +//! WeCom (WeChat Work) channel adapter. +//! +//! Uses the WeCom Work API for sending messages and a webhook HTTP server for +//! receiving inbound events. Authentication is performed via an access token +//! obtained from `https://qyapi.weixin.qq.com/cgi-bin/gettoken`. +//! The token is cached and refreshed automatically. + +use crate::types::{ + split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser, +}; +use async_trait::async_trait; +use axum::response::IntoResponse; +use chrono::Utc; +use futures::Stream; +use sha1::{Digest, Sha1}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, watch, RwLock}; +use tracing::{info, warn}; +use zeroize::Zeroizing; + +/// WeCom token endpoint. +const WECOM_TOKEN_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"; + +/// WeCom send message endpoint. +const WECOM_SEND_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/message/send"; + +/// Maximum WeCom message text length (characters). +const MAX_MESSAGE_LEN: usize = 2048; + +/// Token refresh buffer — refresh 5 minutes before actual expiry. +const TOKEN_REFRESH_BUFFER_SECS: u64 = 300; + +fn decrypt_aes_cbc(key: &[u8], encrypted_base64: &str) -> Result, String> { + use base64::Engine; + use cbc::cipher::{BlockDecryptMut, KeyIvInit}; + + // Decode base64 + let mut encrypted = base64::engine::general_purpose::STANDARD + .decode(encrypted_base64) + .map_err(|e| format!("base64 decode error: {}", e))?; + + // IV is first 16 bytes of key + type Aes256CbcDecrypt = cbc::Decryptor; + let iv = &key[..16]; + let cipher = Aes256CbcDecrypt::new(key.into(), iv.into()); + + let decrypted = cipher + .decrypt_padded_mut::(&mut encrypted) + .map_err(|e| format!("decrypt error: {}", e))?; + + let decrypted = decrypted.to_vec(); + let pad = decrypted + .last() + .copied() + .ok_or_else(|| "decrypted payload is empty".to_string())? as usize; + + if pad == 0 || pad > 32 || decrypted.len() < pad { + return Err(format!("invalid WeCom PKCS7 padding length: {pad}")); + } + if !decrypted[decrypted.len() - pad..] + .iter() + .all(|byte| *byte as usize == pad) + { + return Err("invalid WeCom PKCS7 padding bytes".to_string()); + } + + Ok(decrypted[..decrypted.len() - pad].to_vec()) +} + +fn is_valid_wecom_signature( + token: &str, + timestamp: &str, + nonce: &str, + encrypted_payload: &str, + msg_signature: &str, +) -> bool { + let mut parts = [token, timestamp, nonce, encrypted_payload]; + parts.sort_unstable(); + + let mut hasher = Sha1::new(); + hasher.update(parts.concat().as_bytes()); + hex::encode(hasher.finalize()) == msg_signature +} + +fn decode_wecom_payload(encoding_aes_key: &str, encrypted_payload: &str) -> Result { + use base64::{ + alphabet, + engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, + Engine, + }; + + let aes_key_engine = GeneralPurpose::new( + &alphabet::STANDARD, + GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::RequireNone) + .with_decode_allow_trailing_bits(true), + ); + + let aes_key = aes_key_engine + .decode(encoding_aes_key) + .map_err(|e| format!("aes key decode error: {e}"))?; + let decrypted = decrypt_aes_cbc(&aes_key, encrypted_payload)?; + + if decrypted.len() < 20 { + return Err("decrypted payload too short".to_string()); + } + + let msg_len = + u32::from_be_bytes([decrypted[16], decrypted[17], decrypted[18], decrypted[19]]) as usize; + if decrypted.len() < 20 + msg_len { + return Err("decrypted payload shorter than declared echostr".to_string()); + } + + String::from_utf8(decrypted[20..20 + msg_len].to_vec()) + .map_err(|e| format!("echostr is not valid utf-8: {e}")) +} + +fn parse_wecom_xml_fields(xml: &str) -> Result, String> { + let doc = roxmltree::Document::parse(xml).map_err(|e| format!("invalid xml: {e}"))?; + let root = doc.root_element(); + if root.tag_name().name() != "xml" { + return Err("root element is not ".to_string()); + } + + let mut fields = HashMap::new(); + for child in root.children().filter(|node| node.is_element()) { + let value = child + .children() + .filter_map(|node| node.text()) + .collect::() + .trim() + .to_string(); + fields.insert(child.tag_name().name().to_string(), value); + } + + Ok(fields) +} + +fn decode_wecom_post_body( + body: &str, + params: &HashMap, + token: Option<&str>, + encoding_aes_key: Option<&str>, +) -> Result, String> { + let parsed = parse_wecom_xml_fields(body)?; + + let Some(encrypted_payload) = parsed.get("Encrypt") else { + return Ok(parsed); + }; + + let token = token.ok_or_else(|| "missing WeCom callback token".to_string())?; + let timestamp = params + .get("timestamp") + .ok_or_else(|| "missing timestamp".to_string())?; + let nonce = params + .get("nonce") + .ok_or_else(|| "missing nonce".to_string())?; + let msg_signature = params + .get("msg_signature") + .ok_or_else(|| "missing msg_signature".to_string())?; + + if !is_valid_wecom_signature(token, timestamp, nonce, encrypted_payload, msg_signature) { + return Err("invalid WeCom callback signature".to_string()); + } + + let aes_key = encoding_aes_key + .filter(|key| !key.is_empty()) + .ok_or_else(|| "missing WeCom encoding_aes_key".to_string())?; + let decrypted_xml = decode_wecom_payload(aes_key, encrypted_payload)?; + parse_wecom_xml_fields(&decrypted_xml) +} + +fn wecom_success_response() -> axum::response::Response { + ( + axum::http::StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + "text/plain; charset=utf-8", + )], + "success", + ) + .into_response() +} + +/// WeCom adapter. +pub struct WeComAdapter { + /// WeCom corp ID. + corp_id: String, + /// WeCom application agent ID. + agent_id: String, + /// WeCom application secret, zeroized on drop. + secret: Zeroizing, + /// Encoding AES key for callback verification (optional). + encoding_aes_key: Option, + /// Token for callback verification (optional). + token: Option, + /// Port on which the inbound webhook HTTP server listens. + webhook_port: u16, + /// HTTP client for API calls. + client: reqwest::Client, + /// Shutdown signal. + shutdown_tx: Arc>, + shutdown_rx: watch::Receiver, + /// Cached access token and its expiry instant. + cached_token: Arc>>, +} + +impl WeComAdapter { + /// Create a new WeCom adapter. + pub fn new(corp_id: String, agent_id: String, secret: String, webhook_port: u16) -> Self { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + Self { + corp_id, + agent_id, + secret: Zeroizing::new(secret), + encoding_aes_key: None, + token: None, + webhook_port, + client: reqwest::Client::new(), + shutdown_tx: Arc::new(shutdown_tx), + shutdown_rx, + cached_token: Arc::new(RwLock::new(None)), + } + } + + /// Create a new WeCom adapter with callback verification. + pub fn with_verification( + corp_id: String, + agent_id: String, + secret: String, + webhook_port: u16, + encoding_aes_key: Option, + token: Option, + ) -> Self { + let mut adapter = Self::new(corp_id, agent_id, secret, webhook_port); + adapter.encoding_aes_key = encoding_aes_key; + adapter.token = token; + adapter + } + + /// Obtain a valid access token, refreshing if expired or missing. + async fn get_token(&self) -> Result> { + let mut cached = self.cached_token.write().await; + + // Check if we have a valid cached token + if let Some((token, expiry)) = cached.as_ref() { + let now = Instant::now(); + let buffer = Duration::from_secs(TOKEN_REFRESH_BUFFER_SECS); + if now + buffer < *expiry { + return Ok(token.clone()); + } + } + + // Fetch new token + let url = format!( + "{}?corpid={}&corpsecret={}", + WECOM_TOKEN_URL, + self.corp_id, + self.secret.as_str() + ); + + let response = self.client.get(&url).send().await?; + let json: serde_json::Value = response.json().await?; + + if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) { + if errcode != 0 { + return Err(format!( + "WeCom API error: {} - {}", + errcode, + json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("") + ) + .into()); + } + } + + let token = json["access_token"] + .as_str() + .ok_or("Missing access_token in response")? + .to_string(); + + let expires_in = json["expires_in"].as_i64().unwrap_or(7200) as u64; + + let expiry = Instant::now() + Duration::from_secs(expires_in); + *cached = Some((token.clone(), expiry)); + + info!("WeCom access token refreshed, expires in {}s", expires_in); + Ok(token) + } + + /// Send a text message to a user. + async fn send_text( + &self, + user_id: &str, + content: &str, + ) -> Result<(), Box> { + let token = self.get_token().await?; + + let url = format!("{}?access_token={}", WECOM_SEND_URL, token); + + let payload = serde_json::json!({ + "touser": user_id, + "msgtype": "text", + "agentid": self.agent_id, + "text": { + "content": content + } + }); + + let response = self.client.post(&url).json(&payload).send().await?; + + let json: serde_json::Value = response.json().await?; + + if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) { + if errcode != 0 { + return Err(format!( + "WeCom send error: {} - {}", + errcode, + json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("") + ) + .into()); + } + } + + Ok(()) + } + + /// Validate credentials by getting the token. + async fn validate(&self) -> Result> { + let _token = self.get_token().await?; + // Token obtained successfully means credentials are valid + Ok(format!("corp_id={}", self.corp_id)) + } +} + +#[async_trait] +impl ChannelAdapter for WeComAdapter { + fn name(&self) -> &str { + "wecom" + } + + fn channel_type(&self) -> ChannelType { + ChannelType::Custom("wecom".to_string()) + } + + async fn start( + &self, + ) -> Result + Send>>, Box> + { + // Validate credentials + let _ = self.validate().await?; + info!("WeCom adapter initialized"); + + let (tx, rx) = mpsc::channel::(256); + let port = self.webhook_port; + let token = self.token.clone(); + let encoding_aes_key = self.encoding_aes_key.clone(); + let mut shutdown_rx = self.shutdown_rx.clone(); + + tokio::spawn(async move { + let token = Arc::new(token); + let encoding_aes_key = Arc::new(encoding_aes_key); + let tx = Arc::new(tx); + + let app = axum::Router::new().route( + "/wecom/webhook", + axum::routing::get({ + let encoding_aes_key = Arc::clone(&encoding_aes_key); + let token = Arc::clone(&token); + move |axum::extract::Query(params): axum::extract::Query>| { + let encoding_aes_key = Arc::clone(&encoding_aes_key); + let token = Arc::clone(&token); + async move { + // Handle callback verification (URL validation GET request) + // WeChat Work sends GET with msg_signature, timestamp, nonce, echostr + if let (Some(echostr_encoded), Some(msg_sig), Some(timestamp), Some(nonce)) = ( + params.get("echostr"), + params.get("msg_signature"), + params.get("timestamp"), + params.get("nonce"), + ) { + let Some(token_str) = token.as_deref() else { + return ( + axum::http::StatusCode::BAD_REQUEST, + "missing WeCom callback token", + ) + .into_response(); + }; + + if !is_valid_wecom_signature( + token_str, + timestamp, + nonce, + echostr_encoded, + msg_sig, + ) { + return ( + axum::http::StatusCode::FORBIDDEN, + "invalid WeCom callback signature", + ) + .into_response(); + } + + let body = match encoding_aes_key.as_deref() { + Some(aes_key) if !aes_key.is_empty() => { + match decode_wecom_payload(aes_key, echostr_encoded) { + Ok(echostr_plain) => echostr_plain, + Err(err) => { + warn!(error = %err, "Failed to decrypt WeCom echostr"); + return ( + axum::http::StatusCode::BAD_REQUEST, + "invalid WeCom echostr", + ) + .into_response(); + } + } + } + _ => echostr_encoded.clone(), + }; + + return ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + body, + ) + .into_response(); + } + ( + axum::http::StatusCode::BAD_REQUEST, + "missing WeCom verification parameters", + ) + .into_response() + } + } + }).post({ + let token = Arc::clone(&token); + let encoding_aes_key = Arc::clone(&encoding_aes_key); + let tx = Arc::clone(&tx); + move |axum::extract::Query(params): axum::extract::Query>, body: String| { + let token = Arc::clone(&token); + let encoding_aes_key = Arc::clone(&encoding_aes_key); + let tx = Arc::clone(&tx); + async move { + let fields = match decode_wecom_post_body( + &body, + ¶ms, + token.as_deref(), + encoding_aes_key.as_deref(), + ) { + Ok(fields) => fields, + Err(err) => { + warn!(error = %err, "Failed to parse WeCom callback body"); + return ( + axum::http::StatusCode::BAD_REQUEST, + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "invalid WeCom callback body", + ) + .into_response(); + } + }; + + let msg_type = fields.get("MsgType").map(String::as_str).unwrap_or(""); + let user_id = fields + .get("FromUserName") + .cloned() + .unwrap_or_default(); + let event = fields.get("Event").map(String::as_str).unwrap_or(""); + + info!( + msg_type = msg_type, + event = event, + from_user = %user_id, + "Received WeCom callback" + ); + + if msg_type == "event" { + if (event == "subscribe" || event == "enter_agent") + && !user_id.is_empty() + { + let msg = ChannelMessage { + channel: ChannelType::Custom("wecom".to_string()), + platform_message_id: String::new(), + sender: ChannelUser { + platform_id: user_id.clone(), + display_name: user_id.clone(), + openfang_user: None, + }, + content: ChannelContent::Text(String::new()), + target_agent: None, + timestamp: Utc::now(), + is_group: false, + thread_id: None, + metadata: HashMap::new(), + }; + let _ = tx.send(msg).await; + } + + return wecom_success_response(); + } + + if msg_type == "text" { + let content = fields.get("Content").cloned().unwrap_or_default(); + let msg_id = fields.get("MsgId").cloned().unwrap_or_default(); + + if !user_id.is_empty() && !content.is_empty() { + let msg = ChannelMessage { + channel: ChannelType::Custom("wecom".to_string()), + platform_message_id: msg_id, + sender: ChannelUser { + platform_id: user_id.clone(), + display_name: user_id.clone(), + openfang_user: None, + }, + content: ChannelContent::Text(content), + target_agent: None, + timestamp: Utc::now(), + is_group: false, + thread_id: None, + metadata: HashMap::new(), + }; + let _ = tx.send(msg).await; + } + } + + wecom_success_response() + } + } + }), + ); + + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + info!("WeCom webhook server listening on http://0.0.0.0:{}", port); + + let server = axum::serve(listener, app); + + tokio::select! { + result = server => { + if let Err(e) = result { + warn!("WeCom webhook server error: {}", e); + } + } + _ = shutdown_rx.changed() => { + info!("WeCom adapter shutting down"); + } + } + }); + + Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))) + } + + async fn send( + &self, + user: &ChannelUser, + content: ChannelContent, + ) -> Result<(), Box> { + let user_id = &user.platform_id; + + match content { + ChannelContent::Text(text) => { + // Split long messages + for chunk in split_message(&text, MAX_MESSAGE_LEN) { + self.send_text(user_id, chunk).await?; + } + } + ChannelContent::Command { name: _, args: _ } => { + // WeCom doesn't support commands natively + warn!("WeCom: commands not supported"); + } + _ => { + warn!("WeCom: unsupported content type"); + } + } + + Ok(()) + } + + async fn stop(&self) -> Result<(), Box> { + let _ = self.shutdown_tx.send(true); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_adapter_name() { + let adapter = WeComAdapter::new( + "corp_id".to_string(), + "agent_id".to_string(), + "secret".to_string(), + 8080, + ); + assert_eq!(adapter.name(), "wecom"); + } + + #[test] + fn test_adapter_channel_type() { + let adapter = WeComAdapter::new( + "corp_id".to_string(), + "agent_id".to_string(), + "secret".to_string(), + 8080, + ); + assert_eq!( + adapter.channel_type(), + ChannelType::Custom("wecom".to_string()) + ); + } + + #[test] + fn test_adapter_with_verification() { + let adapter = WeComAdapter::with_verification( + "corp_id".to_string(), + "agent_id".to_string(), + "secret".to_string(), + 8080, + Some("encoding_aes_key".to_string()), + Some("token".to_string()), + ); + assert_eq!(adapter.name(), "wecom"); + } + + #[test] + fn test_max_message_length() { + // MAX_MESSAGE_LEN should be 2048 for WeCom + assert_eq!(MAX_MESSAGE_LEN, 2048); + } + + #[test] + fn test_token_refresh_buffer() { + // Token refresh buffer should be 5 minutes + assert_eq!(TOKEN_REFRESH_BUFFER_SECS, 300); + } + + #[test] + fn test_wecom_signature_validation() { + assert!(is_valid_wecom_signature( + "token", + "1710000000", + "nonce", + "echostr", + "bf56bf867459f80e3ceb854596f39f02a5ac5e13", + )); + assert!(!is_valid_wecom_signature( + "token", + "1710000000", + "nonce", + "echostr", + "bad-signature", + )); + } + + #[test] + fn test_decode_wecom_payload() { + let plain = decode_wecom_payload( + "ShlNaJ0PrdXQAuCDVqMki7c2JLNnY6mebvQodTv9qoV", + "/gKbXNFpvlyYNTCneTag1rGm1P4Q5fExE3OPzdYlEyUVDgi55PHVIbo+mHMXWatdW8H8RTQJCly0HBNrWry2Uw==", + ) + .expect("echostr should decrypt"); + + assert_eq!(plain, "openfang-wecom-check"); + } + + #[test] + fn test_parse_wecom_xml_fields() { + let fields = parse_wecom_xml_fields( + r#" + + + + +123456 +"#, + ) + .expect("xml should parse"); + + assert_eq!( + fields.get("FromUserName").map(String::as_str), + Some("user123") + ); + assert_eq!(fields.get("MsgType").map(String::as_str), Some("text")); + assert_eq!(fields.get("Content").map(String::as_str), Some("hello")); + assert_eq!(fields.get("MsgId").map(String::as_str), Some("123456")); + } +} diff --git a/crates/openfang-channels/src/whatsapp.rs b/crates/openfang-channels/src/whatsapp.rs index 82ad5840..9f45fcd4 100644 --- a/crates/openfang-channels/src/whatsapp.rs +++ b/crates/openfang-channels/src/whatsapp.rs @@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter { if let Some(ref gw) = self.gateway_url { let text = match &content { ChannelContent::Text(t) => t.clone(), - ChannelContent::Image { caption, .. } => { - caption - .clone() - .unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()) - } + ChannelContent::Image { caption, .. } => caption + .clone() + .unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()), ChannelContent::File { filename, .. } => { format!("(File: {filename} — not supported in Web mode)") } diff --git a/crates/openfang-cli/src/bundled_agents.rs b/crates/openfang-cli/src/bundled_agents.rs index d1e036c9..0194859b 100644 --- a/crates/openfang-cli/src/bundled_agents.rs +++ b/crates/openfang-cli/src/bundled_agents.rs @@ -7,34 +7,112 @@ /// Returns all bundled agent templates as `(name, toml_content)` pairs. pub fn bundled_agents() -> Vec<(&'static str, &'static str)> { vec![ - ("analyst", include_str!("../../../agents/analyst/agent.toml")), - ("architect", include_str!("../../../agents/architect/agent.toml")), - ("assistant", include_str!("../../../agents/assistant/agent.toml")), + ( + "analyst", + include_str!("../../../agents/analyst/agent.toml"), + ), + ( + "architect", + include_str!("../../../agents/architect/agent.toml"), + ), + ( + "assistant", + include_str!("../../../agents/assistant/agent.toml"), + ), ("coder", include_str!("../../../agents/coder/agent.toml")), - ("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")), - ("customer-support", include_str!("../../../agents/customer-support/agent.toml")), - ("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")), - ("debugger", include_str!("../../../agents/debugger/agent.toml")), - ("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")), - ("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")), - ("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")), - ("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")), - ("hello-world", include_str!("../../../agents/hello-world/agent.toml")), - ("home-automation", include_str!("../../../agents/home-automation/agent.toml")), - ("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")), - ("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")), + ( + "code-reviewer", + include_str!("../../../agents/code-reviewer/agent.toml"), + ), + ( + "customer-support", + include_str!("../../../agents/customer-support/agent.toml"), + ), + ( + "data-scientist", + include_str!("../../../agents/data-scientist/agent.toml"), + ), + ( + "debugger", + include_str!("../../../agents/debugger/agent.toml"), + ), + ( + "devops-lead", + include_str!("../../../agents/devops-lead/agent.toml"), + ), + ( + "doc-writer", + include_str!("../../../agents/doc-writer/agent.toml"), + ), + ( + "email-assistant", + include_str!("../../../agents/email-assistant/agent.toml"), + ), + ( + "health-tracker", + include_str!("../../../agents/health-tracker/agent.toml"), + ), + ( + "hello-world", + include_str!("../../../agents/hello-world/agent.toml"), + ), + ( + "home-automation", + include_str!("../../../agents/home-automation/agent.toml"), + ), + ( + "legal-assistant", + include_str!("../../../agents/legal-assistant/agent.toml"), + ), + ( + "meeting-assistant", + include_str!("../../../agents/meeting-assistant/agent.toml"), + ), ("ops", include_str!("../../../agents/ops/agent.toml")), - ("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")), - ("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")), - ("planner", include_str!("../../../agents/planner/agent.toml")), - ("recruiter", include_str!("../../../agents/recruiter/agent.toml")), - ("researcher", include_str!("../../../agents/researcher/agent.toml")), - ("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")), - ("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")), - ("social-media", include_str!("../../../agents/social-media/agent.toml")), - ("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")), - ("translator", include_str!("../../../agents/translator/agent.toml")), - ("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")), + ( + "orchestrator", + include_str!("../../../agents/orchestrator/agent.toml"), + ), + ( + "personal-finance", + include_str!("../../../agents/personal-finance/agent.toml"), + ), + ( + "planner", + include_str!("../../../agents/planner/agent.toml"), + ), + ( + "recruiter", + include_str!("../../../agents/recruiter/agent.toml"), + ), + ( + "researcher", + include_str!("../../../agents/researcher/agent.toml"), + ), + ( + "sales-assistant", + include_str!("../../../agents/sales-assistant/agent.toml"), + ), + ( + "security-auditor", + include_str!("../../../agents/security-auditor/agent.toml"), + ), + ( + "social-media", + include_str!("../../../agents/social-media/agent.toml"), + ), + ( + "test-engineer", + include_str!("../../../agents/test-engineer/agent.toml"), + ), + ( + "translator", + include_str!("../../../agents/translator/agent.toml"), + ), + ( + "travel-planner", + include_str!("../../../agents/travel-planner/agent.toml"), + ), ("tutor", include_str!("../../../agents/tutor/agent.toml")), ("writer", include_str!("../../../agents/writer/agent.toml")), ] diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index ab71228c..a09e012d 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -940,7 +940,9 @@ fn main() { WorkflowCommands::List => cmd_workflow_list(), WorkflowCommands::Create { file } => cmd_workflow_create(file), WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id), - WorkflowCommands::Update { workflow_id, file } => cmd_workflow_update(&workflow_id, file), + WorkflowCommands::Update { workflow_id, file } => { + cmd_workflow_update(&workflow_id, file) + } WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id), WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input), }, @@ -1070,7 +1072,10 @@ fn main() { SystemCommands::Version { json } => cmd_system_version(json), }, Some(Commands::Reset { confirm }) => cmd_reset(confirm), - Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config), + Some(Commands::Uninstall { + confirm, + keep_config, + }) => cmd_uninstall(confirm, keep_config), } } @@ -1127,8 +1132,8 @@ pub(crate) fn find_daemon() -> Option { /// includes a `Authorization: Bearer ` header on every request. /// When api_key is empty or missing, no auth header is sent. pub(crate) fn daemon_client() -> reqwest::blocking::Client { - let mut builder = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(120)); + let mut builder = + reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120)); if let Some(key) = read_api_key() { let mut headers = reqwest::header::HeaderMap::new(); @@ -2248,7 +2253,9 @@ decay_rate = 0.05 if !json { ui::check_ok(&format!("Port {api_listen} is available")); } - checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen})); + checks.push( + serde_json::json!({"check": "port", "status": "ok", "address": api_listen}), + ); } Err(_) => { if !json { @@ -3179,7 +3186,11 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) { fn cmd_workflow_get(workflow_id: &str) { let base = require_daemon("workflow get"); let client = daemon_client(); - let body = daemon_json(client.get(format!("{base}/api/workflows/{workflow_id}")).send()); + let body = daemon_json( + client + .get(format!("{base}/api/workflows/{workflow_id}")) + .send(), + ); if body.get("error").is_some() { eprintln!( @@ -4144,7 +4155,10 @@ fn cmd_hand_install(path: &str) { body["name"].as_str().unwrap_or("?"), body["id"].as_str().unwrap_or("?"), ); - println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?")); + println!( + "Use `openfang hand activate {}` to start it.", + body["id"].as_str().unwrap_or("?") + ); } fn cmd_hand_list() { @@ -4169,10 +4183,7 @@ fn cmd_hand_list() { println!("No hands available."); return; } - println!( - "{:<14} {:<20} {:<10} DESCRIPTION", - "ID", "NAME", "CATEGORY" - ); + println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY"); println!("{}", "-".repeat(72)); for h in arr { println!( @@ -4180,7 +4191,12 @@ fn cmd_hand_list() { h["id"].as_str().unwrap_or("?"), h["name"].as_str().unwrap_or("?"), h["category"].as_str().unwrap_or("?"), - h["description"].as_str().unwrap_or("").chars().take(40).collect::(), + h["description"] + .as_str() + .unwrap_or("") + .chars() + .take(40) + .collect::(), ); } println!("\nUse `openfang hand activate ` to activate a hand."); @@ -4202,10 +4218,7 @@ fn cmd_hand_active() { println!("No active hands."); return; } - println!( - "{:<38} {:<14} {:<10} AGENT", - "INSTANCE", "HAND", "STATUS" - ); + println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS"); println!("{}", "-".repeat(72)); for i in &arr { println!( @@ -4293,10 +4306,7 @@ fn cmd_hand_info(id: &str) { let client = daemon_client(); let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send()); if body.get("error").is_some() { - eprintln!( - "Hand not found: {}", - body["error"].as_str().unwrap_or(id) - ); + eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id)); std::process::exit(1); } println!( @@ -5655,7 +5665,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option< .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .take(64) .collect(); - format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt }) + format!( + "{}-{}", + agent, + if short_prompt.is_empty() { + "job" + } else { + &short_prompt + } + ) }; let body = daemon_json( @@ -6409,10 +6427,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) { } else { match std::fs::remove_dir_all(&openfang_dir) { Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())), - Err(e) => ui::error(&format!( - "Failed to remove {}: {e}", - openfang_dir.display() - )), + Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())), } } } @@ -6421,10 +6436,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) { if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) { match std::fs::remove_file(&cargo_bin) { Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())), - Err(e) => ui::error(&format!( - "Failed to remove {}: {e}", - cargo_bin.display() - )), + Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())), } } @@ -6664,7 +6676,10 @@ fn remove_self_binary(exe_path: &std::path::Path) { .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) .spawn(); - ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display())); + ui::success(&format!( + "Removed {} (deferred cleanup)", + exe_path.display() + )); } } diff --git a/crates/openfang-cli/src/mcp.rs b/crates/openfang-cli/src/mcp.rs index 6eb1a807..f0e226c2 100644 --- a/crates/openfang-cli/src/mcp.rs +++ b/crates/openfang-cli/src/mcp.rs @@ -329,7 +329,11 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option { _ => { // Unknown method — always respond with error - Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}"))) + Some(jsonrpc_error( + rid, + -32601, + &format!("Method not found: {method}"), + )) } } } diff --git a/crates/openfang-cli/src/tui/chat_runner.rs b/crates/openfang-cli/src/tui/chat_runner.rs index c2ca8fed..9ad032f1 100644 --- a/crates/openfang-cli/src/tui/chat_runner.rs +++ b/crates/openfang-cli/src/tui/chat_runner.rs @@ -394,10 +394,7 @@ impl StandaloneChat { .as_str() .unwrap_or("") .to_string(), - provider: m["provider"] - .as_str() - .unwrap_or("") - .to_string(), + provider: m["provider"].as_str().unwrap_or("").to_string(), tier: m["tier"].as_str().unwrap_or("Balanced").to_string(), }) .collect() @@ -459,16 +456,13 @@ impl StandaloneChat { .send() { if let Ok(body) = resp.json::() { - let provider = - body["model_provider"].as_str().unwrap_or("?"); + let provider = body["model_provider"].as_str().unwrap_or("?"); let model = body["model_name"].as_str().unwrap_or("?"); self.chat.model_label = format!("{provider}/{model}"); } } - self.chat.push_message( - Role::System, - format!("Switched to {model_id}"), - ); + self.chat + .push_message(Role::System, format!("Switched to {model_id}")); } _ => { self.chat.push_message( @@ -506,16 +500,12 @@ impl StandaloneChat { .unwrap_or_else(|| "?".to_string()) }); self.chat.model_label = format!("{prov_label}/{model_id}"); - self.chat.push_message( - Role::System, - format!("Switched to {model_id}"), - ); + self.chat + .push_message(Role::System, format!("Switched to {model_id}")); } Err(e) => { - self.chat.push_message( - Role::System, - format!("Switch failed: {e}"), - ); + self.chat + .push_message(Role::System, format!("Switch failed: {e}")); } } } diff --git a/crates/openfang-cli/src/tui/mod.rs b/crates/openfang-cli/src/tui/mod.rs index 907c0aed..dce70434 100644 --- a/crates/openfang-cli/src/tui/mod.rs +++ b/crates/openfang-cli/src/tui/mod.rs @@ -516,8 +516,7 @@ impl App { } AppEvent::CommsEventsLoaded(events) => { self.comms.events = events; - if !self.comms.events.is_empty() - && self.comms.event_list_state.selected().is_none() + if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none() { self.comms.event_list_state.select(Some(0)); } @@ -1869,14 +1868,8 @@ impl App { .as_str() .unwrap_or("") .to_string(), - provider: m["provider"] - .as_str() - .unwrap_or("") - .to_string(), - tier: m["tier"] - .as_str() - .unwrap_or("Balanced") - .to_string(), + provider: m["provider"].as_str().unwrap_or("").to_string(), + tier: m["tier"].as_str().unwrap_or("Balanced").to_string(), }) .collect() }) @@ -1935,8 +1928,7 @@ impl App { .send() { if let Ok(body) = resp.json::() { - let provider = - body["model_provider"].as_str().unwrap_or("?"); + let provider = body["model_provider"].as_str().unwrap_or("?"); let model = body["model_name"].as_str().unwrap_or("?"); self.chat.model_label = format!("{provider}/{model}"); } @@ -1988,10 +1980,8 @@ impl App { ); } Err(e) => { - self.chat.push_message( - chat::Role::System, - format!("Switch failed: {e}"), - ); + self.chat + .push_message(chat::Role::System, format!("Switch failed: {e}")); } } } diff --git a/crates/openfang-cli/src/tui/screens/agents.rs b/crates/openfang-cli/src/tui/screens/agents.rs index 26ecb346..9121dc93 100644 --- a/crates/openfang-cli/src/tui/screens/agents.rs +++ b/crates/openfang-cli/src/tui/screens/agents.rs @@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/audit.rs b/crates/openfang-cli/src/tui/screens/audit.rs index 2fd3d2fe..f7aa4537 100644 --- a/crates/openfang-cli/src/tui/screens/audit.rs +++ b/crates/openfang-cli/src/tui/screens/audit.rs @@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/channels.rs b/crates/openfang-cli/src/tui/screens/channels.rs index aa51b432..8a07e45b 100644 --- a/crates/openfang-cli/src/tui/screens/channels.rs +++ b/crates/openfang-cli/src/tui/screens/channels.rs @@ -206,7 +206,11 @@ const CHANNEL_DEFS: &[ChannelDef] = &[ name: "dingtalk_stream", display_name: "DingTalk Stream", category: "Enterprise", - env_vars: &["DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_ROBOT_CODE"], + env_vars: &[ + "DINGTALK_APP_KEY", + "DINGTALK_APP_SECRET", + "DINGTALK_ROBOT_CODE", + ], description: "DingTalk Stream Mode (WebSocket long-connection)", }, ChannelDef { diff --git a/crates/openfang-cli/src/tui/screens/chat.rs b/crates/openfang-cli/src/tui/screens/chat.rs index 96fe994e..681d8b38 100644 --- a/crates/openfang-cli/src/tui/screens/chat.rs +++ b/crates/openfang-cli/src/tui/screens/chat.rs @@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) { return; // Too small to show picker } let popup_w = area.width.clamp(30, 54); - let popup_h = (filtered.len() as u16 + 4) - .clamp(5, area.height.saturating_sub(2)); + let popup_h = (filtered.len() as u16 + 4).clamp(5, area.height.saturating_sub(2)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_area = Rect::new(x, y, popup_w, popup_h); @@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) { let mut lines: Vec = Vec::new(); let max_name = (chunks[1].width as usize).saturating_sub(14); - for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) { + for (i, entry) in filtered + .iter() + .enumerate() + .skip(scroll_start) + .take(visible_h) + { let selected = i == state.model_picker_idx; let indicator = if selected { "\u{25b6} " } else { " " }; @@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max_len.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/comms.rs b/crates/openfang-cli/src/tui/screens/comms.rs index 40ffc8e5..b0a5b84b 100644 --- a/crates/openfang-cli/src/tui/screens/comms.rs +++ b/crates/openfang-cli/src/tui/screens/comms.rs @@ -158,11 +158,7 @@ impl CommsState { KeyCode::Up | KeyCode::Char('k') => { if self.focus == CommsFocus::EventList && !self.events.is_empty() { let i = self.event_list_state.selected().unwrap_or(0); - let next = if i == 0 { - self.events.len() - 1 - } else { - i - 1 - }; + let next = if i == 0 { self.events.len() - 1 } else { i - 1 }; self.event_list_state.select(Some(next)); } } @@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) { f.render_widget(block, area); let chunks = Layout::vertical([ - Constraint::Length(2), // header - Constraint::Length(1), // separator + Constraint::Length(2), // header + Constraint::Length(1), // separator Constraint::Percentage(35), // topology - Constraint::Length(1), // separator - Constraint::Min(4), // event list - Constraint::Length(1), // hints + Constraint::Length(1), // separator + Constraint::Min(4), // event list + Constraint::Length(1), // hints ]) .split(inner); @@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) { if state.nodes.is_empty() { f.render_widget( - Paragraph::new(Span::styled( - " No agents running.", - theme::dim_style(), - )), + Paragraph::new(Span::styled(" No agents running.", theme::dim_style())), area, ); return; @@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) { Span::styled(" ", Style::default()), Span::styled(branch, theme::dim_style()), Span::styled(format!("[{}]", child.state), state_color(&child.state)), - Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)), + Span::styled( + format!(" {} ", child.name), + Style::default().fg(theme::TEXT), + ), Span::styled(format!("({})", child.model), theme::dim_style()), ])); } @@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) { rows[3], ); f.render_widget( - Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))), + Paragraph::new(Span::styled( + "Assign to (agent ID, optional):", + field_style(2), + )), rows[4], ); f.render_widget( diff --git a/crates/openfang-cli/src/tui/screens/dashboard.rs b/crates/openfang-cli/src/tui/screens/dashboard.rs index 4a6bac7b..a6b8b25e 100644 --- a/crates/openfang-cli/src/tui/screens/dashboard.rs +++ b/crates/openfang-cli/src/tui/screens/dashboard.rs @@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 35950edc..5a076c23 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -950,7 +950,9 @@ fn handle_migration_key( let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") { PathBuf::from(h) } else { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang") + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openfang") }; let tx = migrate_tx.clone(); std::thread::spawn(move || { diff --git a/crates/openfang-cli/src/tui/screens/logs.rs b/crates/openfang-cli/src/tui/screens/logs.rs index d69077e6..40b6656a 100644 --- a/crates/openfang-cli/src/tui/screens/logs.rs +++ b/crates/openfang-cli/src/tui/screens/logs.rs @@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/memory.rs b/crates/openfang-cli/src/tui/screens/memory.rs index 644e27d9..cfa7b2da 100644 --- a/crates/openfang-cli/src/tui/screens/memory.rs +++ b/crates/openfang-cli/src/tui/screens/memory.rs @@ -317,10 +317,7 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) { .iter() .map(|a| { let id_short = if a.id.len() > 12 { - format!( - "{}\u{2026}", - openfang_types::truncate_str(&a.id, 12) - ) + format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12)) } else { a.id.clone() }; @@ -408,10 +405,7 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) { .iter() .map(|kv| { let val_display = if kv.value.len() > 40 { - format!( - "{}\u{2026}", - openfang_types::truncate_str(&kv.value, 39) - ) + format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39)) } else { kv.value.clone() }; @@ -555,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/mod.rs b/crates/openfang-cli/src/tui/screens/mod.rs index 9b395dce..25897100 100644 --- a/crates/openfang-cli/src/tui/screens/mod.rs +++ b/crates/openfang-cli/src/tui/screens/mod.rs @@ -1,8 +1,8 @@ pub mod agents; pub mod audit; pub mod channels; -pub mod comms; pub mod chat; +pub mod comms; pub mod dashboard; pub mod extensions; pub mod hands; diff --git a/crates/openfang-cli/src/tui/screens/peers.rs b/crates/openfang-cli/src/tui/screens/peers.rs index fb063140..3d3d8291 100644 --- a/crates/openfang-cli/src/tui/screens/peers.rs +++ b/crates/openfang-cli/src/tui/screens/peers.rs @@ -149,10 +149,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) { .iter() .map(|p| { let id_short = if p.node_id.len() > 12 { - format!( - "{}\u{2026}", - openfang_types::truncate_str(&p.node_id, 12) - ) + format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12)) } else { p.node_id.clone() }; @@ -211,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/sessions.rs b/crates/openfang-cli/src/tui/screens/sessions.rs index e40940f3..14bad4b6 100644 --- a/crates/openfang-cli/src/tui/screens/sessions.rs +++ b/crates/openfang-cli/src/tui/screens/sessions.rs @@ -251,10 +251,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) { .map(|&idx| { let s = &state.sessions[idx]; let id_short = if s.id.len() > 12 { - format!( - "{}\u{2026}", - openfang_types::truncate_str(&s.id, 12) - ) + format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12)) } else { s.id.clone() }; @@ -311,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/settings.rs b/crates/openfang-cli/src/tui/screens/settings.rs index 978b6ad8..b97e3460 100644 --- a/crates/openfang-cli/src/tui/screens/settings.rs +++ b/crates/openfang-cli/src/tui/screens/settings.rs @@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/skills.rs b/crates/openfang-cli/src/tui/screens/skills.rs index 662d5104..5494ebaf 100644 --- a/crates/openfang-cli/src/tui/screens/skills.rs +++ b/crates/openfang-cli/src/tui/screens/skills.rs @@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/templates.rs b/crates/openfang-cli/src/tui/screens/templates.rs index b8a3a87c..eae8faa7 100644 --- a/crates/openfang-cli/src/tui/screens/templates.rs +++ b/crates/openfang-cli/src/tui/screens/templates.rs @@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/triggers.rs b/crates/openfang-cli/src/tui/screens/triggers.rs index 979f958c..d55ddb6d 100644 --- a/crates/openfang-cli/src/tui/screens/triggers.rs +++ b/crates/openfang-cli/src/tui/screens/triggers.rs @@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/usage.rs b/crates/openfang-cli/src/tui/screens/usage.rs index 9e2fb1e5..e0f829cf 100644 --- a/crates/openfang-cli/src/tui/screens/usage.rs +++ b/crates/openfang-cli/src/tui/screens/usage.rs @@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-cli/src/tui/screens/workflows.rs b/crates/openfang-cli/src/tui/screens/workflows.rs index 50abde22..21514923 100644 --- a/crates/openfang-cli/src/tui/screens/workflows.rs +++ b/crates/openfang-cli/src/tui/screens/workflows.rs @@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { - format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1))) + format!( + "{}\u{2026}", + openfang_types::truncate_str(s, max.saturating_sub(1)) + ) } } diff --git a/crates/openfang-extensions/src/oauth.rs b/crates/openfang-extensions/src/oauth.rs index 811484df..beabe01b 100644 --- a/crates/openfang-extensions/src/oauth.rs +++ b/crates/openfang-extensions/src/oauth.rs @@ -27,9 +27,7 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> { } /// Resolve OAuth client IDs with config overrides applied on top of defaults. -pub fn resolve_client_ids( - config: &openfang_types::config::OAuthConfig, -) -> HashMap { +pub fn resolve_client_ids(config: &openfang_types::config::OAuthConfig) -> HashMap { let defaults = default_client_ids(); let mut resolved: HashMap = defaults .into_iter() diff --git a/crates/openfang-hands/src/bundled.rs b/crates/openfang-hands/src/bundled.rs index bce5af2d..7e91d315 100644 --- a/crates/openfang-hands/src/bundled.rs +++ b/crates/openfang-hands/src/bundled.rs @@ -241,7 +241,14 @@ mod tests { #[test] fn all_einstein_hands_have_schedules() { - let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"]; + let einstein_ids = [ + "lead", + "collector", + "predictor", + "researcher", + "twitter", + "trader", + ]; for (id, toml_content, skill_content) in bundled_hands() { if einstein_ids.contains(&id) { let def = parse_bundled(id, toml_content, skill_content).unwrap(); @@ -266,7 +273,14 @@ mod tests { #[test] fn all_einstein_hands_have_memory() { - let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"]; + let einstein_ids = [ + "lead", + "collector", + "predictor", + "researcher", + "twitter", + "trader", + ]; for (id, toml_content, skill_content) in bundled_hands() { if einstein_ids.contains(&id) { let def = parse_bundled(id, toml_content, skill_content).unwrap(); @@ -286,7 +300,14 @@ mod tests { #[test] fn all_einstein_hands_have_knowledge_graph() { - let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"]; + let einstein_ids = [ + "lead", + "collector", + "predictor", + "researcher", + "twitter", + "trader", + ]; for (id, toml_content, skill_content) in bundled_hands() { if einstein_ids.contains(&id) { let def = parse_bundled(id, toml_content, skill_content).unwrap(); diff --git a/crates/openfang-kernel/src/config_reload.rs b/crates/openfang-kernel/src/config_reload.rs index eec9d327..9533d156 100644 --- a/crates/openfang-kernel/src/config_reload.rs +++ b/crates/openfang-kernel/src/config_reload.rs @@ -242,7 +242,8 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan { } if field_changed(&old.provider_api_keys, &new.provider_api_keys) { - plan.noop_changes.push("provider_api_keys changed (takes effect on next driver init)".to_string()); + plan.noop_changes + .push("provider_api_keys changed (takes effect on next driver init)".to_string()); } // ----- No-op fields ----- @@ -415,7 +416,10 @@ mod tests { let mut b = default_cfg(); b.default_model.model = "gpt-4".to_string(); let plan = build_reload_plan(&a, &b); - assert!(!plan.restart_required, "default_model should be hot-reloadable"); + assert!( + !plan.restart_required, + "default_model should be hot-reloadable" + ); assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel)); } diff --git a/crates/openfang-kernel/src/cron.rs b/crates/openfang-kernel/src/cron.rs index 7bcf0bd7..13e37d9c 100644 --- a/crates/openfang-kernel/src/cron.rs +++ b/crates/openfang-kernel/src/cron.rs @@ -234,9 +234,12 @@ impl CronScheduler { if !entry.value().job.enabled { // Re-enable jobs that were auto-disabled due to the stale // agent ID causing repeated failures. - if entry.value().last_status.as_deref().is_some_and(|s| { - s.contains("not found") || s.contains("No such agent") - }) { + if entry + .value() + .last_status + .as_deref() + .is_some_and(|s| s.contains("not found") || s.contains("No such agent")) + { entry.value_mut().job.enabled = true; entry.value_mut().job.next_run = Some(compute_next_run(&entry.value().job.schedule)); @@ -348,8 +351,7 @@ impl CronScheduler { ); meta.job.enabled = false; } else { - meta.job.next_run = - Some(compute_next_run_after(&meta.job.schedule, Utc::now())); + meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now())); } } } @@ -1094,7 +1096,10 @@ mod tests { sched.reassign_agent_jobs(old_agent, new_agent); let meta = sched.get_meta(id).unwrap(); - assert!(meta.job.enabled, "Job should be re-enabled after reassignment"); + assert!( + meta.job.enabled, + "Job should be re-enabled after reassignment" + ); assert_eq!(meta.consecutive_errors, 0); assert_eq!(meta.job.agent_id, new_agent); } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index baa5e913..5e582d04 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -29,7 +29,7 @@ use openfang_runtime::sandbox::{SandboxConfig, WasmSandbox}; use openfang_runtime::tool_runner::builtin_tool_definitions; use openfang_types::agent::*; use openfang_types::capability::Capability; -use openfang_types::config::KernelConfig; +use openfang_types::config::{KernelConfig, OutputFormat}; use openfang_types::error::OpenFangError; use openfang_types::event::*; use openfang_types::memory::Memory; @@ -150,9 +150,11 @@ pub struct OpenFangKernel { /// WhatsApp Web gateway child process PID (for shutdown cleanup). pub whatsapp_gateway_pid: Arc>>, /// Channel adapters registered at bridge startup (for proactive `channel_send` tool). - pub channel_adapters: dashmap::DashMap>, + pub channel_adapters: + dashmap::DashMap>, /// Hot-reloadable default model override (set via config hot-reload, read at agent spawn). - pub default_model_override: std::sync::RwLock>, + pub default_model_override: + std::sync::RwLock>, /// Per-agent message locks — serializes LLM calls for the same agent to prevent /// session corruption when multiple messages arrive concurrently (e.g. rapid voice /// messages via Telegram). Different agents can still run in parallel. @@ -583,10 +585,7 @@ impl OpenFangKernel { None }; let dotenv_path = config.home_dir.join(".env"); - openfang_extensions::credentials::CredentialResolver::new( - vault, - Some(&dotenv_path), - ) + openfang_extensions::credentials::CredentialResolver::new(vault, Some(&dotenv_path)) }; // Create LLM driver. @@ -605,11 +604,12 @@ impl OpenFangKernel { let driver_config = DriverConfig { provider: config.default_model.provider.clone(), api_key: default_api_key, - base_url: config - .default_model - .base_url - .clone() - .or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()), + base_url: config.default_model.base_url.clone().or_else(|| { + config + .provider_urls + .get(&config.default_model.provider) + .cloned() + }), skip_permissions: true, }; // Primary driver failure is non-fatal: the dashboard should remain accessible @@ -629,7 +629,9 @@ impl OpenFangKernel { if let Some((provider, model, env_var)) = drivers::detect_available_provider() { let auto_config = DriverConfig { provider: provider.to_string(), - api_key: credential_resolver.resolve(env_var).map(|z: zeroize::Zeroizing| z.to_string()), + api_key: credential_resolver + .resolve(env_var) + .map(|z: zeroize::Zeroizing| z.to_string()), base_url: config.provider_urls.get(provider).cloned(), skip_permissions: true, }; @@ -668,7 +670,9 @@ impl OpenFangKernel { } else { config.resolve_api_key_env(&fb.provider) }; - credential_resolver.resolve(&env_var).map(|z: zeroize::Zeroizing| z.to_string()) + credential_resolver + .resolve(&env_var) + .map(|z: zeroize::Zeroizing| z.to_string()) }; let fb_config = DriverConfig { provider: fb.provider.clone(), @@ -701,9 +705,7 @@ impl OpenFangKernel { // Use the chain, or create a stub driver if everything failed let driver: Arc = if driver_chain.len() > 1 { - Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models( - model_chain, - )) + Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models(model_chain)) } else if let Some(single) = driver_chain.into_iter().next() { single } else { @@ -861,7 +863,10 @@ impl OpenFangKernel { configured_model.as_str() }; let api_key_env = config.memory.embedding_api_key_env.as_deref().unwrap_or(""); - let custom_url = config.provider_urls.get(provider.as_str()).map(|s| s.as_str()); + let custom_url = config + .provider_urls + .get(provider.as_str()) + .map(|s| s.as_str()); match create_embedding_driver(provider, model, api_key_env, custom_url) { Ok(d) => { info!(provider = %provider, model = %model, "Embedding driver configured from memory config"); @@ -1071,11 +1076,16 @@ impl OpenFangKernel { Ok(disk_manifest) => { // Compare key fields to detect changes let changed = disk_manifest.name != entry.manifest.name - || disk_manifest.description != entry.manifest.description - || disk_manifest.model.system_prompt != entry.manifest.model.system_prompt - || disk_manifest.model.provider != entry.manifest.model.provider - || disk_manifest.model.model != entry.manifest.model.model - || disk_manifest.capabilities.tools != entry.manifest.capabilities.tools; + || disk_manifest.description + != entry.manifest.description + || disk_manifest.model.system_prompt + != entry.manifest.model.system_prompt + || disk_manifest.model.provider + != entry.manifest.model.provider + || disk_manifest.model.model + != entry.manifest.model.model + || disk_manifest.capabilities.tools + != entry.manifest.capabilities.tools; if changed { info!( agent = %name, @@ -1156,10 +1166,15 @@ impl OpenFangKernel { restored_entry.manifest.model.model = dm.model.clone(); } if !dm.api_key_env.is_empty() { - restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone()); + restored_entry.manifest.model.api_key_env = + Some(dm.api_key_env.clone()); } if dm.base_url.is_some() { - restored_entry.manifest.model.base_url.clone_from(&dm.base_url); + restored_entry + .manifest + .model + .base_url + .clone_from(&dm.base_url); } } } @@ -1300,9 +1315,10 @@ impl OpenFangKernel { apply_budget_defaults(&self.config.budget, &mut manifest.resources); // Create workspace directory for the agent (name-based, so SOUL.md survives recreation) - let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| { - self.config.effective_workspaces_dir().join(&name) - }); + let workspace_dir = manifest + .workspace + .clone() + .unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name)); ensure_workspace(&workspace_dir)?; if manifest.generate_identity_files { generate_identity_files(&workspace_dir, &manifest); @@ -1441,8 +1457,15 @@ impl OpenFangKernel { .get() .and_then(|w| w.upgrade()) .map(|arc| arc as Arc); - self.send_message_with_handle_and_blocks(agent_id, message, handle, Some(blocks), None, None) - .await + self.send_message_with_handle_and_blocks( + agent_id, + message, + handle, + Some(blocks), + None, + None, + ) + .await } /// Send a message with an optional kernel handle for inter-agent tools. @@ -1454,8 +1477,15 @@ impl OpenFangKernel { sender_id: Option, sender_name: Option, ) -> KernelResult { - self.send_message_with_handle_and_blocks(agent_id, message, kernel_handle, None, sender_id, sender_name) - .await + self.send_message_with_handle_and_blocks( + agent_id, + message, + kernel_handle, + None, + sender_id, + sender_name, + ) + .await } /// Send a message with optional content blocks and an optional kernel handle. @@ -1504,8 +1534,16 @@ impl OpenFangKernel { self.execute_python_agent(&entry, agent_id, message).await } else { // Default: LLM agent loop (builtin:chat or any unrecognized module) - self.execute_llm_agent(&entry, agent_id, message, kernel_handle, content_blocks, sender_id, sender_name) - .await + self.execute_llm_agent( + &entry, + agent_id, + message, + kernel_handle, + content_blocks, + sender_id, + sender_name, + ) + .await }; match result { @@ -1797,7 +1835,11 @@ impl OpenFangKernel { None }, peer_agents, - current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()), + current_date: Some( + chrono::Local::now() + .format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)") + .to_string(), + ), sender_id, sender_name, }; @@ -2337,7 +2379,11 @@ impl OpenFangKernel { None }, peer_agents, - current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()), + current_date: Some( + chrono::Local::now() + .format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)") + .to_string(), + ), sender_id, sender_name, }; @@ -2791,22 +2837,17 @@ impl OpenFangKernel { .get(agent_id) .map(|e| e.manifest.model.base_url.is_some()) .unwrap_or(false); - if has_custom_url { // Keep the current provider — don't let auto-detection override // a deliberately configured custom endpoint. None } else { // No custom base_url: safe to auto-detect from catalog / model name - let resolved_provider = self - .model_catalog - .read() - .ok() - .and_then(|catalog| { - catalog - .find_model(model) - .map(|entry| entry.provider.clone()) - }); + let resolved_provider = self.model_catalog.read().ok().and_then(|catalog| { + catalog + .find_model(model) + .map(|entry| entry.provider.clone()) + }); resolved_provider.or_else(|| infer_provider_from_model(model)) } }; @@ -3235,10 +3276,6 @@ impl OpenFangKernel { } else { None }, - // Redundant safety: tool_allowlist mirrors capabilities.tools above. - // available_tools() already filters by capabilities.tools, but this - // provides defense-in-depth. - tool_allowlist: def.tools.clone(), tool_blocklist: Vec::new(), // Custom profile avoids ToolProfile-based expansion overriding the // explicit tool list. @@ -3289,7 +3326,11 @@ impl OpenFangKernel { // If an agent with this hand's name already exists, remove it first. // Save triggers before kill so they can be restored under the new ID // (issue #519 — triggers were lost on agent restart). - let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name); + let existing = self + .registry + .list() + .into_iter() + .find(|e| e.name == def.agent.name); let old_agent_id = existing.as_ref().map(|e| e.id); let saved_triggers = old_agent_id .map(|id| self.triggers.take_agent_triggers(id)) @@ -3735,7 +3776,9 @@ impl OpenFangKernel { "Reassigned cron jobs after restart" ); if let Err(e) = self.cron_scheduler.persist() { - warn!("Failed to persist cron jobs after hand restore: {e}"); + warn!( + "Failed to persist cron jobs after hand restore: {e}" + ); } } // Reassign triggers (#519). Currently a no-op on @@ -3761,14 +3804,17 @@ impl OpenFangKernel { } let agents = self.registry.list(); - let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> = - Vec::new(); + let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> = Vec::new(); for entry in &agents { if matches!(entry.manifest.schedule, ScheduleMode::Reactive) { continue; } - bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone())); + bg_agents.push(( + entry.id, + entry.name.clone(), + entry.manifest.schedule.clone(), + )); } if !bg_agents.is_empty() { @@ -3993,10 +4039,18 @@ impl OpenFangKernel { let timeout_s = timeout_secs.unwrap_or(120); let timeout = std::time::Duration::from_secs(timeout_s); let delivery = job.delivery.clone(); - let kh: std::sync::Arc = kernel.clone(); + let kh: std::sync::Arc< + dyn openfang_runtime::kernel_handle::KernelHandle, + > = kernel.clone(); match tokio::time::timeout( timeout, - kernel.send_message_with_handle(agent_id, message, Some(kh), None, None), + kernel.send_message_with_handle( + agent_id, + message, + Some(kh), + None, + None, + ), ) .await { @@ -4049,10 +4103,13 @@ impl OpenFangKernel { Ok(uuid) => crate::workflow::WorkflowId(uuid), Err(_) => { let all_wfs = kernel.workflows.list_workflows().await; - if let Some(wf) = all_wfs.iter().find(|w| w.name == *workflow_id) { + if let Some(wf) = + all_wfs.iter().find(|w| w.name == *workflow_id) + { wf.id } else { - let err_msg = format!("workflow not found: {workflow_id}"); + let err_msg = + format!("workflow not found: {workflow_id}"); tracing::warn!(job = %job_name, %err_msg); kernel.cron_scheduler.record_failure(job_id, &err_msg); continue; @@ -4068,10 +4125,7 @@ impl OpenFangKernel { { Ok(Ok((_run_id, output))) => { match cron_deliver_response( - &kernel, - agent_id, - &output, - &delivery, + &kernel, agent_id, &output, &delivery, ) .await { @@ -4312,10 +4366,9 @@ impl OpenFangKernel { } // Check cooldown - if !recovery_tracker.can_attempt( - status.agent_id, - config.recovery_cooldown_secs, - ) { + if !recovery_tracker + .can_attempt(status.agent_id, config.recovery_cooldown_secs) + { debug!( agent = %status.name, "Recovery cooldown active, skipping" @@ -4627,8 +4680,10 @@ impl OpenFangKernel { // If fallback models are configured, wrap in FallbackDriver if !manifest.fallback_models.is_empty() { // Primary driver uses the agent's own model name (already set in request) - let mut chain: Vec<(std::sync::Arc, String)> = - vec![(primary.clone(), String::new())]; + let mut chain: Vec<( + std::sync::Arc, + String, + )> = vec![(primary.clone(), String::new())]; for fb in &manifest.fallback_models { let fb_api_key = if let Some(env) = &fb.api_key_env { std::env::var(env).ok() @@ -5009,8 +5064,8 @@ impl OpenFangKernel { // Check if the agent has unrestricted tool access: // - capabilities.tools is empty (not specified → all tools) // - capabilities.tools contains "*" (explicit wildcard) - let tools_unrestricted = declared_tools.is_empty() - || declared_tools.iter().any(|t| t == "*"); + let tools_unrestricted = + declared_tools.is_empty() || declared_tools.iter().any(|t| t == "*"); // Step 1: Filter builtin tools. // Priority: declared tools > ToolProfile > all builtins. @@ -5057,9 +5112,7 @@ impl OpenFangKernel { }; for skill_tool in skill_tools { // If agent declares specific tools, only include matching skill tools - if !tools_unrestricted - && !declared_tools.iter().any(|d| d == &skill_tool.name) - { + if !tools_unrestricted && !declared_tools.iter().any(|d| d == &skill_tool.name) { continue; } all_tools.push(ToolDefinition { @@ -5102,7 +5155,12 @@ impl OpenFangKernel { // These are separate from capabilities.tools and act as additional filters. let (tool_allowlist, tool_blocklist) = entry .as_ref() - .map(|e| (e.manifest.tool_allowlist.clone(), e.manifest.tool_blocklist.clone())) + .map(|e| { + ( + e.manifest.tool_allowlist.clone(), + e.manifest.tool_blocklist.clone(), + ) + }) .unwrap_or_default(); if !tool_allowlist.is_empty() { @@ -5244,7 +5302,8 @@ impl OpenFangKernel { tool_names.join(", ") )); } - summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n"); + summary + .push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n"); // Add filesystem-specific guidance when a filesystem MCP server is connected let has_filesystem = servers.keys().any(|s| s.contains("filesystem")); if has_filesystem { @@ -5450,8 +5509,8 @@ fn infer_provider_from_model(model: &str) -> Option { "minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral" | "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity" | "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex" - | "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" | "moonshot" - | "openrouter" | "volcengine" | "doubao" | "dashscope" => { + | "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" + | "moonshot" | "openrouter" | "volcengine" | "doubao" | "dashscope" => { return Some(prefix.to_string()); } // "kimi" is a brand alias for moonshot @@ -5468,16 +5527,26 @@ fn infer_provider_from_model(model: &str) -> Option { Some("gemini".to_string()) } else if lower.starts_with("claude") { Some("anthropic".to_string()) - } else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4") { + } else if lower.starts_with("gpt") + || lower.starts_with("o1") + || lower.starts_with("o3") + || lower.starts_with("o4") + { Some("openai".to_string()) - } else if lower.starts_with("llama") || lower.starts_with("mixtral") || lower.starts_with("qwen") { + } else if lower.starts_with("llama") + || lower.starts_with("mixtral") + || lower.starts_with("qwen") + { // These could be on multiple providers; don't infer None } else if lower.starts_with("grok") { Some("xai".to_string()) } else if lower.starts_with("deepseek") { Some("deepseek".to_string()) - } else if lower.starts_with("mistral") || lower.starts_with("codestral") || lower.starts_with("pixtral") { + } else if lower.starts_with("mistral") + || lower.starts_with("codestral") + || lower.starts_with("pixtral") + { Some("mistral".to_string()) } else if lower.starts_with("command") || lower.starts_with("embed-") { Some("cohere".to_string()) @@ -5581,15 +5650,10 @@ async fn cron_deliver_response( "response": response, "timestamp": chrono::Utc::now().to_rfc3339(), }); - let resp = client - .post(url) - .json(&payload) - .send() - .await - .map_err(|e| { - tracing::warn!(error = %e, "Cron webhook delivery failed"); - format!("webhook delivery failed: {e}") - })?; + let resp = client.post(url).json(&payload).send().await.map_err(|e| { + tracing::warn!(error = %e, "Cron webhook delivery failed"); + format!("webhook delivery failed: {e}") + })?; tracing::debug!(status = %resp.status(), "Cron webhook delivered"); Ok(()) } @@ -6041,8 +6105,20 @@ impl KernelHandle for OpenFangKernel { async fn get_channel_default_recipient(&self, channel: &str) -> Option { match channel { - "telegram" => self.config.channels.telegram.as_ref()?.default_chat_id.clone(), - "discord" => self.config.channels.discord.as_ref()?.default_channel_id.clone(), + "telegram" => self + .config + .channels + .telegram + .as_ref()? + .default_chat_id + .clone(), + "discord" => self + .config + .channels + .discord + .as_ref()? + .default_channel_id + .clone(), _ => None, } } @@ -6076,7 +6152,20 @@ impl KernelHandle for OpenFangKernel { openfang_user: None, }; - let content = openfang_channels::types::ChannelContent::Text(message.to_string()); + let formatted = if channel == "wecom" { + let output_format = self + .config + .channels + .wecom + .as_ref() + .and_then(|c| c.overrides.output_format) + .unwrap_or(OutputFormat::PlainText); + openfang_channels::formatter::format_for_wecom(message, output_format) + } else { + message.to_string() + }; + + let content = openfang_channels::types::ChannelContent::Text(formatted); if let Some(tid) = thread_id { adapter @@ -6135,7 +6224,9 @@ impl KernelHandle for OpenFangKernel { filename: filename.unwrap_or("file").to_string(), }, _ => { - return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'.")); + return Err(format!( + "Unsupported media type: '{media_type}'. Use 'image' or 'file'." + )); } }; @@ -6151,7 +6242,10 @@ impl KernelHandle for OpenFangKernel { .map_err(|e| format!("Channel media send failed: {e}"))?; } - Ok(format!("{} sent to {} via {}", media_type, recipient, channel)) + Ok(format!( + "{} sent to {} via {}", + media_type, recipient, channel + )) } async fn send_channel_file_data( @@ -6203,7 +6297,10 @@ impl KernelHandle for OpenFangKernel { .map_err(|e| format!("Channel file send failed: {e}"))?; } - Ok(format!("File '{}' sent to {} via {}", filename, recipient, channel)) + Ok(format!( + "File '{}' sent to {} via {}", + filename, recipient, channel + )) } async fn spawn_agent_checked( @@ -6518,4 +6615,38 @@ mod tests { .iter() .any(|c| matches!(c, Capability::ToolInvoke(name) if name == "shell_exec"))); } + + #[test] + fn test_hand_activation_does_not_seed_runtime_tool_filters() { + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-kernel-hand-test"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + ..KernelConfig::default() + }; + + let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot"); + let instance = kernel + .activate_hand("browser", HashMap::new()) + .expect("browser hand should activate"); + let agent_id = instance.agent_id.expect("browser hand agent id"); + let entry = kernel + .registry + .get(agent_id) + .expect("browser hand agent entry"); + + assert!( + entry.manifest.tool_allowlist.is_empty(), + "hand activation should leave the runtime tool allowlist empty so skill/MCP tools remain visible" + ); + assert!( + entry.manifest.tool_blocklist.is_empty(), + "hand activation should not set a runtime blocklist by default" + ); + + kernel.shutdown(); + } } diff --git a/crates/openfang-kernel/src/scheduler.rs b/crates/openfang-kernel/src/scheduler.rs index c75fb869..b7ee99c2 100644 --- a/crates/openfang-kernel/src/scheduler.rs +++ b/crates/openfang-kernel/src/scheduler.rs @@ -88,8 +88,7 @@ impl AgentScheduler { // Reset the window if an hour has passed tracker.reset_if_expired(); - if quota.max_llm_tokens_per_hour > 0 - && tracker.total_tokens > quota.max_llm_tokens_per_hour + if quota.max_llm_tokens_per_hour > 0 && tracker.total_tokens > quota.max_llm_tokens_per_hour { return Err(OpenFangError::QuotaExceeded(format!( "Token limit exceeded: {} / {}", diff --git a/crates/openfang-kernel/src/triggers.rs b/crates/openfang-kernel/src/triggers.rs index 32eaa93b..9fe28ee5 100644 --- a/crates/openfang-kernel/src/triggers.rs +++ b/crates/openfang-kernel/src/triggers.rs @@ -215,11 +215,7 @@ impl TriggerEngine { /// on each trigger and moves the index entry. /// /// Returns the number of triggers reassigned. - pub fn reassign_agent_triggers( - &self, - old_agent_id: AgentId, - new_agent_id: AgentId, - ) -> usize { + pub fn reassign_agent_triggers(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize { let trigger_ids = self .agent_triggers .remove(&old_agent_id) @@ -702,7 +698,10 @@ mod tests { matches!(&t.pattern, TriggerPattern::ContentMatch { substring } if substring == "deploy") && t.max_fires == 5 }); - assert!(has_content_match, "ContentMatch trigger with max_fires=5 should be preserved"); + assert!( + has_content_match, + "ContentMatch trigger with max_fires=5 should be preserved" + ); } #[test] @@ -727,6 +726,9 @@ mod tests { engine.restore_triggers(new_agent, taken); let restored = engine.list_agent_triggers(new_agent); assert_eq!(restored.len(), 1); - assert!(!restored[0].enabled, "Disabled state should survive take/restore"); + assert!( + !restored[0].enabled, + "Disabled state should survive take/restore" + ); } } diff --git a/crates/openfang-kernel/src/whatsapp_gateway.rs b/crates/openfang-kernel/src/whatsapp_gateway.rs index a4214a74..17b3aa01 100644 --- a/crates/openfang-kernel/src/whatsapp_gateway.rs +++ b/crates/openfang-kernel/src/whatsapp_gateway.rs @@ -10,10 +10,8 @@ use std::sync::Arc; use tracing::{info, warn}; /// Gateway source files embedded at compile time. -const GATEWAY_INDEX_JS: &str = - include_str!("../../../packages/whatsapp-gateway/index.js"); -const GATEWAY_PACKAGE_JSON: &str = - include_str!("../../../packages/whatsapp-gateway/package.json"); +const GATEWAY_INDEX_JS: &str = include_str!("../../../packages/whatsapp-gateway/index.js"); +const GATEWAY_PACKAGE_JSON: &str = include_str!("../../../packages/whatsapp-gateway/package.json"); /// Default port for the WhatsApp Web gateway. const DEFAULT_GATEWAY_PORT: u16 = 3009; @@ -69,8 +67,8 @@ async fn ensure_gateway_installed() -> Result { let package_path = dir.join("package.json"); // Write files only if content changed (avoids unnecessary npm install) - let index_changed = - write_if_changed(&index_path, GATEWAY_INDEX_JS).map_err(|e| format!("Write index.js: {e}"))?; + let index_changed = write_if_changed(&index_path, GATEWAY_INDEX_JS) + .map_err(|e| format!("Write index.js: {e}"))?; let package_changed = write_if_changed(&package_path, GATEWAY_PACKAGE_JSON) .map_err(|e| format!("Write package.json: {e}"))?; @@ -164,7 +162,10 @@ pub async fn start_whatsapp_gateway(kernel: &Arc) .to_string(); // Auto-set the env var so the rest of the system finds the gateway - std::env::set_var("WHATSAPP_WEB_GATEWAY_URL", format!("http://127.0.0.1:{port}")); + std::env::set_var( + "WHATSAPP_WEB_GATEWAY_URL", + format!("http://127.0.0.1:{port}"), + ); info!("WHATSAPP_WEB_GATEWAY_URL set to http://127.0.0.1:{port}"); // Spawn with crash monitoring @@ -247,9 +248,7 @@ pub async fn start_whatsapp_gateway(kernel: &Arc) restarts += 1; if restarts >= MAX_RESTARTS { - warn!( - "WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up" - ); + warn!("WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up"); return; } diff --git a/crates/openfang-memory/src/session.rs b/crates/openfang-memory/src/session.rs index 74862c37..a4403534 100644 --- a/crates/openfang-memory/src/session.rs +++ b/crates/openfang-memory/src/session.rs @@ -560,7 +560,9 @@ impl SessionStore { ContentBlock::Text { text, .. } => { text_parts.push(text.clone()); } - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { tool_parts.push(serde_json::json!({ "type": "tool_use", "id": id, diff --git a/crates/openfang-memory/src/structured.rs b/crates/openfang-memory/src/structured.rs index d71b6bc9..fb7b45f5 100644 --- a/crates/openfang-memory/src/structured.rs +++ b/crates/openfang-memory/src/structured.rs @@ -99,13 +99,12 @@ impl StructuredStore { let mut pairs = Vec::new(); for row in rows { let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?; - let value: serde_json::Value = serde_json::from_slice(&blob) - .unwrap_or_else(|_| { - // Fallback: try as UTF-8 string - String::from_utf8(blob) - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null) - }); + let value: serde_json::Value = serde_json::from_slice(&blob).unwrap_or_else(|_| { + // Fallback: try as UTF-8 string + String::from_utf8(blob) + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null) + }); pairs.push((key, value)); } Ok(pairs) @@ -192,7 +191,14 @@ impl StructuredStore { } else { None }; - Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str)) + Ok(( + name, + manifest_blob, + state_str, + created_str, + session_id_str, + identity_str, + )) }); match result { @@ -307,13 +313,14 @@ impl StructuredStore { let mut repair_queue: Vec<(String, Vec, String)> = Vec::new(); for row in rows { - let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = match row { - Ok(r) => r, - Err(e) => { - tracing::warn!("Skipping agent row with read error: {e}"); - continue; - } - }; + let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = + match row { + Ok(r) => r, + Err(e) => { + tracing::warn!("Skipping agent row with read error: {e}"); + continue; + } + }; // Deduplicate: skip agents with names we've already seen let name_lower = name.to_lowercase(); diff --git a/crates/openfang-migrate/src/openclaw.rs b/crates/openfang-migrate/src/openclaw.rs index 891186e1..f7965789 100644 --- a/crates/openfang-migrate/src/openclaw.rs +++ b/crates/openfang-migrate/src/openclaw.rs @@ -125,17 +125,21 @@ struct OpenClawAgentTools { /// Extract a profile name from a Value (string or {name: "..."} object). fn extract_profile(val: &serde_json::Value) -> Option { - val.as_str() - .map(|s| s.to_string()) - .or_else(|| val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string())) + val.as_str().map(|s| s.to_string()).or_else(|| { + val.get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) } /// Extract a list of strings from a Value (array of strings, single string, or object keys). fn extract_string_list(val: &serde_json::Value) -> Vec { match val { - serde_json::Value::Array(arr) => { - arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect() - } + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect(), serde_json::Value::String(s) => vec![s.clone()], serde_json::Value::Object(map) => map.keys().cloned().collect(), _ => vec![], @@ -517,8 +521,7 @@ fn build_channel_table( let allow_list = allow_from.map(extract_string_list).unwrap_or_default(); // Add overrides sub-table if any policy is set - let has_overrides = - dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty(); + let has_overrides = dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty(); if has_overrides { let mut overrides = toml::map::Map::new(); diff --git a/crates/openfang-runtime/src/a2a.rs b/crates/openfang-runtime/src/a2a.rs index c52e2d69..a11a4080 100644 --- a/crates/openfang-runtime/src/a2a.rs +++ b/crates/openfang-runtime/src/a2a.rs @@ -583,8 +583,7 @@ mod tests { assert_eq!(wrapper.state(), &A2aTaskStatus::Completed); // Test with a message payload - let json_with_msg = - r#"{"state":"working","message":{"text":"Processing..."}}"#; + let json_with_msg = r#"{"state":"working","message":{"text":"Processing..."}}"#; let wrapper2: A2aTaskStatusWrapper = serde_json::from_str(json_with_msg).unwrap(); assert_eq!(wrapper2, A2aTaskStatus::Working); diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 2a6f4dfe..2fd481d6 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -56,15 +56,9 @@ const TOOL_ERROR_GUIDANCE: &str = "[System: One or more tool calls failed. Failed tools did not produce usable data. Do NOT invent missing results, cite nonexistent search results, or pretend failed tools succeeded. If your next steps depend on a failed tool, either retry with a materially different approach or explain the failure to the user and stop. Do not write files, store memory, or take downstream actions based on failed tool outputs.]"; fn append_tool_error_guidance(tool_result_blocks: &mut Vec) { - let has_tool_error = tool_result_blocks.iter().any(|block| { - matches!( - block, - ContentBlock::ToolResult { - is_error: true, - .. - } - ) - }); + let has_tool_error = tool_result_blocks + .iter() + .any(|block| matches!(block, ContentBlock::ToolResult { is_error: true, .. })); if has_tool_error { tool_result_blocks.push(ContentBlock::Text { text: TOOL_ERROR_GUIDANCE.to_string(), @@ -428,8 +422,8 @@ pub async fn run_agent_loop( // try once more before accepting the empty result. // Triggers on first call OR when input_tokens=0 (silently failed request). if text.trim().is_empty() && response.tool_calls.is_empty() { - let is_silent_failure = response.usage.input_tokens == 0 - && response.usage.output_tokens == 0; + let is_silent_failure = + response.usage.input_tokens == 0 && response.usage.output_tokens == 0; if iteration == 0 || is_silent_failure { warn!( agent = %manifest.name, @@ -744,10 +738,13 @@ pub async fn run_agent_loop( append_tool_error_guidance(&mut tool_result_blocks); // Detect approval denials and inject guidance to prevent infinite retry loops - let denial_count = tool_result_blocks.iter().filter(|b| { - matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } + let denial_count = tool_result_blocks + .iter() + .filter(|b| { + matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } if content.contains("requires human approval and was denied")) - }).count(); + }) + .count(); if denial_count > 0 { tool_result_blocks.push(ContentBlock::Text { text: format!( @@ -764,9 +761,10 @@ pub async fn run_agent_loop( } // Detect tool errors and inject guidance to prevent fabrication - let error_count = tool_result_blocks.iter().filter(|b| { - matches!(b, ContentBlock::ToolResult { is_error: true, .. }) - }).count(); + let error_count = tool_result_blocks + .iter() + .filter(|b| matches!(b, ContentBlock::ToolResult { is_error: true, .. })) + .count(); let non_denial_errors = error_count.saturating_sub(denial_count); if non_denial_errors > 0 { tool_result_blocks.push(ContentBlock::Text { @@ -1411,8 +1409,8 @@ pub async fn run_agent_loop_streaming( // try once more before accepting the empty result. // Triggers on first call OR when input_tokens=0 (silently failed request). if text.trim().is_empty() && response.tool_calls.is_empty() { - let is_silent_failure = response.usage.input_tokens == 0 - && response.usage.output_tokens == 0; + let is_silent_failure = + response.usage.input_tokens == 0 && response.usage.output_tokens == 0; if iteration == 0 || is_silent_failure { warn!( agent = %manifest.name, @@ -1736,10 +1734,13 @@ pub async fn run_agent_loop_streaming( append_tool_error_guidance(&mut tool_result_blocks); // Detect approval denials and inject guidance to prevent infinite retry loops - let denial_count = tool_result_blocks.iter().filter(|b| { - matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } + let denial_count = tool_result_blocks + .iter() + .filter(|b| { + matches!(b, ContentBlock::ToolResult { content, is_error: true, .. } if content.contains("requires human approval and was denied")) - }).count(); + }) + .count(); if denial_count > 0 { tool_result_blocks.push(ContentBlock::Text { text: format!( @@ -1756,9 +1757,10 @@ pub async fn run_agent_loop_streaming( } // Detect tool errors and inject guidance to prevent fabrication - let error_count = tool_result_blocks.iter().filter(|b| { - matches!(b, ContentBlock::ToolResult { is_error: true, .. }) - }).count(); + let error_count = tool_result_blocks + .iter() + .filter(|b| matches!(b, ContentBlock::ToolResult { is_error: true, .. })) + .count(); let non_denial_errors = error_count.saturating_sub(denial_count); if non_denial_errors > 0 { tool_result_blocks.push(ContentBlock::Text { @@ -2156,9 +2158,7 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve } // Custom arrow syntax: {tool => "name", args => {--key "value"}} - if let Some((tool_name, input)) = - parse_arrow_syntax_tool_call(inner, &tool_names) - { + if let Some((tool_name, input)) = parse_arrow_syntax_tool_call(inner, &tool_names) { if !calls .iter() .any(|c| c.name == tool_name && c.input == input) @@ -2213,15 +2213,17 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve { use regex_lite::Regex; // Match both self-closing and - let re = Regex::new( - r#""# - ).unwrap(); + let re = + Regex::new(r#""#).unwrap(); for caps in re.captures_iter(text) { let tool_name = caps.get(1).unwrap().as_str(); let raw_params = caps.get(2).unwrap().as_str(); if !tool_names.contains(&tool_name) { - warn!(tool = tool_name, "XML-attribute tool call for unknown tool — skipping"); + warn!( + tool = tool_name, + "XML-attribute tool call for unknown tool — skipping" + ); continue; } @@ -2241,11 +2243,17 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve } }; - if calls.iter().any(|c| c.name == tool_name && c.input == input) { + if calls + .iter() + .any(|c| c.name == tool_name && c.input == input) + { continue; } - info!(tool = tool_name, "Recovered XML-attribute tool call → synthetic ToolUse"); + info!( + tool = tool_name, + "Recovered XML-attribute tool call → synthetic ToolUse" + ); calls.push(ToolCall { id: format!("recovered_{}", uuid::Uuid::new_v4()), name: tool_name.to_string(), @@ -2269,8 +2277,14 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve search_from = after_tag + close_offset + close_tag.len(); if let Some((tool_name, input)) = parse_json_tool_call_object(inner, &tool_names) { - if !calls.iter().any(|c| c.name == tool_name && c.input == input) { - info!(tool = tool_name.as_str(), "Recovered tool call from <|plugin|> block"); + if !calls + .iter() + .any(|c| c.name == tool_name && c.input == input) + { + info!( + tool = tool_name.as_str(), + "Recovered tool call from <|plugin|> block" + ); calls.push(ToolCall { id: format!("recovered_{}", uuid::Uuid::new_v4()), name: tool_name, @@ -2286,17 +2300,30 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve let mut i = 0; while i < lines.len() { let line = lines[i].trim(); - if let Some(tool_part) = line.strip_prefix("Action:").or_else(|| line.strip_prefix("action:")) { + if let Some(tool_part) = line + .strip_prefix("Action:") + .or_else(|| line.strip_prefix("action:")) + { let tool_name = tool_part.trim(); if tool_names.contains(&tool_name) { // Look for "Action Input:" on the next line(s) if i + 1 < lines.len() { let next = lines[i + 1].trim(); - if let Some(json_part) = next.strip_prefix("Action Input:").or_else(|| next.strip_prefix("action input:")).or_else(|| next.strip_prefix("action_input:")) { + if let Some(json_part) = next + .strip_prefix("Action Input:") + .or_else(|| next.strip_prefix("action input:")) + .or_else(|| next.strip_prefix("action_input:")) + { let json_str = json_part.trim(); if let Ok(input) = serde_json::from_str::(json_str) { - if !calls.iter().any(|c| c.name == tool_name && c.input == input) { - info!(tool = tool_name, "Recovered tool call from Action/Action Input pattern"); + if !calls + .iter() + .any(|c| c.name == tool_name && c.input == input) + { + info!( + tool = tool_name, + "Recovered tool call from Action/Action Input pattern" + ); calls.push(ToolCall { id: format!("recovered_{}", uuid::Uuid::new_v4()), name: tool_name.to_string(), @@ -2332,8 +2359,14 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve continue; } if let Ok(input) = serde_json::from_str::(json_line) { - if !calls.iter().any(|c| c.name == name_line && c.input == input) { - info!(tool = name_line, "Recovered tool call from name+JSON line pair"); + if !calls + .iter() + .any(|c| c.name == name_line && c.input == input) + { + info!( + tool = name_line, + "Recovered tool call from name+JSON line pair" + ); calls.push(ToolCall { id: format!("recovered_{}", uuid::Uuid::new_v4()), name: name_line.to_string(), @@ -2358,8 +2391,14 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve search_from = after_tag + close_offset + "".len(); if let Some((tool_name, input)) = parse_json_tool_call_object(inner, &tool_names) { - if !calls.iter().any(|c| c.name == tool_name && c.input == input) { - info!(tool = tool_name.as_str(), "Recovered tool call from block"); + if !calls + .iter() + .any(|c| c.name == tool_name && c.input == input) + { + info!( + tool = tool_name.as_str(), + "Recovered tool call from block" + ); calls.push(ToolCall { id: format!("recovered_{}", uuid::Uuid::new_v4()), name: tool_name, @@ -2903,11 +2942,13 @@ mod tests { .await .expect("Loop should complete without error"); - let guidance_seen = session.messages.iter().any(|msg| match &msg.content { + let guidance_seen = session.messages.iter().any(|msg| { + match &msg.content { MessageContent::Blocks(blocks) => blocks.iter().any(|block| { matches!(block, ContentBlock::Text { text, .. } if text == TOOL_ERROR_GUIDANCE) }), _ => false, + } }); assert!( @@ -3618,7 +3659,8 @@ mod tests { input_schema: serde_json::json!({}), }]; // Same call in both function tag and tool tag — should only appear once - let text = r#"{"command":"ls"} exec{"command":"ls"}"#; + let text = + r#"{"command":"ls"} exec{"command":"ls"}"#; let calls = recover_text_tool_calls(text, &tools); assert_eq!(calls.len(), 1); } @@ -3815,7 +3857,8 @@ mod tests { description: "Execute".into(), input_schema: serde_json::json!({}), }]; - let text = "I'll run that: {\"name\": \"shell_exec\", \"arguments\": {\"command\": \"ls -la\"}}"; + let text = + "I'll run that: {\"name\": \"shell_exec\", \"arguments\": {\"command\": \"ls -la\"}}"; let calls = recover_text_tool_calls(text, &tools); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "shell_exec"); @@ -3911,7 +3954,8 @@ mod tests { description: "Search".into(), input_schema: serde_json::json!({}), }]; - let text = "<|plugin|>\n{\"name\": \"hack\", \"arguments\": {\"cmd\": \"rm\"}}\n<|endofblock|>"; + let text = + "<|plugin|>\n{\"name\": \"hack\", \"arguments\": {\"cmd\": \"rm\"}}\n<|endofblock|>"; let calls = recover_text_tool_calls(text, &tools); assert!(calls.is_empty()); } @@ -3981,7 +4025,8 @@ mod tests { description: "Search".into(), input_schema: serde_json::json!({}), }]; - let text = "{\"name\": \"web_search\", \"arguments\": {\"query\": \"test\"}}"; + let text = + "{\"name\": \"web_search\", \"arguments\": {\"query\": \"test\"}}"; let calls = recover_text_tool_calls(text, &tools); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "web_search"); @@ -4049,10 +4094,8 @@ mod tests { #[test] fn test_parse_json_tool_call_object_unknown_tool() { let tool_names = vec!["shell_exec"]; - let result = parse_json_tool_call_object( - "{\"name\": \"unknown\", \"arguments\": {}}", - &tool_names, - ); + let result = + parse_json_tool_call_object("{\"name\": \"unknown\", \"arguments\": {}}", &tool_names); assert!(result.is_none()); } diff --git a/crates/openfang-runtime/src/browser.rs b/crates/openfang-runtime/src/browser.rs index a1607410..1e0c9db1 100644 --- a/crates/openfang-runtime/src/browser.rs +++ b/crates/openfang-runtime/src/browser.rs @@ -141,13 +141,13 @@ impl CdpConnection { if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { if let Some((_, sender)) = pending.remove(&id) { if let Some(error) = json.get("error") { - let msg = error["message"] - .as_str() - .unwrap_or("CDP error") - .to_string(); + let msg = error["message"].as_str().unwrap_or("CDP error").to_string(); let _ = sender.send(Err(msg)); } else { - let result = json.get("result").cloned().unwrap_or(serde_json::Value::Null); + let result = json + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null); let _ = sender.send(Ok(result)); } } @@ -293,9 +293,12 @@ impl BrowserSession { } } - let mut child = cmd - .spawn() - .map_err(|e| format!("Failed to launch Chromium at {}: {e}", chrome_path.display()))?; + let mut child = cmd.spawn().map_err(|e| { + format!( + "Failed to launch Chromium at {}: {e}", + chrome_path.display() + ) + })?; // Parse stderr for the DevTools WebSocket URL let stderr = child.stderr.take().ok_or("No stderr from Chromium")?; @@ -459,7 +462,10 @@ impl BrowserSession { .unwrap_or(val); if parsed["success"].as_bool() == Some(false) { return BrowserResponse::err( - parsed["error"].as_str().unwrap_or("Click failed").to_string(), + parsed["error"] + .as_str() + .unwrap_or("Click failed") + .to_string(), ); } // Wait briefly for any navigation triggered by click @@ -638,7 +644,11 @@ impl BrowserSession { .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or(info); - let content_val = self.cdp.run_js(EXTRACT_CONTENT_JS).await.unwrap_or_default(); + let content_val = self + .cdp + .run_js(EXTRACT_CONTENT_JS) + .await + .unwrap_or_default(); let content_obj: serde_json::Value = content_val .as_str() .and_then(|s| serde_json::from_str(s).ok()) @@ -993,9 +1003,7 @@ pub async fn tool_browser_read_page( mgr: &BrowserManager, agent_id: &str, ) -> Result { - let resp = mgr - .send_command(agent_id, BrowserCommand::ReadPage) - .await?; + let resp = mgr.send_command(agent_id, BrowserCommand::ReadPage).await?; if !resp.success { return Err(resp.error.unwrap_or_else(|| "ReadPage failed".to_string())); } @@ -1335,7 +1343,10 @@ mod tests { #[test] fn test_chromium_candidates_not_empty() { let paths = chromium_candidates(); - assert!(!paths.is_empty(), "Should have platform-specific candidates"); + assert!( + !paths.is_empty(), + "Should have platform-specific candidates" + ); } #[test] diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 85570546..f42f5eb8 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -343,7 +343,11 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S if oversized { let limit = config.max_chunk_chars / 4; let truncated = if s.len() > limit { - format!("{}...[truncated from {} chars]", safe_truncate_str(s, limit), s.len()) + format!( + "{}...[truncated from {} chars]", + safe_truncate_str(s, limit), + s.len() + ) } else { s.clone() }; @@ -431,7 +435,11 @@ async fn summarize_messages( let safe_start = if conversation_text.is_char_boundary(start) { start } else { - conversation_text[start..].char_indices().next().map(|(i, _)| start + i).unwrap_or(conversation_text.len()) + conversation_text[start..] + .char_indices() + .next() + .map(|(i, _)| start + i) + .unwrap_or(conversation_text.len()) }; conversation_text = conversation_text[safe_start..].to_string(); } @@ -564,7 +572,10 @@ async fn summarize_in_chunks( model: model.to_string(), messages: vec![Message { role: Role::User, - content: MessageContent::Blocks(vec![ContentBlock::Text { text: merge_prompt, provider_metadata: None }]), + content: MessageContent::Blocks(vec![ContentBlock::Text { + text: merge_prompt, + provider_metadata: None, + }]), }], tools: vec![], max_tokens: config.max_summary_tokens, diff --git a/crates/openfang-runtime/src/copilot_oauth.rs b/crates/openfang-runtime/src/copilot_oauth.rs index b63d69a2..0fe33360 100644 --- a/crates/openfang-runtime/src/copilot_oauth.rs +++ b/crates/openfang-runtime/src/copilot_oauth.rs @@ -89,10 +89,7 @@ pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus { .header("Accept", "application/json") .form(&[ ("client_id", COPILOT_CLIENT_ID), - ( - "grant_type", - "urn:ietf:params:oauth:grant-type:device_code", - ), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("device_code", device_code), ]) .send() @@ -112,10 +109,7 @@ pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus { return match error { "authorization_pending" => DeviceFlowStatus::Pending, "slow_down" => { - let interval = body - .get("interval") - .and_then(|v| v.as_u64()) - .unwrap_or(10); + let interval = body.get("interval").and_then(|v| v.as_u64()).unwrap_or(10); DeviceFlowStatus::SlowDown { new_interval: interval, } diff --git a/crates/openfang-runtime/src/docker_sandbox.rs b/crates/openfang-runtime/src/docker_sandbox.rs index 512da074..25444e54 100644 --- a/crates/openfang-runtime/src/docker_sandbox.rs +++ b/crates/openfang-runtime/src/docker_sandbox.rs @@ -205,21 +205,13 @@ pub async fn exec_in_sandbox( let max_output = 50_000; let stdout = if stdout.len() > max_output { let safe_end = crate::str_utils::safe_truncate_str(&stdout, max_output); - format!( - "{}... [truncated, {} total bytes]", - safe_end, - stdout.len() - ) + format!("{}... [truncated, {} total bytes]", safe_end, stdout.len()) } else { stdout }; let stderr = if stderr.len() > max_output { let safe_end = crate::str_utils::safe_truncate_str(&stderr, max_output); - format!( - "{}... [truncated, {} total bytes]", - safe_end, - stderr.len() - ) + format!("{}... [truncated, {} total bytes]", safe_end, stderr.len()) } else { stderr }; diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index 857774e2..fdbe99b5 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -507,7 +507,10 @@ impl LlmDriver for AnthropicDriver { for block in blocks { match block { ContentBlockAccum::Text(text) => { - content.push(ContentBlock::Text { text, provider_metadata: None }); + content.push(ContentBlock::Text { + text, + provider_metadata: None, + }); } ContentBlockAccum::Thinking(thinking) => { content.push(ContentBlock::Thinking { thinking }); @@ -573,7 +576,9 @@ fn convert_message(msg: &Message) -> ApiMessage { data: data.clone(), }, }), - ContentBlock::ToolUse { id, name, input, .. } => Some(ApiContentBlock::ToolUse { + ContentBlock::ToolUse { + id, name, input, .. + } => Some(ApiContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: input.clone(), @@ -610,7 +615,10 @@ fn convert_response(api: ApiResponse) -> CompletionResponse { for block in api.content { match block { ResponseContentBlock::Text { text } => { - content.push(ContentBlock::Text { text, provider_metadata: None }); + content.push(ContentBlock::Text { + text, + provider_metadata: None, + }); } ResponseContentBlock::ToolUse { id, name, input } => { content.push(ContentBlock::ToolUse { diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 986224ee..8b93fca4 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -89,7 +89,11 @@ impl ClaudeCodeDriver { } /// Create a new Claude Code driver with a custom timeout. - pub fn with_timeout(cli_path: Option, skip_permissions: bool, timeout_secs: u64) -> Self { + pub fn with_timeout( + cli_path: Option, + skip_permissions: bool, + timeout_secs: u64, + ) -> Self { let mut driver = Self::new(cli_path, skip_permissions); driver.message_timeout_secs = timeout_secs; driver @@ -150,9 +154,7 @@ impl ClaudeCodeDriver { /// Map a model ID like "claude-code/opus" to CLI --model flag value. fn model_flag(model: &str) -> Option { - let stripped = model - .strip_prefix("claude-code/") - .unwrap_or(model); + let stripped = model.strip_prefix("claude-code/").unwrap_or(model); match stripped { "opus" => Some("opus".to_string()), "sonnet" => Some("sonnet".to_string()), @@ -228,10 +230,7 @@ struct ClaudeStreamEvent { #[async_trait] impl LlmDriver for ClaudeCodeDriver { - async fn complete( - &self, - request: CompletionRequest, - ) -> Result { + async fn complete(&self, request: CompletionRequest) -> Result { let prompt = Self::build_prompt(&request); let model_flag = Self::model_flag(&request.model); @@ -257,13 +256,13 @@ impl LlmDriver for ClaudeCodeDriver { debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Claude Code CLI"); // Spawn child process instead of cmd.output() so we can track PID and timeout - let mut child = cmd - .spawn() - .map_err(|e| LlmError::Http(format!( + let mut child = cmd.spawn().map_err(|e| { + LlmError::Http(format!( "Claude Code CLI not found or failed to start ({}). \ Install: npm install -g @anthropic-ai/claude-code && claude auth", e - )))?; + )) + })?; // Track the PID using the model name as label (best identifier available) let pid_label = request.model.clone(); @@ -319,7 +318,11 @@ impl LlmDriver for ClaudeCodeDriver { if !status.success() { let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string(); let stdout_str = String::from_utf8_lossy(&stdout_bytes).trim().to_string(); - let detail = if !stderr.is_empty() { &stderr } else { &stdout_str }; + let detail = if !stderr.is_empty() { + &stderr + } else { + &stdout_str + }; let code = status.code().unwrap_or(1); warn!( @@ -335,9 +338,7 @@ impl LlmDriver for ClaudeCodeDriver { || detail.contains("login") || detail.contains("credentials") { - format!( - "Claude Code CLI is not authenticated. Run: claude auth\nDetail: {detail}" - ) + format!("Claude Code CLI is not authenticated. Run: claude auth\nDetail: {detail}") } else if detail.contains("permission") || detail.contains("--dangerously-skip-permissions") { @@ -361,13 +362,17 @@ impl LlmDriver for ClaudeCodeDriver { // Try JSON parse first if let Ok(parsed) = serde_json::from_str::(&stdout) { - let text = parsed.result + let text = parsed + .result .or(parsed.content) .or(parsed.text) .unwrap_or_default(); let usage = parsed.usage.unwrap_or_default(); return Ok(CompletionResponse { - content: vec![ContentBlock::Text { text: text.clone(), provider_metadata: None }], + content: vec![ContentBlock::Text { + text: text.clone(), + provider_metadata: None, + }], stop_reason: StopReason::EndTurn, tool_calls: Vec::new(), usage: TokenUsage { @@ -380,7 +385,10 @@ impl LlmDriver for ClaudeCodeDriver { // Fallback: treat entire stdout as plain text let text = stdout.trim().to_string(); Ok(CompletionResponse { - content: vec![ContentBlock::Text { text, provider_metadata: None }], + content: vec![ContentBlock::Text { + text, + provider_metadata: None, + }], stop_reason: StopReason::EndTurn, tool_calls: Vec::new(), usage: TokenUsage { @@ -420,13 +428,13 @@ impl LlmDriver for ClaudeCodeDriver { debug!(cli = %self.cli_path, "Spawning Claude Code CLI (streaming)"); - let mut child = cmd - .spawn() - .map_err(|e| LlmError::Http(format!( + let mut child = cmd.spawn().map_err(|e| { + LlmError::Http(format!( "Claude Code CLI not found or failed to start ({}). \ Install: npm install -g @anthropic-ai/claude-code && claude auth", e - )))?; + )) + })?; // Track PID let pid_label = format!("{}-stream", request.model); @@ -435,13 +443,10 @@ impl LlmDriver for ClaudeCodeDriver { debug!(pid = pid, model = %pid_label, "Claude Code CLI streaming subprocess started"); } - let stdout = child - .stdout - .take() - .ok_or_else(|| { - self.active_pids.remove(&pid_label); - LlmError::Http("No stdout from claude CLI".to_string()) - })?; + let stdout = child.stdout.take().ok_or_else(|| { + self.active_pids.remove(&pid_label); + LlmError::Http("No stdout from claude CLI".to_string()) + })?; let reader = tokio::io::BufReader::new(stdout); let mut lines = reader.lines(); @@ -507,9 +512,7 @@ impl LlmDriver for ClaudeCodeDriver { // Not valid JSON — treat as raw text warn!(line = %line, error = %e, "Non-JSON line from Claude CLI"); full_text.push_str(&line); - let _ = tx - .send(StreamEvent::TextDelta { text: line }) - .await; + let _ = tx.send(StreamEvent::TextDelta { text: line }).await; } } } @@ -558,7 +561,11 @@ impl LlmDriver for ClaudeCodeDriver { status: code as u16, message: format!( "Claude Code CLI streaming exited with code {code}: {}", - if stderr_text.is_empty() { "no stderr" } else { &stderr_text } + if stderr_text.is_empty() { + "no stderr" + } else { + &stderr_text + } ), }); } @@ -571,7 +578,10 @@ impl LlmDriver for ClaudeCodeDriver { .await; Ok(CompletionResponse { - content: vec![ContentBlock::Text { text: full_text, provider_metadata: None }], + content: vec![ContentBlock::Text { + text: full_text, + provider_metadata: None, + }], stop_reason: StopReason::EndTurn, tool_calls: Vec::new(), usage: final_usage, @@ -581,8 +591,7 @@ impl LlmDriver for ClaudeCodeDriver { /// Check if the Claude Code CLI is available. pub fn claude_code_available() -> bool { - ClaudeCodeDriver::detect().is_some() - || claude_credentials_exist() + ClaudeCodeDriver::detect().is_some() || claude_credentials_exist() } /// Check if Claude credentials file exists. @@ -604,7 +613,9 @@ fn claude_credentials_exist() -> bool { fn home_dir() -> Option { #[cfg(target_os = "windows")] { - std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from) + std::env::var("USERPROFILE") + .ok() + .map(std::path::PathBuf::from) } #[cfg(not(target_os = "windows"))] { diff --git a/crates/openfang-runtime/src/drivers/copilot.rs b/crates/openfang-runtime/src/drivers/copilot.rs index 3170163a..44cb3e22 100644 --- a/crates/openfang-runtime/src/drivers/copilot.rs +++ b/crates/openfang-runtime/src/drivers/copilot.rs @@ -204,12 +204,19 @@ impl CopilotDriver { } else { token.base_url.clone() }; - super::openai::OpenAIDriver::new(token.token.to_string(), base_url) - .with_extra_headers(vec![ + super::openai::OpenAIDriver::new(token.token.to_string(), base_url).with_extra_headers( + vec![ ("Editor-Version".to_string(), "vscode/1.96.0".to_string()), - ("Editor-Plugin-Version".to_string(), "copilot/1.250.0".to_string()), - ("Copilot-Integration-Id".to_string(), "vscode-chat".to_string()), - ]) + ( + "Editor-Plugin-Version".to_string(), + "copilot/1.250.0".to_string(), + ), + ( + "Copilot-Integration-Id".to_string(), + "vscode-chat".to_string(), + ), + ], + ) } } diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index 05144d30..cd37cc1b 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -219,7 +219,8 @@ fn parse_gemini_error(body: &str) -> String { } // Google sometimes returns bare JSON arrays or HTML error pages if body.starts_with('<') { - return "Google API returned an HTML error page — check your API key and model name".to_string(); + return "Google API returned an HTML error page — check your API key and model name" + .to_string(); } body.to_string() } @@ -412,9 +413,8 @@ fn convert_response(resp: GeminiResponse) -> Result Result Result { - let reason = candidate - .finish_reason - .as_deref() - .unwrap_or("unknown"); + let reason = candidate.finish_reason.as_deref().unwrap_or("unknown"); warn!(finish_reason = %reason, "Gemini returned candidate with no content"); return Err(LlmError::Parse(format!( "Gemini returned empty response (finish_reason: {reason})" @@ -520,7 +516,9 @@ impl LlmDriver for GeminiDriver { for attempt in 0..=max_retries { let url = format!( "{}/v1beta/models/{}:generateContent?key={}", - self.base_url, request.model, self.api_key.as_str() + self.base_url, + request.model, + self.api_key.as_str() ); debug!(url = %url, attempt, "Sending Gemini API request"); @@ -604,7 +602,9 @@ impl LlmDriver for GeminiDriver { for attempt in 0..=max_retries { let url = format!( "{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}", - self.base_url, request.model, self.api_key.as_str() + self.base_url, + request.model, + self.api_key.as_str() ); debug!(url = %url, attempt, "Sending Gemini streaming request"); @@ -773,9 +773,8 @@ impl LlmDriver for GeminiDriver { let mut tool_calls = Vec::new(); if !text_content.is_empty() { - let provider_metadata = text_thought_sig.map(|sig| { - serde_json::json!({ "thought_signature": sig }) - }); + let provider_metadata = + text_thought_sig.map(|sig| serde_json::json!({ "thought_signature": sig })); content.push(ContentBlock::Text { text: text_content, provider_metadata, @@ -1171,7 +1170,9 @@ mod tests { ContentBlock::ToolUse { provider_metadata, .. } => { - let meta = provider_metadata.as_ref().expect("provider_metadata should be set"); + let meta = provider_metadata + .as_ref() + .expect("provider_metadata should be set"); assert_eq!(meta["thought_signature"], "abc123signature"); } _ => panic!("Expected ToolUse content block"), @@ -1207,7 +1208,9 @@ mod tests { provider_metadata, } => { assert_eq!(text, "Let me think about this..."); - let meta = provider_metadata.as_ref().expect("provider_metadata should be set for text with thoughtSignature"); + let meta = provider_metadata + .as_ref() + .expect("provider_metadata should be set for text with thoughtSignature"); assert_eq!(meta["thought_signature"], "text_sig_456"); } _ => panic!("Expected Text content block"), @@ -1256,10 +1259,7 @@ mod tests { thought_signature, } => { assert_eq!(function_call.name, "web_search"); - assert_eq!( - thought_signature.as_deref(), - Some("sig_xyz789") - ); + assert_eq!(thought_signature.as_deref(), Some("sig_xyz789")); } _ => panic!("Expected FunctionCall part"), } @@ -1292,10 +1292,7 @@ mod tests { thought_signature, } => { assert_eq!(text, "Let me think..."); - assert_eq!( - thought_signature.as_deref(), - Some("text_sig_abc") - ); + assert_eq!(thought_signature.as_deref(), Some("text_sig_abc")); } _ => panic!("Expected Text part"), } @@ -1398,10 +1395,7 @@ mod tests { GeminiPart::FunctionCall { thought_signature, .. } => { - assert_eq!( - thought_signature.as_deref(), - Some("my_sig_abc") - ); + assert_eq!(thought_signature.as_deref(), Some("my_sig_abc")); } _ => panic!("Expected FunctionCall"), } @@ -1653,8 +1647,7 @@ mod tests { #[test] fn test_thought_part_with_signature() { - let json = - r#"{"text": "reasoning...", "thought": true, "thoughtSignature": "sig_abc123"}"#; + let json = r#"{"text": "reasoning...", "thought": true, "thoughtSignature": "sig_abc123"}"#; let part: GeminiPart = serde_json::from_str(json).unwrap(); match part { GeminiPart::Thought { diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 45d381bc..c6939eab 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -16,12 +16,12 @@ use crate::llm_driver::{DriverConfig, LlmDriver, LlmError}; use openfang_types::model_catalog::{ AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, - KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, - MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, - OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, - VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, - ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, + KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, + MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, + PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, + TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, + VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, + ZHIPU_CODING_BASE_URL, }; use std::sync::Arc; @@ -295,9 +295,7 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr .or_else(|| std::env::var("OPENAI_API_KEY").ok()) .or_else(crate::model_catalog::read_codex_credential) .ok_or_else(|| { - LlmError::MissingApiKey( - "Set OPENAI_API_KEY or install Codex CLI".to_string(), - ) + LlmError::MissingApiKey("Set OPENAI_API_KEY or install Codex CLI".to_string()) })?; let base_url = config .base_url @@ -444,21 +442,45 @@ pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'stat ("gemini", "gemini-2.5-flash", "GEMINI_API_KEY"), ("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY"), ("deepseek", "deepseek-chat", "DEEPSEEK_API_KEY"), - ("openrouter", "openrouter/google/gemini-2.5-flash", "OPENROUTER_API_KEY"), + ( + "openrouter", + "openrouter/google/gemini-2.5-flash", + "OPENROUTER_API_KEY", + ), ("mistral", "mistral-large-latest", "MISTRAL_API_KEY"), - ("together", "meta-llama/Llama-3-70b-chat-hf", "TOGETHER_API_KEY"), - ("fireworks", "accounts/fireworks/models/llama-v3p1-70b-instruct", "FIREWORKS_API_KEY"), + ( + "together", + "meta-llama/Llama-3-70b-chat-hf", + "TOGETHER_API_KEY", + ), + ( + "fireworks", + "accounts/fireworks/models/llama-v3p1-70b-instruct", + "FIREWORKS_API_KEY", + ), ("xai", "grok-2", "XAI_API_KEY"), - ("perplexity", "llama-3.1-sonar-large-128k-online", "PERPLEXITY_API_KEY"), + ( + "perplexity", + "llama-3.1-sonar-large-128k-online", + "PERPLEXITY_API_KEY", + ), ("cohere", "command-r-plus", "COHERE_API_KEY"), ]; for &(provider, model, env_var) in PROBE_ORDER { - if std::env::var(env_var).ok().filter(|v| !v.is_empty()).is_some() { + if std::env::var(env_var) + .ok() + .filter(|v| !v.is_empty()) + .is_some() + { return Some((provider, model, env_var)); } } // Also check GOOGLE_API_KEY as alias for Gemini - if std::env::var("GOOGLE_API_KEY").ok().filter(|v| !v.is_empty()).is_some() { + if std::env::var("GOOGLE_API_KEY") + .ok() + .filter(|v| !v.is_empty()) + .is_some() + { return Some(("gemini", "gemini-2.5-flash", "GOOGLE_API_KEY")); } None @@ -659,7 +681,10 @@ mod tests { skip_permissions: true, }; let driver = create_driver(&config); - assert!(driver.is_ok(), "NVIDIA provider with env var should succeed"); + assert!( + driver.is_ok(), + "NVIDIA provider with env var should succeed" + ); std::env::remove_var("NVIDIA_API_KEY"); } @@ -690,7 +715,11 @@ mod tests { let result = create_driver(&config); assert!(result.is_err()); let err = result.err().unwrap().to_string(); - assert!(err.contains("base_url"), "Error should mention base_url: {}", err); + assert!( + err.contains("base_url"), + "Error should mention base_url: {}", + err + ); std::env::remove_var("MYCUSTOM_API_KEY"); } diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 12e0cdf8..11b9d6f4 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -262,9 +262,11 @@ impl LlmDriver for OpenAIDriver { has_tool_results = true; oai_messages.push(OaiMessage { role: "tool".to_string(), - content: Some(OaiMessageContent::Text( - if content.is_empty() { "(empty)".to_string() } else { content.clone() } - )), + content: Some(OaiMessageContent::Text(if content.is_empty() { + "(empty)".to_string() + } else { + content.clone() + })), tool_calls: None, tool_call_id: Some(tool_use_id.clone()), reasoning_content: None, @@ -301,7 +303,9 @@ impl LlmDriver for OpenAIDriver { for block in blocks { match block { ContentBlock::Text { text, .. } => text_parts.push(text.clone()), - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { tool_calls.push(OaiToolCall { id: id.clone(), call_type: "function".to_string(), @@ -337,7 +341,11 @@ impl LlmDriver for OpenAIDriver { }, tool_call_id: None, reasoning_content: if needs_reasoning { - Some(if reasoning_text.is_empty() { String::new() } else { reasoning_text }) + Some(if reasoning_text.is_empty() { + String::new() + } else { + reasoning_text + }) } else { None }, @@ -485,9 +493,16 @@ impl LlmDriver for OpenAIDriver { // Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192) if status == 400 && body.contains("max_tokens") && attempt < max_retries { - let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096); + let current = oai_request + .max_tokens + .or(oai_request.max_completion_tokens) + .unwrap_or(4096); let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2); - warn!(old = current, new = cap, "Auto-capping max_tokens to model limit"); + warn!( + old = current, + new = cap, + "Auto-capping max_tokens to model limit" + ); if oai_request.max_completion_tokens.is_some() { oai_request.max_completion_tokens = Some(cap); } else { @@ -544,7 +559,10 @@ impl LlmDriver for OpenAIDriver { // (DeepSeek-R1, Qwen3, etc. via LM Studio/Ollama) if let Some(ref reasoning) = choice.message.reasoning_content { if !reasoning.is_empty() { - debug!(len = reasoning.len(), "Captured reasoning_content from response"); + debug!( + len = reasoning.len(), + "Captured reasoning_content from response" + ); content.push(ContentBlock::Thinking { thinking: reasoning.clone(), }); @@ -565,7 +583,10 @@ impl LlmDriver for OpenAIDriver { } } if !cleaned.is_empty() { - content.push(ContentBlock::Text { text: cleaned, provider_metadata: None }); + content.push(ContentBlock::Text { + text: cleaned, + provider_metadata: None, + }); } } } @@ -573,17 +594,30 @@ impl LlmDriver for OpenAIDriver { // If we have reasoning but no text content and no tool calls, // synthesize a brief text block so the agent loop doesn't treat // this as an empty response. - let has_text = content.iter().any(|b| matches!(b, ContentBlock::Text { .. })); - let has_thinking = content.iter().any(|b| matches!(b, ContentBlock::Thinking { .. })); + let has_text = content + .iter() + .any(|b| matches!(b, ContentBlock::Text { .. })); + let has_thinking = content + .iter() + .any(|b| matches!(b, ContentBlock::Thinking { .. })); if has_thinking && !has_text && choice.message.tool_calls.is_none() { // Extract the last sentence or line from the thinking as a response - let thinking_text = content.iter().find_map(|b| match b { - ContentBlock::Thinking { thinking } => Some(thinking.as_str()), - _ => None, - }).unwrap_or(""); + let thinking_text = content + .iter() + .find_map(|b| match b { + ContentBlock::Thinking { thinking } => Some(thinking.as_str()), + _ => None, + }) + .unwrap_or(""); let summary = extract_thinking_summary(thinking_text); - debug!(summary_len = summary.len(), "Synthesizing text from thinking-only response"); - content.push(ContentBlock::Text { text: summary, provider_metadata: None }); + debug!( + summary_len = summary.len(), + "Synthesizing text from thinking-only response" + ); + content.push(ContentBlock::Text { + text: summary, + provider_metadata: None, + }); } if let Some(calls) = choice.message.tool_calls { @@ -630,7 +664,9 @@ impl LlmDriver for OpenAIDriver { // non-zero output_tokens so the agent loop doesn't misclassify // this as a "silent failure" and loop unnecessarily. if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 { - debug!("Response has content but no usage stats — setting synthetic output_tokens=1"); + debug!( + "Response has content but no usage stats — setting synthetic output_tokens=1" + ); usage.output_tokens = 1; } @@ -707,9 +743,11 @@ impl LlmDriver for OpenAIDriver { { oai_messages.push(OaiMessage { role: "tool".to_string(), - content: Some(OaiMessageContent::Text( - if content.is_empty() { "(empty)".to_string() } else { content.clone() } - )), + content: Some(OaiMessageContent::Text(if content.is_empty() { + "(empty)".to_string() + } else { + content.clone() + })), tool_calls: None, tool_call_id: Some(tool_use_id.clone()), reasoning_content: None, @@ -724,7 +762,9 @@ impl LlmDriver for OpenAIDriver { for block in blocks { match block { ContentBlock::Text { text, .. } => text_parts.push(text.clone()), - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { tool_calls_out.push(OaiToolCall { id: id.clone(), call_type: "function".to_string(), @@ -760,7 +800,11 @@ impl LlmDriver for OpenAIDriver { }, tool_call_id: None, reasoning_content: if needs_reasoning { - Some(if reasoning_text.is_empty() { String::new() } else { reasoning_text }) + Some(if reasoning_text.is_empty() { + String::new() + } else { + reasoning_text + }) } else { None }, @@ -909,7 +953,10 @@ impl LlmDriver for OpenAIDriver { // Auto-cap max_tokens when model rejects our value if status == 400 && body.contains("max_tokens") && attempt < max_retries { - let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096); + let current = oai_request + .max_tokens + .or(oai_request.max_completion_tokens) + .unwrap_or(4096); let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2); warn!(old = current, new = cap, "Auto-capping max_tokens (stream)"); if oai_request.max_completion_tokens.is_some() { @@ -1030,9 +1077,8 @@ impl LlmDriver for OpenAIDriver { for action in think_filter.process(text) { match action { FilterAction::EmitText(t) => { - let _ = tx - .send(StreamEvent::TextDelta { text: t }) - .await; + let _ = + tx.send(StreamEvent::TextDelta { text: t }).await; } FilterAction::EmitThinking(t) => { // Route think content the same way as @@ -1116,9 +1162,7 @@ impl LlmDriver for OpenAIDriver { let _ = tx.send(StreamEvent::TextDelta { text: t }).await; } FilterAction::EmitThinking(t) => { - let _ = tx - .send(StreamEvent::ThinkingDelta { text: t }) - .await; + let _ = tx.send(StreamEvent::ThinkingDelta { text: t }).await; } } } @@ -1175,23 +1219,39 @@ impl LlmDriver for OpenAIDriver { } } if !cleaned.is_empty() { - content.push(ContentBlock::Text { text: cleaned, provider_metadata: None }); + content.push(ContentBlock::Text { + text: cleaned, + provider_metadata: None, + }); } } // If we have reasoning but no text content and no tool calls, // synthesize a brief text block so the agent loop doesn't treat // this as an empty response. - let has_text = content.iter().any(|b| matches!(b, ContentBlock::Text { .. })); - let has_thinking = content.iter().any(|b| matches!(b, ContentBlock::Thinking { .. })); + let has_text = content + .iter() + .any(|b| matches!(b, ContentBlock::Text { .. })); + let has_thinking = content + .iter() + .any(|b| matches!(b, ContentBlock::Thinking { .. })); if has_thinking && !has_text && tool_accum.is_empty() { - let thinking_text = content.iter().find_map(|b| match b { - ContentBlock::Thinking { thinking } => Some(thinking.as_str()), - _ => None, - }).unwrap_or(""); + let thinking_text = content + .iter() + .find_map(|b| match b { + ContentBlock::Thinking { thinking } => Some(thinking.as_str()), + _ => None, + }) + .unwrap_or(""); let summary = extract_thinking_summary(thinking_text); - debug!(summary_len = summary.len(), "Synthesizing text from thinking-only stream response"); - content.push(ContentBlock::Text { text: summary, provider_metadata: None }); + debug!( + summary_len = summary.len(), + "Synthesizing text from thinking-only stream response" + ); + content.push(ContentBlock::Text { + text: summary, + provider_metadata: None, + }); } for (id, name, arguments) in &tool_accum { @@ -1314,7 +1374,8 @@ fn extract_think_tags(text: &str) -> (String, Option) { fn extract_thinking_summary(thinking: &str) -> String { let trimmed = thinking.trim(); if trimmed.is_empty() { - return "[The model produced reasoning but no final answer. Try rephrasing your question.]".to_string(); + return "[The model produced reasoning but no final answer. Try rephrasing your question.]" + .to_string(); } // Take the last non-empty paragraph (models usually conclude with their answer) @@ -1333,7 +1394,8 @@ fn extract_thinking_summary(thinking: &str) -> String { last[last.len() - 2000..].to_string() } } else { - "[The model produced reasoning but no final answer. Try rephrasing your question.]".to_string() + "[The model produced reasoning but no final answer. Try rephrasing your question.]" + .to_string() } } @@ -1591,7 +1653,8 @@ mod tests { #[test] fn test_extract_think_tags_multiple_blocks() { - let input = "First thoughtMiddle textSecond thoughtFinal text"; + let input = + "First thoughtMiddle textSecond thoughtFinal text"; let (cleaned, thinking) = extract_think_tags(input); assert_eq!(cleaned, "Middle textFinal text"); let t = thinking.unwrap(); @@ -1632,7 +1695,8 @@ mod tests { #[test] fn test_oai_response_message_with_reasoning_content() { - let json = r#"{"content": null, "reasoning_content": "Let me think...", "tool_calls": null}"#; + let json = + r#"{"content": null, "reasoning_content": "Let me think...", "tool_calls": null}"#; let msg: OaiResponseMessage = serde_json::from_str(json).unwrap(); assert!(msg.content.is_none()); assert_eq!(msg.reasoning_content.as_deref(), Some("Let me think...")); diff --git a/crates/openfang-runtime/src/drivers/qwen_code.rs b/crates/openfang-runtime/src/drivers/qwen_code.rs index a5ee8e06..451a2b0e 100644 --- a/crates/openfang-runtime/src/drivers/qwen_code.rs +++ b/crates/openfang-runtime/src/drivers/qwen_code.rs @@ -205,10 +205,7 @@ struct QwenStreamEvent { #[async_trait] impl LlmDriver for QwenCodeDriver { - async fn complete( - &self, - request: CompletionRequest, - ) -> Result { + async fn complete(&self, request: CompletionRequest) -> Result { let prompt = Self::build_prompt(&request); let args = self.build_args(&prompt, &request.model, false); @@ -224,14 +221,13 @@ impl LlmDriver for QwenCodeDriver { debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI"); - let output = cmd - .output() - .await - .map_err(|e| LlmError::Http(format!( + let output = cmd.output().await.map_err(|e| { + LlmError::Http(format!( "Qwen Code CLI not found or failed to start ({}). \ Install: npm install -g @qwen-code/qwen-code && qwen auth", e - )))?; + )) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); @@ -244,9 +240,7 @@ impl LlmDriver for QwenCodeDriver { || detail.contains("login") || detail.contains("credentials") { - format!( - "Qwen Code CLI is not authenticated. Run: qwen auth\nDetail: {detail}" - ) + format!("Qwen Code CLI is not authenticated. Run: qwen auth\nDetail: {detail}") } else { format!("Qwen Code CLI exited with code {code}: {detail}") }; @@ -315,13 +309,13 @@ impl LlmDriver for QwenCodeDriver { debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI (streaming)"); - let mut child = cmd - .spawn() - .map_err(|e| LlmError::Http(format!( + let mut child = cmd.spawn().map_err(|e| { + LlmError::Http(format!( "Qwen Code CLI not found or failed to start ({}). \ Install: npm install -g @qwen-code/qwen-code && qwen auth", e - )))?; + )) + })?; let stdout = child .stdout @@ -386,9 +380,7 @@ impl LlmDriver for QwenCodeDriver { Err(e) => { warn!(line = %line, error = %e, "Non-JSON line from Qwen CLI"); full_text.push_str(&line); - let _ = tx - .send(StreamEvent::TextDelta { text: line }) - .await; + let _ = tx.send(StreamEvent::TextDelta { text: line }).await; } } } @@ -448,9 +440,7 @@ fn home_dir() -> Option { } #[cfg(not(target_os = "windows"))] { - std::env::var("HOME") - .ok() - .map(std::path::PathBuf::from) + std::env::var("HOME").ok().map(std::path::PathBuf::from) } } @@ -590,8 +580,7 @@ mod tests { #[test] fn test_stream_event_result() { - let json = - r#"{"type":"result","result":"Final answer","usage":{"input_tokens":20,"output_tokens":10}}"#; + let json = r#"{"type":"result","result":"Final answer","usage":{"input_tokens":20,"output_tokens":10}}"#; let event: QwenStreamEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.r#type, "result"); assert_eq!(event.result.unwrap(), "Final answer"); diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index 0f5fc9ea..00f19559 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -216,7 +216,9 @@ pub trait KernelHandle: Send + Sync { filename: Option<&str>, thread_id: Option<&str>, ) -> Result { - let _ = (channel, recipient, media_type, media_url, caption, filename, thread_id); + let _ = ( + channel, recipient, media_type, media_url, caption, filename, thread_id, + ); Err("Channel media send not available".to_string()) } diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index 93e0eb2e..9e88fe8b 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -15,9 +15,9 @@ pub mod auth_cooldown; pub mod browser; pub mod command_lane; pub mod compactor; -pub mod copilot_oauth; pub mod context_budget; pub mod context_overflow; +pub mod copilot_oauth; pub mod docker_sandbox; pub mod drivers; pub mod embedding; diff --git a/crates/openfang-runtime/src/llm_errors.rs b/crates/openfang-runtime/src/llm_errors.rs index cb2afb17..3ba77281 100644 --- a/crates/openfang-runtime/src/llm_errors.rs +++ b/crates/openfang-runtime/src/llm_errors.rs @@ -119,8 +119,8 @@ const FORBIDDEN_NON_AUTH_PATTERNS: &[&str] = &[ "not available", "not supported", "not allowed", - "access denied", // model/resource access, not API key - "permission", // model permission, not API key auth + "access denied", // model/resource access, not API key + "permission", // model permission, not API key auth "insufficient", "exceeded", "capacity", @@ -128,7 +128,7 @@ const FORBIDDEN_NON_AUTH_PATTERNS: &[&str] = &[ "restricted", "not enabled", "does not exist", - "model", // model-level 403 (e.g., "model access forbidden") + "model", // model-level 403 (e.g., "model access forbidden") ]; /// Rate-limit patterns. @@ -417,9 +417,7 @@ pub fn sanitize_for_user(category: LlmErrorCategory, raw: &str) -> String { if detail.is_empty() { // Fall back to a helpful generic message when there is no raw detail. match category { - LlmErrorCategory::RateLimit => { - "Rate limited — retrying shortly.".to_string() - } + LlmErrorCategory::RateLimit => "Rate limited — retrying shortly.".to_string(), LlmErrorCategory::Overloaded => { "Provider temporarily overloaded — retrying.".to_string() } @@ -429,18 +427,14 @@ pub fn sanitize_for_user(category: LlmErrorCategory, raw: &str) -> String { LlmErrorCategory::Billing => { "Billing issue. Check your API plan and balance.".to_string() } - LlmErrorCategory::Auth => { - "Auth error. Check your API key configuration.".to_string() - } + LlmErrorCategory::Auth => "Auth error. Check your API key configuration.".to_string(), LlmErrorCategory::ContextOverflow => { "Context too long for the model's context window.".to_string() } LlmErrorCategory::Format => { "Request failed. Check API key and model config.".to_string() } - LlmErrorCategory::ModelNotFound => { - "Model not found. Check the model name.".to_string() - } + LlmErrorCategory::ModelNotFound => "Model not found. Check the model name.".to_string(), } } else { // Include the sanitized detail — cap total at 300 chars. @@ -888,8 +882,7 @@ mod tests { #[test] fn test_sanitize_html_page() { - let msg = - sanitize_raw_excerpt("502 Bad Gateway"); + let msg = sanitize_raw_excerpt("502 Bad Gateway"); assert!(msg.contains("error page")); assert!(!msg.contains("")); } diff --git a/crates/openfang-runtime/src/mcp.rs b/crates/openfang-runtime/src/mcp.rs index 170cd74b..0fbfbb98 100644 --- a/crates/openfang-runtime/src/mcp.rs +++ b/crates/openfang-runtime/src/mcp.rs @@ -432,9 +432,7 @@ impl McpConnection { let has_cmd = std::env::var("PATH") .unwrap_or_default() .split(';') - .any(|dir| { - std::path::Path::new(dir).join(&cmd_variant).exists() - }); + .any(|dir| std::path::Path::new(dir).join(&cmd_variant).exists()); if has_cmd { cmd_variant } else { diff --git a/crates/openfang-runtime/src/media_understanding.rs b/crates/openfang-runtime/src/media_understanding.rs index 7338f591..452db86c 100644 --- a/crates/openfang-runtime/src/media_understanding.rs +++ b/crates/openfang-runtime/src/media_understanding.rs @@ -279,17 +279,18 @@ async fn transcribe_with_parakeet_mlx( "audio/flac" => "flac", _ => "wav", }; - let path = std::env::temp_dir() - .join(format!("openfang_parakeet_{}.{}", uuid::Uuid::new_v4(), ext)); + let path = std::env::temp_dir().join(format!( + "openfang_parakeet_{}.{}", + uuid::Uuid::new_v4(), + ext + )); tokio::fs::write(&path, decoded) .await .map_err(|e| format!("Failed to write temp audio: {e}"))?; (path, true) } MediaSource::Url { url } => { - return Err(format!( - "URL audio not supported for parakeet-mlx: {url}" - )); + return Err(format!("URL audio not supported for parakeet-mlx: {url}")); } }; @@ -302,7 +303,15 @@ print(json.dumps({"text": result.text, "model": "mlx-community/parakeet-tdt-0.6b "#; let mut cmd = tokio::process::Command::new("uv"); - cmd.args(["run", "--with", "parakeet-mlx", "python3", "-c", script, &audio_path.to_string_lossy()]); + cmd.args([ + "run", + "--with", + "parakeet-mlx", + "python3", + "-c", + script, + &audio_path.to_string_lossy(), + ]); cmd.env("PYTHONUNBUFFERED", "1"); cmd.kill_on_drop(true); @@ -320,12 +329,16 @@ print(json.dumps({"text": result.text, "model": "mlx-community/parakeet-tdt-0.6b return Err(format!("parakeet-mlx failed: {}", stderr.trim())); } - let stdout = String::from_utf8(output.stdout) - .map_err(|e| format!("parakeet-mlx non-UTF8: {e}"))?; + let stdout = + String::from_utf8(output.stdout).map_err(|e| format!("parakeet-mlx non-UTF8: {e}"))?; let parsed: serde_json::Value = serde_json::from_str(stdout.trim()) .map_err(|e| format!("parakeet-mlx parse failed: {e}"))?; - let text = parsed["text"].as_str().ok_or("missing text field")?.trim().to_string(); + let text = parsed["text"] + .as_str() + .ok_or("missing text field")? + .trim() + .to_string(); if text.is_empty() { return Err("parakeet-mlx returned empty transcription".into()); } @@ -334,7 +347,10 @@ print(json.dumps({"text": result.text, "model": "mlx-community/parakeet-tdt-0.6b media_type: MediaType::Audio, description: text, provider: "parakeet-mlx".to_string(), - model: parsed["model"].as_str().unwrap_or("parakeet-tdt-0.6b-v3").to_string(), + model: parsed["model"] + .as_str() + .unwrap_or("parakeet-tdt-0.6b-v3") + .to_string(), }) } diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 4cb15795..b0e12de2 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -9,8 +9,7 @@ use openfang_types::model_catalog::{ FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, - OPENAI_BASE_URL, - OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, + OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, @@ -60,21 +59,19 @@ impl ModelCatalog { // Claude Code is special: no API key needed, but we probe for CLI // installation so the dashboard shows "Configured" vs "Not Installed". if provider.id == "claude-code" { - provider.auth_status = - if crate::drivers::claude_code::claude_code_available() { - AuthStatus::Configured - } else { - AuthStatus::Missing - }; + provider.auth_status = if crate::drivers::claude_code::claude_code_available() { + AuthStatus::Configured + } else { + AuthStatus::Missing + }; continue; } if provider.id == "qwen-code" { - provider.auth_status = - if crate::drivers::qwen_code::qwen_code_available() { - AuthStatus::Configured - } else { - AuthStatus::Missing - }; + provider.auth_status = if crate::drivers::qwen_code::qwen_code_available() { + AuthStatus::Configured + } else { + AuthStatus::Missing + }; continue; } @@ -90,8 +87,7 @@ impl ModelCatalog { let has_fallback = match provider.id.as_str() { "gemini" => std::env::var("GOOGLE_API_KEY").is_ok(), "codex" => { - std::env::var("OPENAI_API_KEY").is_ok() - || read_codex_credential().is_some() + std::env::var("OPENAI_API_KEY").is_ok() || read_codex_credential().is_some() } // claude-code is handled above (before key_required check) _ => false, @@ -868,7 +864,10 @@ fn builtin_aliases() -> HashMap { ("qwen-coder-plus", "qwen-code/qwen-coder-plus"), ("qwq", "qwen-code/qwq-32b"), // OpenRouter free-tier aliases - ("openrouter/free", "openrouter/meta-llama/llama-3.1-8b-instruct:free"), + ( + "openrouter/free", + "openrouter/meta-llama/llama-3.1-8b-instruct:free", + ), ("free", "openrouter/meta-llama/llama-3.1-8b-instruct:free"), ("free-reasoning", "openrouter/deepseek/deepseek-r1:free"), ]; @@ -3773,10 +3772,7 @@ mod tests { #[test] fn test_resolve_alias() { let catalog = ModelCatalog::new(); - assert_eq!( - catalog.resolve_alias("sonnet"), - Some("claude-sonnet-4-6") - ); + assert_eq!(catalog.resolve_alias("sonnet"), Some("claude-sonnet-4-6")); assert_eq!( catalog.resolve_alias("haiku"), Some("claude-haiku-4-5-20251001") diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index ca54ac4c..fbe0bdbd 100644 --- a/crates/openfang-runtime/src/prompt_builder.rs +++ b/crates/openfang-runtime/src/prompt_builder.rs @@ -154,7 +154,9 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String { // Section 9.1 — Sender Identity (skip for subagents) if !ctx.is_subagent { - if let Some(sender_line) = build_sender_section(ctx.sender_name.as_deref(), ctx.sender_id.as_deref()) { + if let Some(sender_line) = + build_sender_section(ctx.sender_name.as_deref(), ctx.sender_id.as_deref()) + { sections.push(sender_line); } } diff --git a/crates/openfang-runtime/src/provider_health.rs b/crates/openfang-runtime/src/provider_health.rs index 1b17260c..0e0b5eae 100644 --- a/crates/openfang-runtime/src/provider_health.rs +++ b/crates/openfang-runtime/src/provider_health.rs @@ -262,7 +262,10 @@ pub async fn probe_model( } else { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - Err(format!("HTTP {status}: {}", crate::str_utils::safe_truncate_str(&body, 200))) + Err(format!( + "HTTP {status}: {}", + crate::str_utils::safe_truncate_str(&body, 200) + )) } } diff --git a/crates/openfang-runtime/src/session_repair.rs b/crates/openfang-runtime/src/session_repair.rs index 3854f076..5975a05a 100644 --- a/crates/openfang-runtime/src/session_repair.rs +++ b/crates/openfang-runtime/src/session_repair.rs @@ -675,7 +675,10 @@ fn merge_content(dst: &mut MessageContent, src: MessageContent) { /// Convert MessageContent to a Vec. fn content_to_blocks(content: MessageContent) -> Vec { match content { - MessageContent::Text(s) => vec![ContentBlock::Text { text: s, provider_metadata: None }], + MessageContent::Text(s) => vec![ContentBlock::Text { + text: s, + provider_metadata: None, + }], MessageContent::Blocks(blocks) => blocks, } } diff --git a/crates/openfang-runtime/src/str_utils.rs b/crates/openfang-runtime/src/str_utils.rs index beb13a8b..00ba72ce 100644 --- a/crates/openfang-runtime/src/str_utils.rs +++ b/crates/openfang-runtime/src/str_utils.rs @@ -44,7 +44,7 @@ mod tests { fn multibyte_chinese() { // Each Chinese character is 3 bytes in UTF-8 let s = "\u{4f60}\u{597d}\u{4e16}\u{754c}"; // "hello world" in Chinese, 12 bytes - // Truncating at 7 bytes should not split the 3rd char (bytes 6..9) + // Truncating at 7 bytes should not split the 3rd char (bytes 6..9) let t = safe_truncate_str(s, 7); assert_eq!(t, "\u{4f60}\u{597d}"); // 6 bytes, 2 chars assert!(t.len() <= 7); diff --git a/crates/openfang-runtime/src/think_filter.rs b/crates/openfang-runtime/src/think_filter.rs index cfbeb26e..c761d5a6 100644 --- a/crates/openfang-runtime/src/think_filter.rs +++ b/crates/openfang-runtime/src/think_filter.rs @@ -202,10 +202,7 @@ mod tests { // Delta 3: closing tag + visible text let a3 = filter.process("The answer is 42."); - assert_eq!( - a3, - vec![FilterAction::EmitText("The answer is 42.".into())] - ); + assert_eq!(a3, vec![FilterAction::EmitText("The answer is 42.".into())]); assert!(!filter.is_inside_think()); } @@ -221,10 +218,7 @@ mod tests { // Delta 2: completes the tag let a2 = filter.process("nk>deep thought"); // The tag is complete. "deep thought" is thinking. - assert_eq!( - a2, - vec![FilterAction::EmitThinking("deep thought".into())] - ); + assert_eq!(a2, vec![FilterAction::EmitThinking("deep thought".into())]); assert!(filter.is_inside_think()); } @@ -234,10 +228,7 @@ mod tests { // Enter think block let a1 = filter.process("thinking herefirstmiddlesecondend"); + let actions = filter.process("firstmiddlesecondend"); assert_eq!( actions, vec![ @@ -315,10 +305,7 @@ mod tests { fn test_flush_inside_think_with_pending() { let mut filter = StreamingThinkFilter::new(); let a1 = filter.process("thinkingThe answer."); - assert_eq!( - actions, - vec![FilterAction::EmitText("The answer.".into())] - ); + assert_eq!(actions, vec![FilterAction::EmitText("The answer.".into())]); } #[test] @@ -380,10 +364,7 @@ mod tests { assert!(filter.is_inside_think()); let a9 = filter.process("deep thought"); - assert_eq!( - a9, - vec![FilterAction::EmitThinking("deep thought".into())] - ); + assert_eq!(a9, vec![FilterAction::EmitThinking("deep thought".into())]); let a10 = filter.process("done"); assert_eq!(a10, vec![FilterAction::EmitText("done".into())]); diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index aa7de97a..0e185359 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -28,20 +28,11 @@ fn check_taint_shell_exec(command: &str) -> Option { // Layer 1: Block shell metacharacters that enable command injection. // Uses the same validator as subprocess_sandbox and docker_sandbox. if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) { - return Some(format!( - "Shell metacharacter injection blocked: {reason}" - )); + return Some(format!("Shell metacharacter injection blocked: {reason}")); } // Layer 2: Heuristic patterns for injected external URLs / base64 payloads - let suspicious_patterns = [ - "curl ", - "wget ", - "| sh", - "| bash", - "base64 -d", - "eval ", - ]; + let suspicious_patterns = ["curl ", "wget ", "| sh", "| bash", "base64 -d", "eval "]; for pattern in &suspicious_patterns { if command.contains(pattern) { let mut labels = HashSet::new(); @@ -202,7 +193,9 @@ pub async fn execute_tool( let headers = input.get("headers").and_then(|v| v.as_object()); let body = input["body"].as_str(); if let Some(ctx) = web_ctx { - ctx.fetch.fetch_with_options(url, method, headers, body).await + ctx.fetch + .fetch_with_options(url, method, headers, body) + .await } else { tool_web_fetch_legacy(input).await } @@ -223,7 +216,8 @@ pub async fn execute_tool( // SECURITY: Always check for shell metacharacters, even in Full mode. // These enable command injection regardless of exec policy. - if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) { + if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) + { return ToolResult { tool_use_id: tool_use_id.to_string(), content: format!( @@ -365,8 +359,7 @@ pub async fn execute_tool( crate::browser::tool_browser_navigate(input, mgr, aid).await } None => Err( - "Browser tools not available. Ensure Chrome/Chromium is installed." - .to_string(), + "Browser tools not available. Ensure Chrome/Chromium is installed.".to_string(), ), } } @@ -375,63 +368,81 @@ pub async fn execute_tool( let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_click(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_type" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_type(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_screenshot" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_screenshot(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_read_page" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_read_page(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_close" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_close(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_scroll" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_scroll(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_wait" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_wait(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_run_js" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_run_js(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, "browser_back" => match browser_ctx { Some(mgr) => { let aid = caller_agent_id.unwrap_or("default"); crate::browser::tool_browser_back(input, mgr, aid).await } - None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()), + None => { + Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()) + } }, // Canvas / A2UI tool @@ -442,9 +453,12 @@ pub async fn execute_tool( if mcp::is_mcp_tool(other) { if let Some(mcp_conns) = mcp_connections { let mut conns = mcp_conns.lock().await; - let known_names: Vec = conns.iter().map(|c| c.name().to_string()).collect(); + let known_names: Vec = + conns.iter().map(|c| c.name().to_string()).collect(); let known_refs: Vec<&str> = known_names.iter().map(|s| s.as_str()).collect(); - if let Some(server_name) = mcp::extract_mcp_server_from_known(other, &known_refs) { + if let Some(server_name) = + mcp::extract_mcp_server_from_known(other, &known_refs) + { if let Some(conn) = conns.iter_mut().find(|c| c.name() == server_name) { debug!( tool = other, @@ -2198,10 +2212,12 @@ async fn tool_channel_send( let default_id = kh.get_channel_default_recipient(&channel).await; match default_id { Some(id) => id, - None => return Err(format!( + None => { + return Err(format!( "Missing 'recipient' parameter. Set default_chat_id in [channels.{channel}] config \ or pass recipient explicitly." - )), + )) + } } } else { recipient_input @@ -2226,7 +2242,9 @@ async fn tool_channel_send( let caption = input["message"].as_str().filter(|s| !s.is_empty()); let filename = input["filename"].as_str(); return kh - .send_channel_media(&channel, recipient, "file", url, caption, filename, thread_id) + .send_channel_media( + &channel, recipient, "file", url, caption, filename, thread_id, + ) .await; } @@ -2281,9 +2299,7 @@ async fn tool_channel_send( }; return kh - .send_channel_file_data( - &channel, recipient, data, &filename, mime_type, thread_id, - ) + .send_channel_file_data(&channel, recipient, data, &filename, mime_type, thread_id) .await; } @@ -3220,7 +3236,10 @@ async fn tool_canvas_present( let _ = tokio::fs::create_dir_all(&output_dir).await; let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); - let filename = format!("canvas_{timestamp}_{}.html", crate::str_utils::safe_truncate_str(&canvas_id, 8)); + let filename = format!( + "canvas_{timestamp}_{}.html", + crate::str_utils::safe_truncate_str(&canvas_id, 8) + ); let filepath = output_dir.join(&filename); // Write the full HTML document @@ -3366,7 +3385,11 @@ mod tests { None, // process_manager ) .await; - assert!(result.is_error, "Expected error but got: {}", result.content); + assert!( + result.is_error, + "Expected error but got: {}", + result.content + ); } #[tokio::test] @@ -3580,10 +3603,17 @@ mod tests { ) .await; // Should fail for file-not-found, NOT for permission denied - assert!(result.is_error, "Expected error but got: {}", result.content); assert!( - result.content.contains("Failed to read") || result.content.contains("not found") || result.content.contains("No such file"), - "Unexpected error: {}", result.content + result.is_error, + "Expected error but got: {}", + result.content + ); + assert!( + result.content.contains("Failed to read") + || result.content.contains("not found") + || result.content.contains("No such file"), + "Unexpected error: {}", + result.content ); } diff --git a/crates/openfang-runtime/src/web_content.rs b/crates/openfang-runtime/src/web_content.rs index d24c4198..f8d32d92 100644 --- a/crates/openfang-runtime/src/web_content.rs +++ b/crates/openfang-runtime/src/web_content.rs @@ -424,7 +424,10 @@ mod tests { let html = "İstanbul ẞtraße bold text"; let md = html_to_markdown(html); assert!(md.contains("**bold**"), "Expected bold, got: {md}"); - assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}"); + assert!( + md.contains("İstanbul"), + "Expected unicode preserved, got: {md}" + ); } #[test] diff --git a/crates/openfang-runtime/src/web_fetch.rs b/crates/openfang-runtime/src/web_fetch.rs index 22681e0e..d230ffe1 100644 --- a/crates/openfang-runtime/src/web_fetch.rs +++ b/crates/openfang-runtime/src/web_fetch.rs @@ -72,7 +72,10 @@ impl WebFetchEngine { "DELETE" => self.client.delete(url), _ => self.client.get(url), }; - req = req.header("User-Agent", format!("Mozilla/5.0 (compatible; {})", crate::USER_AGENT)); + req = req.header( + "User-Agent", + format!("Mozilla/5.0 (compatible; {})", crate::USER_AGENT), + ); // Add custom headers if let Some(hdrs) = headers { @@ -191,9 +194,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> { let host = extract_host(url); // For IPv6 bracket notation like [::1]:80, extract [::1] as hostname let hostname = if host.starts_with('[') { - host.find(']') - .map(|i| &host[..=i]) - .unwrap_or(&host) + host.find(']').map(|i| &host[..=i]).unwrap_or(&host) } else { host.split(':').next().unwrap_or(&host) }; @@ -290,8 +291,8 @@ mod tests { // (Chinese, Japanese, emoji — common on international finance sites). // Old code: &s[..max] panics when max lands inside a multi-byte char. let content = "\u{4f60}\u{597d}\u{4e16}\u{754c}!"; // "你好世界!" = 13 bytes - // Truncate at byte 7 — lands inside the 3rd Chinese char (bytes 6..9). - // safe_truncate_str walks back to byte 6, returning "你好". + // Truncate at byte 7 — lands inside the 3rd Chinese char (bytes 6..9). + // safe_truncate_str walks back to byte 6, returning "你好". let truncated = safe_truncate_str(content, 7); assert_eq!(truncated, "\u{4f60}\u{597d}"); assert!(truncated.len() <= 7); @@ -300,7 +301,7 @@ mod tests { #[test] fn test_truncate_emoji_no_panic() { let content = "\u{1f4b0}\u{1f4c8}\u{1f4b9}"; // 💰📈💹 = 12 bytes - // Truncate at byte 5 — lands inside the 2nd emoji (bytes 4..8). + // Truncate at byte 5 — lands inside the 2nd emoji (bytes 4..8). let truncated = safe_truncate_str(content, 5); assert_eq!(truncated, "\u{1f4b0}"); // 4 bytes } diff --git a/crates/openfang-skills/src/clawhub.rs b/crates/openfang-skills/src/clawhub.rs index 01ee1d28..3f53a8f2 100644 --- a/crates/openfang-skills/src/clawhub.rs +++ b/crates/openfang-skills/src/clawhub.rs @@ -337,8 +337,7 @@ impl ClawHubClient { retry_after_secs = ra, "ClawHub sent Retry-After, sleeping {capped}ms" ); - tokio::time::sleep(std::time::Duration::from_millis(capped)) - .await; + tokio::time::sleep(std::time::Duration::from_millis(capped)).await; } } @@ -360,9 +359,7 @@ impl ClawHubClient { } // Non-retryable HTTP error (4xx other than 429). - return Err(SkillError::Network(format!( - "{context} returned {status}" - ))); + return Err(SkillError::Network(format!("{context} returned {status}"))); } Err(e) => { // Network / timeout error — retryable. diff --git a/crates/openfang-skills/src/loader.rs b/crates/openfang-skills/src/loader.rs index 7b8876eb..c8e3e742 100644 --- a/crates/openfang-skills/src/loader.rs +++ b/crates/openfang-skills/src/loader.rs @@ -329,11 +329,7 @@ async fn execute_shell( ) })?; - debug!( - "Executing Shell skill: {} {}", - shell, - script_path.display() - ); + debug!("Executing Shell skill: {} {}", shell, script_path.display()); // Use -s to read from stdin, -c to execute command let mut cmd = tokio::process::Command::new(&shell); diff --git a/crates/openfang-types/src/agent.rs b/crates/openfang-types/src/agent.rs index a3aef38e..42038071 100644 --- a/crates/openfang-types/src/agent.rs +++ b/crates/openfang-types/src/agent.rs @@ -275,7 +275,7 @@ impl Default for ResourceQuota { max_llm_tokens_per_hour: 0, // unlimited by default max_network_bytes_per_hour: 100 * 1024 * 1024, // 100 MB max_cost_per_hour_usd: 0.0, // unlimited by default - max_cost_per_day_usd: 0.0, // unlimited + max_cost_per_day_usd: 0.0, // unlimited max_cost_per_month_usd: 0.0, // unlimited } } @@ -1269,10 +1269,7 @@ model = "llama-3.3-70b-versatile" system_prompt = "You are a helpful assistant." "#; let manifest: AgentManifest = toml::from_str(toml_str).unwrap(); - assert_eq!( - manifest.model.system_prompt, - "You are a helpful assistant." - ); + assert_eq!(manifest.model.system_prompt, "You are a helpful assistant."); } #[test] diff --git a/crates/openfang-types/src/approval.rs b/crates/openfang-types/src/approval.rs index a53dd45f..157efed2 100644 --- a/crates/openfang-types/src/approval.rs +++ b/crates/openfang-types/src/approval.rs @@ -562,8 +562,7 @@ mod tests { #[test] fn policy_require_approval_bool_true() { // require_approval = true → ["shell_exec"] - let policy: ApprovalPolicy = - serde_json::from_str(r#"{"require_approval": true}"#).unwrap(); + let policy: ApprovalPolicy = serde_json::from_str(r#"{"require_approval": true}"#).unwrap(); assert_eq!(policy.require_approval, vec!["shell_exec"]); } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index c3ce6b2a..71748eb0 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1645,6 +1645,8 @@ pub struct ChannelsConfig { pub webhook: Option, /// LinkedIn messaging configuration (None = disabled). pub linkedin: Option, + /// WeCom/WeChat Work configuration (None = disabled). + pub wecom: Option, } /// Telegram channel adapter configuration. @@ -2418,6 +2420,44 @@ impl Default for FeishuConfig { } } +/// WeCom/WeChat Work channel adapter configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct WeComConfig { + /// WeCom corp ID. + pub corp_id: String, + /// WeCom application agent ID. + pub agent_id: String, + /// Env var name holding the application secret. + pub secret_env: String, + /// Port for the incoming webhook. + pub webhook_port: u16, + /// Callback verification token (optional, for URL verification). + pub token: Option, + /// Encoding AES key for callback (optional, for encrypted mode). + pub encoding_aes_key: Option, + /// Default agent name to route messages to. + pub default_agent: Option, + /// Per-channel behavior overrides. + #[serde(default)] + pub overrides: ChannelOverrides, +} + +impl Default for WeComConfig { + fn default() -> Self { + Self { + corp_id: String::new(), + agent_id: String::new(), + secret_env: "WECOM_SECRET".to_string(), + webhook_port: 8454, + token: None, + encoding_aes_key: None, + default_agent: None, + overrides: ChannelOverrides::default(), + } + } +} + /// Revolt (Discord-like) channel adapter configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -3908,10 +3948,7 @@ mod tests { }], ); // Auth profiles take precedence over convention (but not explicit mapping) - assert_eq!( - config.resolve_api_key_env("nvidia"), - "NVIDIA_PRIMARY_KEY" - ); + assert_eq!(config.resolve_api_key_env("nvidia"), "NVIDIA_PRIMARY_KEY"); } #[test] diff --git a/crates/openfang-types/src/message.rs b/crates/openfang-types/src/message.rs index 99be5957..d99a69c6 100644 --- a/crates/openfang-types/src/message.rs +++ b/crates/openfang-types/src/message.rs @@ -327,8 +327,12 @@ mod tests { match msg.content { MessageContent::Blocks(ref b) => { assert_eq!(b.len(), 2); - assert!(matches!(&b[0], ContentBlock::Text { text, .. } if text == "What is in this image?")); - assert!(matches!(&b[1], ContentBlock::Image { media_type, .. } if media_type == "image/jpeg")); + assert!( + matches!(&b[0], ContentBlock::Text { text, .. } if text == "What is in this image?") + ); + assert!( + matches!(&b[1], ContentBlock::Image { media_type, .. } if media_type == "image/jpeg") + ); } _ => panic!("Expected blocks content"), } diff --git a/crates/openfang-types/src/scheduler.rs b/crates/openfang-types/src/scheduler.rs index 55e97a37..946bc98f 100644 --- a/crates/openfang-types/src/scheduler.rs +++ b/crates/openfang-types/src/scheduler.rs @@ -332,9 +332,7 @@ impl CronJob { } // Workflows can run longer than agent turns (max 3600s = 1h) if *t > 3600 { - return Err(format!( - "timeout_secs too large ({t}, max 3600)" - )); + return Err(format!("timeout_secs too large ({t}, max 3600)")); } } } diff --git a/crates/openfang-types/src/serde_compat.rs b/crates/openfang-types/src/serde_compat.rs index b3b2fb77..0cad84c7 100644 --- a/crates/openfang-types/src/serde_compat.rs +++ b/crates/openfang-types/src/serde_compat.rs @@ -187,8 +187,15 @@ where return Err(de::Error::unknown_variant( other, &[ - "deny", "none", "disabled", "allowlist", "restricted", "full", - "allow", "all", "unrestricted", + "deny", + "none", + "disabled", + "allowlist", + "restricted", + "full", + "allow", + "all", + "unrestricted", ], )); } @@ -203,9 +210,8 @@ where where A: MapAccess<'de>, { - let policy = crate::config::ExecPolicy::deserialize( - de::value::MapAccessDeserializer::new(map), - )?; + let policy = + crate::config::ExecPolicy::deserialize(de::value::MapAccessDeserializer::new(map))?; Ok(Some(policy)) } diff --git a/crates/openfang-types/src/tool.rs b/crates/openfang-types/src/tool.rs index a977a5b4..a252e8f6 100644 --- a/crates/openfang-types/src/tool.rs +++ b/crates/openfang-types/src/tool.rs @@ -115,8 +115,7 @@ fn normalize_schema_recursive(schema: &serde_json::Value) -> serde_json::Value { if let Some(arr) = value.as_array() { let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); let has_null = types.contains(&"null"); - let non_null: Vec<&&str> = - types.iter().filter(|&&t| t != "null").collect(); + let non_null: Vec<&&str> = types.iter().filter(|&&t| t != "null").collect(); if has_null && non_null.len() == 1 { // ["string", "null"] → type: "string", nullable: true result.insert( @@ -139,10 +138,7 @@ fn normalize_schema_recursive(schema: &serde_json::Value) -> serde_json::Value { serde_json::Value::String(non_null[0].to_string()), ); if has_null { - result.insert( - "nullable".to_string(), - serde_json::Value::Bool(true), - ); + result.insert("nullable".to_string(), serde_json::Value::Bool(true)); } continue; } @@ -191,10 +187,7 @@ fn resolve_refs(obj: &serde_json::Map) -> serde_json: result.remove("$defs"); // Recursively replace $ref in the schema - fn inline_refs( - val: &mut serde_json::Value, - defs: &serde_json::Map, - ) { + fn inline_refs(val: &mut serde_json::Value, defs: &serde_json::Map) { match val { serde_json::Value::Object(map) => { // If this object is a $ref, replace it with the definition diff --git a/crates/openfang-wire/src/peer.rs b/crates/openfang-wire/src/peer.rs index 64229d2e..677018dd 100644 --- a/crates/openfang-wire/src/peer.rs +++ b/crates/openfang-wire/src/peer.rs @@ -51,11 +51,15 @@ impl NonceTracker { let now = Instant::now(); // Garbage-collect expired nonces (older than window) - self.seen.retain(|_, ts| now.duration_since(*ts) < self.window); + self.seen + .retain(|_, ts| now.duration_since(*ts) < self.window); // Check for replay if self.seen.contains_key(nonce) { - return Err(format!("Nonce replay detected: {}", openfang_types::truncate_str(nonce, 16))); + return Err(format!( + "Nonce replay detected: {}", + openfang_types::truncate_str(nonce, 16) + )); } // Record the nonce @@ -287,11 +291,7 @@ impl PeerNode { } // SECURITY: Derive per-session key for authenticated messages - let key = derive_session_key( - &self.config.shared_secret, - &our_nonce, - ack_nonce, - ); + let key = derive_session_key(&self.config.shared_secret, &our_nonce, ack_nonce); info!( "OFP: handshake complete with {} ({}) — {} agents", @@ -333,8 +333,15 @@ impl PeerNode { // Spawn a task to handle ongoing communication with per-message HMAC let registry = self.registry.clone(); tokio::spawn(async move { - if let Err(e) = - connection_loop(&mut reader, &mut writer, &peer_node_id, ®istry, &*handle, Some(&sess_key)).await + if let Err(e) = connection_loop( + &mut reader, + &mut writer, + &peer_node_id, + ®istry, + &*handle, + Some(&sess_key), + ) + .await { debug!("OFP: connection to {} ended: {}", peer_node_id, e); } @@ -573,8 +580,8 @@ impl PeerNode { // SECURITY: Derive per-session key (server side: their nonce first, our nonce second) let session_key = derive_session_key( &node.config.shared_secret, - nonce, // client's nonce - &ack_nonce, // our nonce + nonce, // client's nonce + &ack_nonce, // our nonce ); info!( @@ -622,8 +629,15 @@ impl PeerNode { }; // Enter the message dispatch loop with per-message HMAC - if let Err(e) = - connection_loop(&mut reader, &mut writer, &peer_node_id, registry, handle, Some(&session_key)).await + if let Err(e) = connection_loop( + &mut reader, + &mut writer, + &peer_node_id, + registry, + handle, + Some(&session_key), + ) + .await { debug!("OFP: connection with {} ended: {}", peer_node_id, e); } @@ -940,8 +954,7 @@ pub async fn broadcast_notification( // SECURITY: Derive a per-message key from shared secret + fresh nonce let nonce = uuid::Uuid::new_v4().to_string(); let session_key = hmac_sign(shared_secret, nonce.as_bytes()); - if let Err(e) = write_message_authenticated(&mut writer, &msg, &session_key).await - { + if let Err(e) = write_message_authenticated(&mut writer, &msg, &session_key).await { errors.push((peer.node_id.clone(), e)); } }