construct/channels/
wecom.rs1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3
4pub 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 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 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 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}