open_lark/
custom_bot.rs

1#[cfg(feature = "im")]
2use base64::{prelude::BASE64_STANDARD, Engine};
3#[cfg(feature = "im")]
4use hmac::{Hmac, Mac};
5#[cfg(feature = "im")]
6use serde_json::{json, Value};
7#[cfg(feature = "im")]
8use sha2::Sha256;
9
10#[cfg(feature = "im")]
11use crate::core::{
12    api_resp::{BaseResponse, RawResponse},
13    http::Transport,
14    SDKResult,
15};
16
17#[cfg(feature = "im")]
18use crate::service::im::v1::message::{MessageCardTemplate, SendMessageTrait};
19
20/// 自定义机器人
21///
22/// [使用指南](https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot)
23#[allow(dead_code)]
24pub struct CustomBot<'a> {
25    /// webhook 地址
26    webhook_url: &'a str,
27    /// 密钥
28    secret: Option<&'a str>,
29    client: reqwest::Client,
30}
31
32impl<'a> CustomBot<'a> {
33    pub fn new(webhook_url: &'a str, secret: Option<&'a str>) -> Self {
34        CustomBot {
35            webhook_url,
36            secret,
37            client: reqwest::Client::new(),
38        }
39    }
40}
41
42impl CustomBot<'_> {
43    #[cfg(feature = "im")]
44    pub async fn send_message<T>(&self, message: T) -> SDKResult<BaseResponse<RawResponse>>
45    where
46        T: SendMessageTrait,
47    {
48        let mut json = json!({
49            "msg_type": message.msg_type(),
50            "content": message.content()
51        });
52        self.check_sign(&mut json);
53        Transport::do_send(
54            self.client.post(self.webhook_url),
55            json.to_string().into(),
56            false,
57        )
58        .await
59    }
60
61    /// 发送飞书卡片消息, 因为自定义机器人发送飞书卡片消息的格式比较特殊,所以单独提供一个方法
62    #[cfg(feature = "im")]
63    pub async fn send_card(
64        &self,
65        message: MessageCardTemplate,
66    ) -> SDKResult<BaseResponse<RawResponse>> {
67        let mut json = json!({
68            "msg_type": message.msg_type(),
69            "card": message.content()
70        });
71
72        self.check_sign(&mut json);
73
74        Transport::do_send(
75            self.client.post(self.webhook_url),
76            json.to_string().into_bytes(),
77            false,
78        )
79        .await
80    }
81
82    /// 如果设置了密钥,就计算签名
83    #[cfg(feature = "im")]
84    fn check_sign(&self, json: &mut Value) {
85        if let Some(secret) = self.secret.as_ref() {
86            let now = chrono::Local::now().timestamp();
87            json["timestamp"] = serde_json::to_value(now).unwrap();
88            let sign = CustomBot::sign(now, secret);
89            json["sign"] = serde_json::to_value(sign).unwrap();
90        }
91    }
92
93    /// 计算签名
94    #[cfg(feature = "im")]
95    fn sign(timestamp: i64, secret: &str) -> String {
96        let string_to_sign = format!("{timestamp}\n{secret}");
97        let hmac: Hmac<Sha256> = Hmac::new_from_slice(string_to_sign.as_bytes()).unwrap();
98        let hmac_code = hmac.finalize().into_bytes();
99        BASE64_STANDARD.encode(hmac_code)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_custom_bot_creation_with_secret() {
109        let webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/test";
110        let secret = Some("test_secret");
111
112        let bot = CustomBot::new(webhook_url, secret);
113
114        assert_eq!(bot.webhook_url, webhook_url);
115        assert_eq!(bot.secret, secret);
116    }
117
118    #[test]
119    fn test_custom_bot_creation_without_secret() {
120        let webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/test";
121
122        let bot = CustomBot::new(webhook_url, None);
123
124        assert_eq!(bot.webhook_url, webhook_url);
125        assert!(bot.secret.is_none());
126    }
127
128    #[test]
129    fn test_custom_bot_creation_with_empty_webhook() {
130        let webhook_url = "";
131        let secret = Some("test_secret");
132
133        let bot = CustomBot::new(webhook_url, secret);
134
135        assert_eq!(bot.webhook_url, "");
136        assert_eq!(bot.secret, secret);
137    }
138
139    #[test]
140    fn test_custom_bot_creation_with_different_urls() {
141        let test_urls = [
142            "https://open.feishu.cn/open-apis/bot/v2/hook/test1",
143            "https://open.larksuite.com/open-apis/bot/v2/hook/test2",
144            "http://localhost:8080/webhook",
145            "https://example.com/hook",
146        ];
147
148        for url in &test_urls {
149            let bot = CustomBot::new(url, None);
150            assert_eq!(bot.webhook_url, *url);
151        }
152    }
153
154    #[cfg(feature = "im")]
155    #[test]
156    fn test_sign_basic() {
157        let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
158        let secret = "test_secret";
159
160        let signature = CustomBot::sign(timestamp, secret);
161
162        // Should produce a valid base64 string
163        assert!(!signature.is_empty());
164        assert!(base64::prelude::BASE64_STANDARD.decode(&signature).is_ok());
165    }
166
167    #[cfg(feature = "im")]
168    #[test]
169    fn test_sign_consistency() {
170        let timestamp = 1609459200;
171        let secret = "test_secret";
172
173        let signature1 = CustomBot::sign(timestamp, secret);
174        let signature2 = CustomBot::sign(timestamp, secret);
175
176        // Same inputs should produce same signature
177        assert_eq!(signature1, signature2);
178    }
179
180    #[cfg(feature = "im")]
181    #[test]
182    fn test_sign_different_inputs() {
183        let timestamp1 = 1609459200;
184        let timestamp2 = 1609459201;
185        let secret = "test_secret";
186
187        let signature1 = CustomBot::sign(timestamp1, secret);
188        let signature2 = CustomBot::sign(timestamp2, secret);
189
190        // Different timestamps should produce different signatures
191        assert_ne!(signature1, signature2);
192
193        let signature3 = CustomBot::sign(timestamp1, "different_secret");
194
195        // Different secrets should produce different signatures
196        assert_ne!(signature1, signature3);
197    }
198
199    #[cfg(feature = "im")]
200    #[test]
201    fn test_sign_with_empty_secret() {
202        let timestamp = 1609459200;
203        let secret = "";
204
205        let signature = CustomBot::sign(timestamp, secret);
206
207        // Should handle empty secret without panicking
208        assert!(!signature.is_empty());
209        assert!(base64::prelude::BASE64_STANDARD.decode(&signature).is_ok());
210    }
211
212    #[cfg(feature = "im")]
213    #[test]
214    fn test_sign_with_special_characters() {
215        let timestamp = 1609459200;
216        let secret = "test_secret!@#$%^&*()_+";
217
218        let signature = CustomBot::sign(timestamp, secret);
219
220        // Should handle special characters in secret
221        assert!(!signature.is_empty());
222        assert!(base64::prelude::BASE64_STANDARD.decode(&signature).is_ok());
223    }
224
225    #[cfg(feature = "im")]
226    #[test]
227    fn test_sign_with_unicode() {
228        let timestamp = 1609459200;
229        let secret = "测试密钥🔐";
230
231        let signature = CustomBot::sign(timestamp, secret);
232
233        // Should handle Unicode characters
234        assert!(!signature.is_empty());
235        assert!(base64::prelude::BASE64_STANDARD.decode(&signature).is_ok());
236    }
237
238    #[cfg(feature = "im")]
239    #[test]
240    fn test_check_sign_with_secret() {
241        let webhook_url = "https://test.webhook.url";
242        let secret = Some("test_secret");
243        let bot = CustomBot::new(webhook_url, secret);
244
245        let mut json = json!({
246            "msg_type": "text",
247            "content": "test message"
248        });
249
250        bot.check_sign(&mut json);
251
252        // Should add timestamp and sign fields
253        assert!(json["timestamp"].is_i64());
254        assert!(json["sign"].is_string());
255        assert!(!json["sign"].as_str().unwrap().is_empty());
256    }
257
258    #[cfg(feature = "im")]
259    #[test]
260    fn test_check_sign_without_secret() {
261        let webhook_url = "https://test.webhook.url";
262        let bot = CustomBot::new(webhook_url, None);
263
264        let mut json = json!({
265            "msg_type": "text",
266            "content": "test message"
267        });
268        let original_json = json.clone();
269
270        bot.check_sign(&mut json);
271
272        // Should not modify JSON when no secret is set
273        assert_eq!(json, original_json);
274        assert!(json["timestamp"].is_null());
275        assert!(json["sign"].is_null());
276    }
277
278    #[cfg(feature = "im")]
279    #[test]
280    fn test_check_sign_preserves_existing_fields() {
281        let webhook_url = "https://test.webhook.url";
282        let secret = Some("test_secret");
283        let bot = CustomBot::new(webhook_url, secret);
284
285        let mut json = json!({
286            "msg_type": "text",
287            "content": "test message",
288            "existing_field": "existing_value"
289        });
290
291        bot.check_sign(&mut json);
292
293        // Should preserve existing fields
294        assert_eq!(json["msg_type"], "text");
295        assert_eq!(json["content"], "test message");
296        assert_eq!(json["existing_field"], "existing_value");
297
298        // Should add new fields
299        assert!(json["timestamp"].is_i64());
300        assert!(json["sign"].is_string());
301    }
302
303    #[test]
304    fn test_custom_bot_is_send_sync() {
305        // Test that CustomBot implements required traits for concurrent usage
306        fn assert_send<T: Send>() {}
307        fn assert_sync<T: Sync>() {}
308
309        assert_send::<CustomBot>();
310        assert_sync::<CustomBot>();
311    }
312
313    #[test]
314    fn test_custom_bot_lifetime() {
315        let webhook_url = String::from("https://test.webhook.url");
316        let secret_str = String::from("test_secret");
317
318        // Test that CustomBot can be created with string references
319        let bot = CustomBot::new(&webhook_url, Some(&secret_str));
320
321        assert_eq!(bot.webhook_url, webhook_url.as_str());
322        assert_eq!(bot.secret, Some(secret_str.as_str()));
323    }
324
325    #[test]
326    fn test_custom_bot_multiple_instances() {
327        let webhook_url1 = "https://test1.webhook.url";
328        let webhook_url2 = "https://test2.webhook.url";
329        let secret1 = Some("secret1");
330        let secret2 = Some("secret2");
331
332        let bot1 = CustomBot::new(webhook_url1, secret1);
333        let bot2 = CustomBot::new(webhook_url2, secret2);
334
335        assert_eq!(bot1.webhook_url, webhook_url1);
336        assert_eq!(bot1.secret, secret1);
337        assert_eq!(bot2.webhook_url, webhook_url2);
338        assert_eq!(bot2.secret, secret2);
339
340        // Should be independent instances
341        assert_ne!(bot1.webhook_url, bot2.webhook_url);
342        assert_ne!(bot1.secret, bot2.secret);
343    }
344
345    #[test]
346    fn test_custom_bot_debug_representation() {
347        let webhook_url = "https://test.webhook.url";
348        let secret = Some("test_secret");
349        let bot = CustomBot::new(webhook_url, secret);
350
351        // Should be able to create debug representation without panicking
352        let debug_str = format!("{:?}", bot.client);
353        assert!(debug_str.contains("Client"));
354    }
355
356    #[cfg(feature = "im")]
357    #[test]
358    fn test_sign_boundary_values() {
359        // Test with various timestamp boundary values
360        let test_cases = [
361            (0, "secret"),        // Unix epoch
362            (i64::MAX, "secret"), // Maximum timestamp
363            (1609459200, ""),     // Empty secret
364            (-1, "secret"),       // Negative timestamp (before epoch)
365        ];
366
367        for (timestamp, secret) in &test_cases {
368            let signature = CustomBot::sign(*timestamp, secret);
369            assert!(
370                !signature.is_empty(),
371                "Failed for timestamp: {}, secret: '{}'",
372                timestamp,
373                secret
374            );
375            assert!(
376                base64::prelude::BASE64_STANDARD.decode(&signature).is_ok(),
377                "Invalid base64 for timestamp: {}, secret: '{}'",
378                timestamp,
379                secret
380            );
381        }
382    }
383
384    #[cfg(feature = "im")]
385    #[test]
386    fn test_sign_very_long_secret() {
387        let timestamp = 1609459200;
388        let long_secret = "a".repeat(1000); // Very long secret
389
390        let signature = CustomBot::sign(timestamp, &long_secret);
391
392        assert!(!signature.is_empty());
393        assert!(base64::prelude::BASE64_STANDARD.decode(&signature).is_ok());
394    }
395}