Skip to main content

zeroclaw_channels/
wecom.rs

1use async_trait::async_trait;
2use std::sync::Arc;
3use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
4
5/// WeCom (WeChat Enterprise) Bot Webhook channel.
6///
7/// Sends messages via the WeCom Bot Webhook API. Incoming messages are received
8/// through a configurable callback URL that WeCom posts to.
9pub struct WeComChannel {
10    webhook_key: String,
11    /// The alias key under `[channels.wecom.<alias>]` this handle is
12    /// bound to. Used to scope peer-group writes and resolver lookups.
13    alias: String,
14    /// Resolves inbound external peers from canonical state at message-time.
15    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
16    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
17}
18
19impl WeComChannel {
20    pub fn new(
21        webhook_key: String,
22        alias: impl Into<String>,
23        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
24    ) -> Self {
25        Self {
26            webhook_key,
27            alias: alias.into(),
28            peer_resolver,
29        }
30    }
31
32    fn http_client(&self) -> reqwest::Client {
33        zeroclaw_config::schema::build_runtime_proxy_client("channel.wecom")
34    }
35
36    fn webhook_url(&self) -> String {
37        format!(
38            "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={}",
39            self.webhook_key
40        )
41    }
42
43    /// Check whether `user_id` is on the allowlist for this WeCom channel.
44    ///
45    /// WeCom Bot Webhook is send-only, so this gate is exercised only by
46    /// callback flows the gateway routes back through this channel handle.
47    /// The `alias` is included in the trace span so multi-WeCom deployments
48    /// can distinguish which channel made the decision.
49    pub fn is_user_allowed(&self, user_id: &str) -> bool {
50        let peers = (self.peer_resolver)();
51        let allowed =
52            crate::allowlist::is_user_allowed(&peers, user_id, crate::allowlist::Match::Sensitive);
53        ::zeroclaw_log::record!(TRACE, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"channel": "wecom", "alias": self.alias, "user_id": user_id, "allowed": allowed})), "wecom allowlist decision");
54        allowed
55    }
56}
57
58impl ::zeroclaw_api::attribution::Attributable for WeComChannel {
59    fn role(&self) -> ::zeroclaw_api::attribution::Role {
60        ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::WeCom)
61    }
62    fn alias(&self) -> &str {
63        &self.alias
64    }
65}
66
67#[async_trait]
68impl Channel for WeComChannel {
69    fn name(&self) -> &str {
70        "wecom"
71    }
72
73    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
74        let body = serde_json::json!({
75            "msgtype": "text",
76            "text": {
77                "content": message.content,
78            }
79        });
80
81        let resp = self
82            .http_client()
83            .post(self.webhook_url())
84            .json(&body)
85            .send()
86            .await?;
87
88        if !resp.status().is_success() {
89            let status = resp.status();
90            let err = resp.text().await.unwrap_or_default();
91            anyhow::bail!("WeCom webhook send failed ({status}): {err}");
92        }
93
94        // WeCom returns {"errcode":0,"errmsg":"ok"} on success.
95        let result: serde_json::Value = resp.json().await?;
96        let errcode = result.get("errcode").and_then(|v| v.as_i64()).unwrap_or(-1);
97        if errcode != 0 {
98            let errmsg = result
99                .get("errmsg")
100                .and_then(|v| v.as_str())
101                .unwrap_or("unknown error");
102            anyhow::bail!("WeCom API error (errcode={errcode}): {errmsg}");
103        }
104
105        Ok(())
106    }
107
108    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
109        // WeCom Bot Webhook is send-only by default. For receiving messages,
110        // an enterprise application with a callback URL is needed, which is
111        // handled via the gateway webhook subsystem.
112        //
113        // This listener keeps the channel alive and waits for the sender to close.
114        ::zeroclaw_log::record!(
115            INFO,
116            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
117            "channel ready (send-only via Bot Webhook)"
118        );
119        tx.closed().await;
120        Ok(())
121    }
122
123    async fn health_check(&self) -> bool {
124        // Verify we can reach the WeCom API endpoint.
125        let resp = self
126            .http_client()
127            .post(self.webhook_url())
128            .json(&serde_json::json!({
129                "msgtype": "text",
130                "text": {
131                    "content": "health_check"
132                }
133            }))
134            .send()
135            .await;
136
137        match resp {
138            Ok(r) => r.status().is_success(),
139            Err(_) => false,
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_name() {
150        let ch = WeComChannel::new("test-key".into(), "wecom_test_alias", Arc::new(Vec::new));
151        assert_eq!(ch.name(), "wecom");
152    }
153
154    #[test]
155    fn test_webhook_url() {
156        let ch = WeComChannel::new("abc-123".into(), "wecom_test_alias", Arc::new(Vec::new));
157        assert_eq!(
158            ch.webhook_url(),
159            "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abc-123"
160        );
161    }
162
163    #[test]
164    fn test_user_allowed_wildcard() {
165        let ch = WeComChannel::new(
166            "key".into(),
167            "wecom_test_alias",
168            Arc::new(|| vec!["*".into()]),
169        );
170        assert!(ch.is_user_allowed("anyone"));
171    }
172
173    #[test]
174    fn test_user_allowed_specific() {
175        let ch = WeComChannel::new(
176            "key".into(),
177            "wecom_test_alias",
178            Arc::new(|| vec!["user123".into()]),
179        );
180        assert!(ch.is_user_allowed("user123"));
181        assert!(!ch.is_user_allowed("other"));
182    }
183
184    #[test]
185    fn test_user_denied_empty() {
186        let ch = WeComChannel::new("key".into(), "wecom_test_alias", Arc::new(Vec::new));
187        assert!(!ch.is_user_allowed("anyone"));
188    }
189
190    #[test]
191    fn v2_allowed_users_fold_into_peer_groups() {
192        // V2 `[channels.wecom].allowed_users` migrates into a synthesized
193        // `[peer_groups.wecom_default]` block in V3. The wildcard sentinel is
194        // filtered out during synthesis so only concrete usernames survive as
195        // external peers.
196        let v2_toml = r#"
197schema_version = 2
198
199[channels.wecom]
200enabled = true
201webhook_key = "key-abc-123"
202allowed_users = ["user1", "*"]
203"#;
204        let cfg = zeroclaw_config::migration::migrate_to_current(v2_toml)
205            .expect("V2 wecom config migrates to V3");
206        let wecom = cfg
207            .channels
208            .wecom
209            .get("default")
210            .expect("V2 wecom folds under alias `default`");
211        assert_eq!(wecom.webhook_key, "key-abc-123");
212
213        let group = cfg
214            .peer_groups
215            .get("wecom_default")
216            .expect("wecom allow-list synthesizes [peer_groups.wecom_default]");
217        assert_eq!(group.channel, "wecom");
218        let peers: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
219        assert_eq!(peers, vec!["user1"]);
220    }
221
222    #[test]
223    fn v2_no_allowed_users_synthesizes_no_peer_group() {
224        // V2 wecom without `allowed_users` must not synthesize a peer group.
225        let v2_toml = r#"
226schema_version = 2
227
228[channels.wecom]
229enabled = true
230webhook_key = "key"
231"#;
232        let cfg = zeroclaw_config::migration::migrate_to_current(v2_toml)
233            .expect("V2 wecom config without allowed_users migrates");
234        assert!(
235            !cfg.peer_groups.contains_key("wecom_default"),
236            "no peer group synthesized when allowed_users is absent"
237        );
238    }
239}