diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 960143724..63e7f7ffb 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -38,6 +38,7 @@ pub mod traits; pub mod transcription; pub mod tts; pub mod wati; +pub mod wecom; pub mod whatsapp; #[cfg(feature = "whatsapp-web")] pub mod whatsapp_storage; @@ -68,6 +69,7 @@ pub use traits::{Channel, SendMessage}; #[allow(unused_imports)] pub use tts::{TtsManager, TtsProvider}; pub use wati::WatiChannel; +pub use wecom::WeComChannel; pub use whatsapp::WhatsAppChannel; #[cfg(feature = "whatsapp-web")] pub use whatsapp_web::WhatsAppWebChannel; @@ -3270,6 +3272,16 @@ fn collect_configured_channels( }); } + if let Some(ref wc) = config.channels_config.wecom { + channels.push(ConfiguredChannel { + display_name: "WeCom", + channel: Arc::new(WeComChannel::new( + wc.webhook_key.clone(), + wc.allowed_users.clone(), + )), + }); + } + if let Some(ref ct) = config.channels_config.clawdtalk { channels.push(ConfiguredChannel { display_name: "ClawdTalk", diff --git a/src/channels/wecom.rs b/src/channels/wecom.rs new file mode 100644 index 000000000..862b15d75 --- /dev/null +++ b/src/channels/wecom.rs @@ -0,0 +1,167 @@ +use super::traits::{Channel, ChannelMessage, SendMessage}; +use async_trait::async_trait; + +/// WeCom (WeChat Enterprise) Bot Webhook channel. +/// +/// Sends messages via the WeCom Bot Webhook API. Incoming messages are received +/// through a configurable callback URL that WeCom posts to. +pub struct WeComChannel { + webhook_key: String, + allowed_users: Vec, +} + +impl WeComChannel { + pub fn new(webhook_key: String, allowed_users: Vec) -> Self { + Self { + webhook_key, + allowed_users, + } + } + + fn http_client(&self) -> reqwest::Client { + crate::config::build_runtime_proxy_client("channel.wecom") + } + + fn webhook_url(&self) -> String { + format!( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={}", + self.webhook_key + ) + } + + fn is_user_allowed(&self, user_id: &str) -> bool { + self.allowed_users.iter().any(|u| u == "*" || u == user_id) + } +} + +#[async_trait] +impl Channel for WeComChannel { + fn name(&self) -> &str { + "wecom" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + let body = serde_json::json!({ + "msgtype": "text", + "text": { + "content": message.content, + } + }); + + let resp = self + .http_client() + .post(self.webhook_url()) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("WeCom webhook send failed ({status}): {err}"); + } + + // WeCom returns {"errcode":0,"errmsg":"ok"} on success. + let result: serde_json::Value = resp.json().await?; + let errcode = result.get("errcode").and_then(|v| v.as_i64()).unwrap_or(-1); + if errcode != 0 { + let errmsg = result + .get("errmsg") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("WeCom API error (errcode={errcode}): {errmsg}"); + } + + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + // WeCom Bot Webhook is send-only by default. For receiving messages, + // an enterprise application with a callback URL is needed, which is + // handled via the gateway webhook subsystem. + // + // This listener keeps the channel alive and waits for the sender to close. + tracing::info!("WeCom: channel ready (send-only via Bot Webhook)"); + tx.closed().await; + Ok(()) + } + + async fn health_check(&self) -> bool { + // Verify we can reach the WeCom API endpoint. + let resp = self + .http_client() + .post(self.webhook_url()) + .json(&serde_json::json!({ + "msgtype": "text", + "text": { + "content": "health_check" + } + })) + .send() + .await; + + match resp { + Ok(r) => r.status().is_success(), + Err(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_name() { + let ch = WeComChannel::new("test-key".into(), vec![]); + assert_eq!(ch.name(), "wecom"); + } + + #[test] + fn test_webhook_url() { + let ch = WeComChannel::new("abc-123".into(), vec![]); + assert_eq!( + ch.webhook_url(), + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abc-123" + ); + } + + #[test] + fn test_user_allowed_wildcard() { + let ch = WeComChannel::new("key".into(), vec!["*".into()]); + assert!(ch.is_user_allowed("anyone")); + } + + #[test] + fn test_user_allowed_specific() { + let ch = WeComChannel::new("key".into(), vec!["user123".into()]); + assert!(ch.is_user_allowed("user123")); + assert!(!ch.is_user_allowed("other")); + } + + #[test] + fn test_user_denied_empty() { + let ch = WeComChannel::new("key".into(), vec![]); + assert!(!ch.is_user_allowed("anyone")); + } + + #[test] + fn test_config_serde() { + let toml_str = r#" +webhook_key = "key-abc-123" +allowed_users = ["user1", "*"] +"#; + let config: crate::config::schema::WeComConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.webhook_key, "key-abc-123"); + assert_eq!(config.allowed_users, vec!["user1", "*"]); + } + + #[test] + fn test_config_serde_defaults() { + let toml_str = r#" +webhook_key = "key" +"#; + let config: crate::config::schema::WeComConfig = toml::from_str(toml_str).unwrap(); + assert!(config.allowed_users.is_empty()); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 254976373..54057c1aa 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -3013,6 +3013,8 @@ pub struct ChannelsConfig { pub feishu: Option, /// DingTalk channel configuration. pub dingtalk: Option, + /// WeCom (WeChat Enterprise) Bot Webhook channel configuration. + pub wecom: Option, /// QQ Official Bot channel configuration. pub qq: Option, #[cfg(feature = "channel-nostr")] @@ -3101,6 +3103,10 @@ impl ChannelsConfig { Box::new(ConfigWrapper::new(self.dingtalk.as_ref())), self.dingtalk.is_some(), ), + ( + Box::new(ConfigWrapper::new(self.wecom.as_ref())), + self.wecom.is_some(), + ), ( Box::new(ConfigWrapper::new(self.qq.as_ref())), self.qq.is_some() @@ -3152,6 +3158,7 @@ impl Default for ChannelsConfig { lark: None, feishu: None, dingtalk: None, + wecom: None, qq: None, #[cfg(feature = "channel-nostr")] nostr: None, @@ -3964,6 +3971,25 @@ impl ChannelConfig for DingTalkConfig { } } +/// WeCom (WeChat Enterprise) Bot Webhook configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WeComConfig { + /// Webhook key from WeCom Bot configuration + pub webhook_key: String, + /// Allowed user IDs. Empty = deny all, "*" = allow all + #[serde(default)] + pub allowed_users: Vec, +} + +impl ChannelConfig for WeComConfig { + fn name() -> &'static str { + "WeCom" + } + fn desc() -> &'static str { + "WeCom Bot Webhook" + } +} + /// QQ Official Bot configuration (Tencent QQ Bot SDK) #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct QQConfig { @@ -4749,6 +4775,13 @@ impl Config { "config.channels_config.dingtalk.client_secret", )?; } + if let Some(ref mut wc) = config.channels_config.wecom { + decrypt_secret( + &store, + &mut wc.webhook_key, + "config.channels_config.wecom.webhook_key", + )?; + } if let Some(ref mut qq) = config.channels_config.qq { decrypt_secret( &store, @@ -5615,6 +5648,13 @@ impl Config { "config.channels_config.dingtalk.client_secret", )?; } + if let Some(ref mut wc) = config_to_save.channels_config.wecom { + encrypt_secret( + &store, + &mut wc.webhook_key, + "config.channels_config.wecom.webhook_key", + )?; + } if let Some(ref mut qq) = config_to_save.channels_config.qq { encrypt_secret( &store, @@ -6062,6 +6102,7 @@ default_temperature = 0.7 lark: None, feishu: None, dingtalk: None, + wecom: None, qq: None, #[cfg(feature = "channel-nostr")] nostr: None, @@ -6737,6 +6778,7 @@ allowed_users = ["@ops:matrix.org"] lark: None, feishu: None, dingtalk: None, + wecom: None, qq: None, nostr: None, clawdtalk: None, @@ -6952,6 +6994,7 @@ channel_id = "C123" lark: None, feishu: None, dingtalk: None, + wecom: None, qq: None, nostr: None, clawdtalk: None,