diff --git a/Cargo.lock b/Cargo.lock index 73ba5baf3..63cd40937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -672,6 +681,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -2885,6 +2903,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -3831,9 +3850,11 @@ dependencies = [ name = "openfang-channels" version = "0.3.46" dependencies = [ + "aes", "async-trait", "axum", "base64 0.22.1", + "cbc", "chrono", "dashmap", "futures", @@ -3846,8 +3867,10 @@ dependencies = [ "native-tls", "openfang-types", "reqwest 0.12.28", + "roxmltree", "serde", "serde_json", + "sha1", "sha2", "tokio", "tokio-stream", @@ -5315,6 +5338,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 a6d845e33..be11cde5f 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 1477f85b5..5dca01e37 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -50,6 +50,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; @@ -545,7 +546,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}"), } @@ -780,6 +785,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { "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, } } @@ -1119,7 +1125,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(); @@ -1384,6 +1392,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 diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index c2e063f4e..d1393ab85 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 { @@ -170,15 +174,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 { @@ -464,20 +466,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(), }, ); @@ -501,8 +502,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 { .. } => {} @@ -547,9 +547,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()) @@ -557,8 +555,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); } @@ -1896,6 +1893,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). @@ -1941,6 +1956,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, } } @@ -1990,9 +2006,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); } } @@ -2012,46 +2026,170 @@ 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()), - "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()), + "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, } } @@ -2173,7 +2311,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), + ); } } @@ -3177,7 +3318,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) => { @@ -3190,9 +3333,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})), ) } } @@ -3245,7 +3386,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) => { @@ -3258,9 +3401,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})), ) } } @@ -3953,7 +4094,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, @@ -4109,7 +4255,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.")}), + ), ), } } @@ -4236,7 +4384,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) + ); } } } @@ -4906,7 +5057,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"}), + ), ); } @@ -5223,7 +5376,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 { ( @@ -5741,7 +5896,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)}), + ), ); } @@ -6455,11 +6612,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) => ( @@ -6544,10 +6708,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}")})), @@ -6757,10 +6918,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('-', "_")) }) }; @@ -6816,7 +6974,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 { @@ -6829,7 +6991,8 @@ pub async fn set_provider_key( if let Ok(existing) = std::fs::read_to_string(&config_path) { // Remove existing [default_model] section if present, then append 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); } @@ -6891,9 +7054,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)) @@ -7097,8 +7261,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() { @@ -7992,7 +8155,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)).await { + match state + .kernel + .send_message_with_handle(target_agent, &run_message, Some(kernel_handle)) + .await + { Ok(result) => ( StatusCode::OK, Json(serde_json::json!({ @@ -8146,7 +8313,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)")}), + ), ); } } @@ -8154,7 +8323,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)")}), + ), ); } } @@ -8162,7 +8333,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)")}), + ), ); } } @@ -9154,12 +9327,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() @@ -10013,8 +10192,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), }, ); @@ -10078,7 +10256,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}")}), + ), ); } @@ -10282,15 +10462,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() }; @@ -10298,12 +10493,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, @@ -10315,8 +10516,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, }) } @@ -10367,9 +10576,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::< @@ -10538,3 +10745,55 @@ 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_eq!( + meta.fields + .iter() + .find(|f| f.key == "corp_id") + .unwrap() + .required, + true + ); + assert_eq!( + meta.fields + .iter() + .find(|f| f.key == "secret_env") + .unwrap() + .required, + true + ); + } +} diff --git a/crates/openfang-channels/Cargo.toml b/crates/openfang-channels/Cargo.toml index 49e572507..5dd3520c5 100644 --- a/crates/openfang-channels/Cargo.toml +++ b/crates/openfang-channels/Cargo.toml @@ -24,9 +24,13 @@ 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 } +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 3a8c3d68a..afd2046fc 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::Instant; use tokio::sync::watch; @@ -401,7 +401,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 { @@ -415,6 +419,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. @@ -448,11 +461,7 @@ 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) @@ -483,7 +492,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 { .. }); @@ -529,9 +540,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, @@ -552,17 +570,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 } => { @@ -747,7 +774,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; } @@ -766,7 +800,14 @@ async fn dispatch_message( send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await; 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) => { @@ -887,7 +928,10 @@ 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 }]; @@ -991,7 +1035,14 @@ async fn dispatch_with_blocks( send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await; 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) => { @@ -1460,6 +1511,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 diff --git a/crates/openfang-channels/src/formatter.rs b/crates/openfang-channels/src/formatter.rs index 0ffface72..a412281a4 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: ``, ``, ``, `
`, ``.
@@ -146,6 +155,188 @@ 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();
@@ -254,4 +445,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 978c202e7..bb67773b8 100644
--- a/crates/openfang-channels/src/lib.rs
+++ b/crates/openfang-channels/src/lib.rs
@@ -50,3 +50,4 @@ pub mod linkedin;
 pub mod mumble;
 pub mod ntfy;
 pub mod webhook;
+pub mod wecom;
diff --git a/crates/openfang-channels/src/wecom.rs b/crates/openfang-channels/src/wecom.rs
new file mode 100644
index 000000000..2bacb3531
--- /dev/null
+++ b/crates/openfang-channels/src/wecom.rs
@@ -0,0 +1,688 @@
+//! 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 API base URL.
+const WECOM_API_HOST: &str = "https://qyapi.weixin.qq.com";
+
+/// 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-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs
index 0b1b1cbe2..c239d7e02 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;
@@ -148,9 +148,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.
@@ -659,9 +661,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 {
@@ -819,7 +819,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");
@@ -1028,11 +1031,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,
@@ -1113,10 +1121,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);
                             }
                         }
                     }
@@ -1255,9 +1268,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);
@@ -1730,7 +1744,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(),
+                ),
             };
             manifest.model.system_prompt =
                 openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -2201,7 +2219,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(),
+                ),
             };
             manifest.model.system_prompt =
                 openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -2653,7 +2675,6 @@ 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.
@@ -3097,10 +3118,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.
@@ -3552,7 +3569,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
@@ -3578,14 +3597,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() {
@@ -3792,7 +3814,9 @@ 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)),
@@ -4239,8 +4263,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()
@@ -4714,7 +4740,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() {
@@ -4856,7 +4887,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 {
@@ -5056,8 +5088,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
@@ -5074,16 +5106,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())
@@ -5653,7 +5695,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
@@ -5712,7 +5767,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'."
+                ));
             }
         };
 
@@ -5728,7 +5785,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(
@@ -6095,4 +6155,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-types/src/config.rs b/crates/openfang-types/src/config.rs
index 76c3a5107..6e60f9e4b 100644
--- a/crates/openfang-types/src/config.rs
+++ b/crates/openfang-types/src/config.rs
@@ -1582,6 +1582,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.
@@ -2315,6 +2317,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)]