Skip to main content

construct/channels/
wecom.rs

1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3
4/// WeCom (WeChat Enterprise) Bot Webhook channel.
5///
6/// Sends messages via the WeCom Bot Webhook API. Incoming messages are received
7/// through a configurable callback URL that WeCom posts to.
8pub struct WeComChannel {
9    webhook_key: String,
10    allowed_users: Vec<String>,
11}
12
13impl WeComChannel {
14    pub fn new(webhook_key: String, allowed_users: Vec<String>) -> Self {
15        Self {
16            webhook_key,
17            allowed_users,
18        }
19    }
20
21    fn http_client(&self) -> reqwest::Client {
22        crate::config::build_runtime_proxy_client("channel.wecom")
23    }
24
25    fn webhook_url(&self) -> String {
26        format!(
27            "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={}",
28            self.webhook_key
29        )
30    }
31
32    fn is_user_allowed(&self, user_id: &str) -> bool {
33        self.allowed_users.iter().any(|u| u == "*" || u == user_id)
34    }
35}
36
37#[async_trait]
38impl Channel for WeComChannel {
39    fn name(&self) -> &str {
40        "wecom"
41    }
42
43    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
44        let body = serde_json::json!({
45            "msgtype": "text",
46            "text": {
47                "content": message.content,
48            }
49        });
50
51        let resp = self
52            .http_client()
53            .post(self.webhook_url())
54            .json(&body)
55            .send()
56            .await?;
57
58        if !resp.status().is_success() {
59            let status = resp.status();
60            let err = resp.text().await.unwrap_or_default();
61            anyhow::bail!("WeCom webhook send failed ({status}): {err}");
62        }
63
64        // WeCom returns {"errcode":0,"errmsg":"ok"} on success.
65        let result: serde_json::Value = resp.json().await?;
66        let errcode = result.get("errcode").and_then(|v| v.as_i64()).unwrap_or(-1);
67        if errcode != 0 {
68            let errmsg = result
69                .get("errmsg")
70                .and_then(|v| v.as_str())
71                .unwrap_or("unknown error");
72            anyhow::bail!("WeCom API error (errcode={errcode}): {errmsg}");
73        }
74
75        Ok(())
76    }
77
78    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
79        // WeCom Bot Webhook is send-only by default. For receiving messages,
80        // an enterprise application with a callback URL is needed, which is
81        // handled via the gateway webhook subsystem.
82        //
83        // This listener keeps the channel alive and waits for the sender to close.
84        tracing::info!("WeCom: channel ready (send-only via Bot Webhook)");
85        tx.closed().await;
86        Ok(())
87    }
88
89    async fn health_check(&self) -> bool {
90        // Verify we can reach the WeCom API endpoint.
91        let resp = self
92            .http_client()
93            .post(self.webhook_url())
94            .json(&serde_json::json!({
95                "msgtype": "text",
96                "text": {
97                    "content": "health_check"
98                }
99            }))
100            .send()
101            .await;
102
103        match resp {
104            Ok(r) => r.status().is_success(),
105            Err(_) => false,
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_name() {
116        let ch = WeComChannel::new("test-key".into(), vec![]);
117        assert_eq!(ch.name(), "wecom");
118    }
119
120    #[test]
121    fn test_webhook_url() {
122        let ch = WeComChannel::new("abc-123".into(), vec![]);
123        assert_eq!(
124            ch.webhook_url(),
125            "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abc-123"
126        );
127    }
128
129    #[test]
130    fn test_user_allowed_wildcard() {
131        let ch = WeComChannel::new("key".into(), vec!["*".into()]);
132        assert!(ch.is_user_allowed("anyone"));
133    }
134
135    #[test]
136    fn test_user_allowed_specific() {
137        let ch = WeComChannel::new("key".into(), vec!["user123".into()]);
138        assert!(ch.is_user_allowed("user123"));
139        assert!(!ch.is_user_allowed("other"));
140    }
141
142    #[test]
143    fn test_user_denied_empty() {
144        let ch = WeComChannel::new("key".into(), vec![]);
145        assert!(!ch.is_user_allowed("anyone"));
146    }
147
148    #[test]
149    fn test_config_serde() {
150        let toml_str = r#"
151webhook_key = "key-abc-123"
152allowed_users = ["user1", "*"]
153"#;
154        let config: crate::config::schema::WeComConfig = toml::from_str(toml_str).unwrap();
155        assert_eq!(config.webhook_key, "key-abc-123");
156        assert_eq!(config.allowed_users, vec!["user1", "*"]);
157    }
158
159    #[test]
160    fn test_config_serde_defaults() {
161        let toml_str = r#"
162webhook_key = "key"
163"#;
164        let config: crate::config::schema::WeComConfig = toml::from_str(toml_str).unwrap();
165        assert!(config.allowed_users.is_empty());
166    }
167}