Skip to main content

lark_webhook_notify/
client.rs

1use 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/// HTTP client for sending cards to a Lark (Feishu) group bot webhook.
11///
12/// Handles HMAC-SHA256 request signing automatically on every send.
13///
14/// # Construction
15///
16/// Use [`LarkWebhookNotifier::from_params`] for quick setup, or
17/// [`LarkWebhookNotifier::new`] with a [`LarkWebhookSettings`] loaded from
18/// environment variables or a TOML file:
19///
20/// ```no_run
21/// use lark_webhook_notify::{LarkWebhookNotifier, LarkWebhookSettings};
22///
23/// # fn main() -> lark_webhook_notify::Result<()> {
24/// // From env vars / config file
25/// let settings = LarkWebhookSettings::load(None, None, None)?;
26/// let notifier = LarkWebhookNotifier::new(settings)?;
27///
28/// // Or directly
29/// let notifier = LarkWebhookNotifier::from_params("https://...", "secret")?;
30/// # Ok(())
31/// # }
32/// ```
33#[derive(Debug)]
34pub struct LarkWebhookNotifier {
35    webhook_url: String,
36    webhook_secret: String,
37    client: reqwest::blocking::Client,
38}
39
40impl LarkWebhookNotifier {
41    /// Create a notifier from a [`LarkWebhookSettings`] instance.
42    ///
43    /// Returns an error if `webhook_url` or `webhook_secret` is missing or empty.
44    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    /// Create a notifier by passing the webhook URL and signing secret directly.
73    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    /// Render `template` and send the resulting card to the webhook.
92    pub fn send_template(&self, template: &dyn LarkTemplate) -> Result<Value> {
93        let content = template.generate();
94        self.send_raw_content(content)
95    }
96
97    /// Send a pre-built [`CardContent`] JSON value directly.
98    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(&timestamp, &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        // base64 of 32-byte SHA256 = 44 chars
148        assert_eq!(result.len(), 44);
149        // valid base64 — decode must succeed
150        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        // Pre-computed: HMAC-SHA256(key="1234567890\ntest_secret", msg=b"") as standard base64
174        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}