zeroclaw_channels/
wecom.rs1use async_trait::async_trait;
2use std::sync::Arc;
3use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
4
5pub struct WeComChannel {
10 webhook_key: String,
11 alias: String,
14 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 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 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 ::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 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 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 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}