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 let message = resp_data
120 .get("msg")
121 .and_then(|m| m.as_str())
122 .unwrap_or("unknown error")
123 .to_owned();
124 return Err(LarkWebhookError::ApiError { code, message });
125 }
126 Ok(resp_data)
127 }
128}
129
130pub(crate) fn gen_sign(timestamp: &str, secret: &str) -> String {
131 let key = format!("{timestamp}\n{secret}");
132 let mut mac =
133 Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
134 mac.update(b"");
135 let result = mac.finalize();
136 base64::engine::general_purpose::STANDARD.encode(result.into_bytes())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn test_gen_sign_non_empty() {
145 let result = gen_sign("1234567890", "test_secret");
146 assert_eq!(result.len(), 44);
148 use base64::Engine;
150 let decoded = base64::engine::general_purpose::STANDARD
151 .decode(&result)
152 .unwrap();
153 assert_eq!(decoded.len(), 32);
154 }
155
156 #[test]
157 fn test_gen_sign_deterministic() {
158 let a = gen_sign("ts", "sec");
159 let b = gen_sign("ts", "sec");
160 assert_eq!(a, b);
161 }
162
163 #[test]
164 fn test_gen_sign_different_inputs() {
165 let a = gen_sign("ts1", "sec");
166 let b = gen_sign("ts2", "sec");
167 assert_ne!(a, b);
168 }
169
170 #[test]
171 fn test_gen_sign_known_vector() {
172 let expected = "3H7JNC7ltBAwibQHFO1KFVN9HTkLtm2virjdsmGcAzw=";
174 assert_eq!(gen_sign("1234567890", "test_secret"), expected);
175 }
176
177 #[test]
178 fn test_from_params_missing_url() {
179 let result = LarkWebhookNotifier::from_params("", "secret");
180 assert!(result.is_err());
181 }
182
183 #[test]
184 fn test_from_params_missing_secret() {
185 let result = LarkWebhookNotifier::from_params("https://example.com", "");
186 assert!(result.is_err());
187 }
188
189 #[test]
190 fn test_from_params_ok() {
191 let result = LarkWebhookNotifier::from_params("https://example.com", "secret");
192 assert!(result.is_ok());
193 }
194}