lark_webhook_notify/
client.rs1use base64::Engine;
2use hmac::{Hmac, Mac};
3use serde_json::{Value, json};
4use sha2::Sha256;
5
6use crate::config::LarkWebhookSettings;
7use crate::error::{LarkWebhookError, Result};
8use crate::templates::{CardContent, LarkTemplate};
9
10#[derive(Debug)]
34pub struct LarkWebhookNotifier {
35 webhook_url: String,
36 webhook_secret: String,
37 client: reqwest::blocking::Client,
38}
39
40impl LarkWebhookNotifier {
41 pub fn new(settings: LarkWebhookSettings) -> Result<Self> {
45 let webhook_url = settings.webhook_url.ok_or_else(|| {
46 LarkWebhookError::Config(
47 "webhook_url is required. Set via LARK_WEBHOOK_URL env var, config file, or direct param".to_owned()
48 )
49 })?;
50 let webhook_secret = settings.webhook_secret.ok_or_else(|| {
51 LarkWebhookError::Config(
52 "webhook_secret is required. Set via LARK_WEBHOOK_SECRET env var, config file, or direct param".to_owned()
53 )
54 })?;
55 if webhook_url.is_empty() {
56 return Err(LarkWebhookError::Config(
57 "webhook_url cannot be empty".to_owned(),
58 ));
59 }
60 if webhook_secret.is_empty() {
61 return Err(LarkWebhookError::Config(
62 "webhook_secret cannot be empty".to_owned(),
63 ));
64 }
65 Ok(Self {
66 webhook_url,
67 webhook_secret,
68 client: reqwest::blocking::Client::new(),
69 })
70 }
71
72 pub fn from_params(url: &str, secret: &str) -> Result<Self> {
74 if url.is_empty() {
75 return Err(LarkWebhookError::Config(
76 "webhook_url cannot be empty".to_owned(),
77 ));
78 }
79 if secret.is_empty() {
80 return Err(LarkWebhookError::Config(
81 "webhook_secret cannot be empty".to_owned(),
82 ));
83 }
84 Ok(Self {
85 webhook_url: url.to_owned(),
86 webhook_secret: secret.to_owned(),
87 client: reqwest::blocking::Client::new(),
88 })
89 }
90
91 pub fn send_template(&self, template: &dyn LarkTemplate) -> Result<Value> {
93 let content = template.generate();
94 self.send_raw_content(content)
95 }
96
97 pub fn send_raw_content(&self, content: CardContent) -> Result<Value> {
99 let payload = self.create_payload(content);
100 self.send_payload(payload)
101 }
102
103 fn create_payload(&self, content: CardContent) -> Value {
104 let timestamp = chrono::Utc::now().timestamp().to_string();
105 let sign = gen_sign(×tamp, &self.webhook_secret);
106 json!({
107 "timestamp": timestamp,
108 "sign": sign,
109 "msg_type": "interactive",
110 "card": content,
111 })
112 }
113
114 fn send_payload(&self, payload: Value) -> Result<Value> {
115 let resp = self.client.post(&self.webhook_url).json(&payload).send()?;
116 let resp_data: Value = resp.error_for_status()?.json()?;
117 if let Some(code) = resp_data.get("code").and_then(|c| c.as_i64())
118 && code != 0
119 {
120 let message = resp_data
121 .get("msg")
122 .and_then(|m| m.as_str())
123 .unwrap_or("unknown error")
124 .to_owned();
125 return Err(LarkWebhookError::ApiError { code, message });
126 }
127 Ok(resp_data)
128 }
129}
130
131pub(crate) fn gen_sign(timestamp: &str, secret: &str) -> String {
132 let key = format!("{timestamp}\n{secret}");
133 let mut mac =
134 Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
135 mac.update(b"");
136 let result = mac.finalize();
137 base64::engine::general_purpose::STANDARD.encode(result.into_bytes())
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn test_gen_sign_non_empty() {
146 let result = gen_sign("1234567890", "test_secret");
147 assert_eq!(result.len(), 44);
149 use base64::Engine;
151 let decoded = base64::engine::general_purpose::STANDARD
152 .decode(&result)
153 .unwrap();
154 assert_eq!(decoded.len(), 32);
155 }
156
157 #[test]
158 fn test_gen_sign_deterministic() {
159 let a = gen_sign("ts", "sec");
160 let b = gen_sign("ts", "sec");
161 assert_eq!(a, b);
162 }
163
164 #[test]
165 fn test_gen_sign_different_inputs() {
166 let a = gen_sign("ts1", "sec");
167 let b = gen_sign("ts2", "sec");
168 assert_ne!(a, b);
169 }
170
171 #[test]
172 fn test_gen_sign_known_vector() {
173 let expected = "3H7JNC7ltBAwibQHFO1KFVN9HTkLtm2virjdsmGcAzw=";
175 assert_eq!(gen_sign("1234567890", "test_secret"), expected);
176 }
177
178 #[test]
179 fn test_from_params_missing_url() {
180 let result = LarkWebhookNotifier::from_params("", "secret");
181 assert!(result.is_err());
182 }
183
184 #[test]
185 fn test_from_params_missing_secret() {
186 let result = LarkWebhookNotifier::from_params("https://example.com", "");
187 assert!(result.is_err());
188 }
189
190 #[test]
191 fn test_from_params_ok() {
192 let result = LarkWebhookNotifier::from_params("https://example.com", "secret");
193 assert!(result.is_ok());
194 }
195}