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                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        // base64 of 32-byte SHA256 = 44 chars
147        assert_eq!(result.len(), 44);
148        // valid base64 — decode must succeed
149        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        // Pre-computed: HMAC-SHA256(key="1234567890\ntest_secret", msg=b"") as standard base64
173        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}