dingtalk_sdk/
lib.rs

1use base64::engine::general_purpose::STANDARD;
2use base64::Engine;
3use hmac::{Hmac, Mac};
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6use sha2::Sha256;
7use std::time::{SystemTime, UNIX_EPOCH};
8use thiserror::Error;
9
10/// Custom error type for DingTalk operations.
11#[derive(Error, Debug)]
12pub enum DingTalkError {
13    #[error("HTTP error: {0}")]
14    HttpError(#[from] reqwest::Error),
15
16    #[error("Timestamp generation failed")]
17    TimestampError(#[from] std::time::SystemTimeError),
18
19    #[error("Serialization error: {0}")]
20    SerializationError(#[from] serde_json::Error),
21
22    #[error("HMAC error")]
23    HmacError,
24
25    #[error("API error: {0}")]
26    ApiError(String),
27}
28
29/// ----------------------- Webhook Bot Section -----------------------
30
31#[allow(dead_code)]
32#[derive(Serialize)]
33#[serde(tag = "msgtype")]
34enum Message {
35    #[serde(rename = "text")]
36    Text {
37        text: TextContent,
38        #[serde(skip_serializing_if = "Option::is_none")]
39        at: Option<At>,
40    },
41    #[serde(rename = "link")]
42    Link {
43        link: LinkContent,
44        #[serde(skip_serializing_if = "Option::is_none")]
45        at: Option<At>,
46    },
47    #[serde(rename = "markdown")]
48    Markdown {
49        markdown: MarkdownContent,
50        #[serde(skip_serializing_if = "Option::is_none")]
51        at: Option<At>,
52    },
53    #[serde(rename = "actionCard")]
54    ActionCard {
55        #[serde(rename = "actionCard")]
56        action_card: ActionCardContent,
57    },
58    #[serde(rename = "feedCard")]
59    FeedCard {
60        #[serde(rename = "feedCard")]
61        feed_card: FeedCardContent,
62    },
63}
64
65#[derive(Serialize)]
66struct TextContent {
67    content: String,
68}
69
70#[derive(Serialize)]
71struct LinkContent {
72    title: String,
73    text: String,
74    #[serde(rename = "messageUrl")]
75    message_url: String,
76    #[serde(rename = "picUrl", skip_serializing_if = "Option::is_none")]
77    pic_url: Option<String>,
78}
79
80#[derive(Serialize)]
81struct MarkdownContent {
82    title: String,
83    text: String,
84}
85
86#[derive(Serialize)]
87struct ActionCardContent {
88    title: String,
89    text: String,
90    #[serde(rename = "btnOrientation", skip_serializing_if = "Option::is_none")]
91    btn_orientation: Option<String>,
92    #[serde(rename = "singleTitle", skip_serializing_if = "Option::is_none")]
93    single_title: Option<String>,
94    #[serde(rename = "singleURL", skip_serializing_if = "Option::is_none")]
95    single_url: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    btns: Option<Vec<ActionCardButton>>,
98}
99
100#[derive(Serialize)]
101pub struct ActionCardButton {
102    title: String,
103    #[serde(rename = "actionURL")]
104    action_url: String,
105}
106
107#[derive(Serialize)]
108struct FeedCardContent {
109    links: Vec<FeedCardLink>,
110}
111
112#[derive(Serialize)]
113pub struct FeedCardLink {
114    title: String,
115    #[serde(rename = "messageURL")]
116    message_url: String,
117    #[serde(rename = "picURL")]
118    pic_url: String,
119}
120
121#[derive(Serialize)]
122struct At {
123    #[serde(rename = "atMobiles", skip_serializing_if = "Option::is_none")]
124    at_mobiles: Option<Vec<String>>,
125    #[serde(rename = "atUserIds", skip_serializing_if = "Option::is_none")]
126    at_user_ids: Option<Vec<String>>,
127    #[serde(rename = "isAtAll", skip_serializing_if = "Option::is_none")]
128    is_at_all: Option<bool>,
129}
130
131/// Implementation of the DingTalk Webhook Bot.
132pub struct DingTalkRobot {
133    token: String,
134    secret: Option<String>,
135    client: Client,
136}
137
138impl DingTalkRobot {
139    /// Creates a new instance of `DingTalkRobot`.
140    ///
141    /// # Arguments
142    ///
143    /// * `token` - The access token for the DingTalk bot.
144    /// * `secret` - An optional secret for signature generation.
145    pub fn new(token: String, secret: Option<String>) -> Self {
146        DingTalkRobot {
147            token,
148            secret,
149            client: Client::builder()
150                .no_proxy()
151                .build()
152                .expect("build Client error"),
153        }
154    }
155
156    /// Creates a URL-encoded signature based on the given timestamp.
157    ///
158    /// The signature is generated by signing the string composed of the timestamp and secret.
159    ///
160    /// # Arguments
161    ///
162    /// * `timestamp` - The current timestamp as a string.
163    ///
164    /// # Returns
165    ///
166    /// A `Result` containing the URL-encoded signature or a `DingTalkError` if an error occurs.
167    fn create_signature(&self, timestamp: &str) -> Result<String, DingTalkError> {
168        if let Some(ref secret) = self.secret {
169            let string_to_sign = format!("{}\n{}", timestamp, secret);
170            let key = secret.as_bytes();
171            let mut mac =
172                Hmac::<Sha256>::new_from_slice(key).map_err(|_| DingTalkError::HmacError)?;
173            mac.update(string_to_sign.as_bytes());
174            let result = mac.finalize().into_bytes();
175            // Use the recommended STANDARD engine for base64 encoding
176            let base64_result = STANDARD.encode(&result);
177            let url_encoded_result = urlencoding::encode(&base64_result).to_string();
178            Ok(url_encoded_result)
179        } else {
180            Ok(String::new())
181        }
182    }
183
184    /// Sends a message to the DingTalk Webhook.
185    ///
186    /// This method constructs the request URL with the necessary signature (if a secret is provided)
187    /// and sends the HTTP POST request with the given message payload. Non-2xx responses are treated as errors.
188    ///
189    /// # Arguments
190    ///
191    /// * `message` - A reference to the message to be sent.
192    ///
193    /// # Returns
194    ///
195    /// A `Result` containing the response text or a `DingTalkError`.
196    async fn send_message(&self, message: &Message) -> Result<String, DingTalkError> {
197        let timestamp = SystemTime::now()
198            .duration_since(UNIX_EPOCH)?
199            .as_millis()
200            .to_string();
201        let sign = self.create_signature(&timestamp)?;
202
203        let url = if self.secret.is_some() {
204            format!(
205                "https://oapi.dingtalk.com/robot/send?access_token={}&timestamp={}&sign={}",
206                self.token, timestamp, sign
207            )
208        } else {
209            format!(
210                "https://oapi.dingtalk.com/robot/send?access_token={}",
211                self.token
212            )
213        };
214
215        println!("URL: {}", url);
216
217        let response = self
218            .client
219            .post(url)
220            .json(message)
221            .send()
222            .await?
223            .error_for_status()?; // Convert non-2xx HTTP responses into errors
224
225        let response_text = response.text().await?;
226        Ok(response_text)
227    }
228
229    /// Sends a text message.
230    ///
231    /// # Arguments
232    ///
233    /// * `content` - The content of the text message.
234    /// * `at_mobiles` - Optional list of mobile numbers to mention.
235    /// * `at_user_ids` - Optional list of user IDs to mention.
236    /// * `is_at_all` - Optional flag indicating whether to mention all users.
237    ///
238    /// # Returns
239    ///
240    /// A `Result` containing the response text or a `DingTalkError`.
241    pub async fn send_text_message(
242        &self,
243        content: &str,
244        at_mobiles: Option<Vec<String>>,
245        at_user_ids: Option<Vec<String>>,
246        is_at_all: Option<bool>,
247    ) -> Result<String, DingTalkError> {
248        let at = if at_mobiles.is_some() || at_user_ids.is_some() || is_at_all.is_some() {
249            Some(At {
250                at_mobiles,
251                at_user_ids,
252                is_at_all,
253            })
254        } else {
255            None
256        };
257
258        let message = Message::Text {
259            text: TextContent {
260                content: content.to_string(),
261            },
262            at,
263        };
264        self.send_message(&message).await
265    }
266
267    /// Sends a link message.
268    ///
269    /// # Arguments
270    ///
271    /// * `title` - The title of the link message.
272    /// * `text` - The descriptive text of the message.
273    /// * `message_url` - The URL that the message should link to.
274    /// * `pic_url` - Optional URL for the picture.
275    ///
276    /// # Returns
277    ///
278    /// A `Result` containing the response text or a `DingTalkError`.
279    pub async fn send_link_message(
280        &self,
281        title: &str,
282        text: &str,
283        message_url: &str,
284        pic_url: Option<&str>,
285    ) -> Result<String, DingTalkError> {
286        let message = Message::Link {
287            link: LinkContent {
288                title: title.to_string(),
289                text: text.to_string(),
290                message_url: message_url.to_string(),
291                pic_url: pic_url.map(|s| s.to_string()),
292            },
293            at: None,
294        };
295        self.send_message(&message).await
296    }
297
298    /// Sends a Markdown message.
299    ///
300    /// # Arguments
301    ///
302    /// * `title` - The title of the Markdown message.
303    /// * `text` - The Markdown formatted content.
304    /// * `at_mobiles` - Optional list of mobile numbers to mention.
305    /// * `at_user_ids` - Optional list of user IDs to mention.
306    /// * `is_at_all` - Optional flag indicating whether to mention all users.
307    ///
308    /// # Returns
309    ///
310    /// A `Result` containing the response text or a `DingTalkError`.
311    pub async fn send_markdown_message(
312        &self,
313        title: &str,
314        text: &str,
315        at_mobiles: Option<Vec<String>>,
316        at_user_ids: Option<Vec<String>>,
317        is_at_all: Option<bool>,
318    ) -> Result<String, DingTalkError> {
319        let at = if at_mobiles.is_some() || at_user_ids.is_some() || is_at_all.is_some() {
320            Some(At {
321                at_mobiles,
322                at_user_ids,
323                is_at_all,
324            })
325        } else {
326            None
327        };
328
329        let message = Message::Markdown {
330            markdown: MarkdownContent {
331                title: title.to_string(),
332                text: text.to_string(),
333            },
334            at,
335        };
336        self.send_message(&message).await
337    }
338
339    /// Sends an ActionCard message with a single redirection button.
340    ///
341    /// This version uses a single button that redirects to the provided URL.
342    ///
343    /// # Arguments
344    ///
345    /// * `title` - The title of the ActionCard message.
346    /// * `text` - The content of the message.
347    /// * `single_title` - The title of the single button.
348    /// * `single_url` - The URL to redirect when the button is clicked.
349    /// * `btn_orientation` - Optional button orientation.
350    ///
351    /// # Returns
352    ///
353    /// A `Result` containing the response text or a `DingTalkError`.
354    pub async fn send_action_card_message_single(
355        &self,
356        title: &str,
357        text: &str,
358        single_title: &str,
359        single_url: &str,
360        btn_orientation: Option<&str>,
361    ) -> Result<String, DingTalkError> {
362        let message = Message::ActionCard {
363            action_card: ActionCardContent {
364                title: title.to_string(),
365                text: text.to_string(),
366                btn_orientation: btn_orientation.map(|s| s.to_string()),
367                single_title: Some(single_title.to_string()),
368                single_url: Some(single_url.to_string()),
369                btns: None,
370            },
371        };
372        self.send_message(&message).await
373    }
374
375    /// Sends an ActionCard message with multiple buttons, each having its own URL.
376    ///
377    /// # Arguments
378    ///
379    /// * `title` - The title of the ActionCard message.
380    /// * `text` - The content of the message.
381    /// * `btns` - A vector of `ActionCardButton` representing the buttons.
382    /// * `btn_orientation` - Optional button orientation.
383    ///
384    /// # Returns
385    ///
386    /// A `Result` containing the response text or a `DingTalkError`.
387    pub async fn send_action_card_message_multi(
388        &self,
389        title: &str,
390        text: &str,
391        btns: Vec<ActionCardButton>,
392        btn_orientation: Option<&str>,
393    ) -> Result<String, DingTalkError> {
394        let message = Message::ActionCard {
395            action_card: ActionCardContent {
396                title: title.to_string(),
397                text: text.to_string(),
398                btn_orientation: btn_orientation.map(|s| s.to_string()),
399                single_title: None,
400                single_url: None,
401                btns: Some(btns),
402            },
403        };
404        self.send_message(&message).await
405    }
406
407    /// Sends a FeedCard message.
408    ///
409    /// # Arguments
410    ///
411    /// * `links` - A vector of `FeedCardLink` representing the individual links.
412    ///
413    /// # Returns
414    ///
415    /// A `Result` containing the response text or a `DingTalkError`.
416    pub async fn send_feed_card_message(
417        &self,
418        links: Vec<FeedCardLink>,
419    ) -> Result<String, DingTalkError> {
420        let message = Message::FeedCard {
421            feed_card: FeedCardContent { links },
422        };
423        self.send_message(&message).await
424    }
425}
426
427/// ----------------------- Enterprise Bot Section -----------------------
428
429/// Struct representing message parameters used to generate the `msgParam` field.
430#[derive(Serialize)]
431struct MsgParam {
432    title: String,
433    text: String,
434}
435
436/// Custom serializer that converts a struct into a JSON string.
437///
438/// This is used to ensure the field is serialized as a JSON string rather than a nested object.
439fn serialize_to_json_string<S, T>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
440where
441    S: serde::Serializer,
442    T: ?Sized + Serialize,
443{
444    let s = serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
445    serializer.serialize_str(&s)
446}
447
448/// Enterprise group message request struct.
449///
450/// Field names are in snake_case and renamed to match the API requirements.
451#[derive(Serialize)]
452struct GroupMessageRequest<'a> {
453    #[serde(rename = "msgParam", serialize_with = "serialize_to_json_string")]
454    msg_param: MsgParam,
455    #[serde(rename = "msgKey")]
456    msg_key: &'a str,
457    #[serde(rename = "robotCode")]
458    robot_code: &'a str,
459    #[serde(rename = "openConversationId")]
460    open_conversation_id: &'a str,
461}
462
463/// Enterprise private message request struct.
464#[derive(Serialize)]
465struct OtoMessageRequest<'a> {
466    #[serde(rename = "msgParam", serialize_with = "serialize_to_json_string")]
467    msg_param: MsgParam,
468    #[serde(rename = "msgKey")]
469    msg_key: &'a str,
470    #[serde(rename = "robotCode")]
471    robot_code: &'a str,
472    #[serde(rename = "userIds", skip_serializing_if = "Vec::is_empty")]
473    user_ids: Vec<&'a str>,
474}
475
476/// Implementation of the Enterprise Bot interface.
477pub struct EnterpriseDingTalkRobot {
478    appkey: String,
479    appsecret: String,
480    robot_code: String,
481    client: Client,
482}
483
484impl EnterpriseDingTalkRobot {
485    /// Creates a new instance of `EnterpriseDingTalkRobot`.
486    ///
487    /// # Arguments
488    ///
489    /// * `appkey` - The application key.
490    /// * `appsecret` - The application secret.
491    /// * `robot_code` - The robot code.
492    pub fn new(appkey: String, appsecret: String, robot_code: String) -> Self {
493        EnterpriseDingTalkRobot {
494            appkey,
495            appsecret,
496            robot_code,
497            client: Client::builder()
498                .no_proxy()
499                .build()
500                .expect("build Client error"),
501        }
502    }
503
504    /// Retrieves the access token.
505    ///
506    /// # Returns
507    ///
508    /// A `Result` containing the access token string or a `DingTalkError`.
509    pub async fn get_access_token(&self) -> Result<String, DingTalkError> {
510        let url = format!(
511            "https://oapi.dingtalk.com/gettoken?appkey={}&appsecret={}",
512            self.appkey, self.appsecret
513        );
514        let res = self
515            .client
516            .get(&url)
517            .send()
518            .await?
519            .json::<GetTokenResponse>()
520            .await?;
521        if res.errcode != 0 {
522            return Err(DingTalkError::ApiError(res.errmsg));
523        }
524        res.access_token
525            .ok_or_else(|| DingTalkError::ApiError("No access token returned".to_string()))
526    }
527
528    /// Sends an enterprise group chat message.
529    ///
530    /// # Arguments
531    ///
532    /// * `open_conversation_id` - The conversation ID of the group chat.
533    /// * `title` - The title of the message.
534    /// * `text` - The content of the message.
535    ///
536    /// # Returns
537    ///
538    /// A `Result` containing the response text or a `DingTalkError`.
539    pub async fn send_group_message(
540        &self,
541        open_conversation_id: &str,
542        title: &str,
543        text: &str,
544    ) -> Result<String, DingTalkError> {
545        let access_token = self.get_access_token().await?;
546        let url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
547        let req_body = GroupMessageRequest {
548            msg_param: MsgParam {
549                title: title.to_string(),
550                text: text.to_string(),
551            },
552            msg_key: "sampleMarkdown",
553            robot_code: &self.robot_code,
554            open_conversation_id,
555        };
556        let response = self
557            .client
558            .post(url)
559            .header("x-acs-dingtalk-access-token", access_token)
560            .json(&req_body)
561            .send()
562            .await?
563            .error_for_status()?
564            .text()
565            .await?;
566        Ok(response)
567    }
568
569    /// Sends an enterprise private chat message.
570    ///
571    /// # Arguments
572    ///
573    /// * `user_id` - The user ID of the recipient.
574    /// * `title` - The title of the message.
575    /// * `text` - The content of the message.
576    ///
577    /// # Returns
578    ///
579    /// A `Result` containing the response text or a `DingTalkError`.
580    pub async fn send_oto_message(
581        &self,
582        user_id: &str,
583        title: &str,
584        text: &str,
585    ) -> Result<String, DingTalkError> {
586        let access_token = self.get_access_token().await?;
587        let url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
588        let req_body = OtoMessageRequest {
589            msg_param: MsgParam {
590                title: title.to_string(),
591                text: text.to_string(),
592            },
593            msg_key: "sampleMarkdown",
594            robot_code: &self.robot_code,
595            user_ids: vec![user_id],
596        };
597        let response = self
598            .client
599            .post(url)
600            .header("x-acs-dingtalk-access-token", access_token)
601            .json(&req_body)
602            .send()
603            .await?
604            .error_for_status()?
605            .text()
606            .await?;
607        Ok(response)
608    }
609
610    /// Replies to a message based on the provided data.
611    ///
612    /// Automatically determines whether to send a group chat or private chat message.
613    ///
614    /// # Arguments
615    ///
616    /// * `data` - The JSON value containing the original message data.
617    /// * `title` - The title of the reply message.
618    /// * `text` - The content of the reply message.
619    ///
620    /// # Returns
621    ///
622    /// A `Result` containing the response text or a `DingTalkError`.
623    pub async fn reply_message(
624        &self,
625        data: &serde_json::Value,
626        title: &str,
627        text: &str,
628    ) -> Result<String, DingTalkError> {
629        let access_token = self.get_access_token().await?;
630        let msg_param = MsgParam {
631            title: title.to_string(),
632            text: text.to_string(),
633        };
634        if data.get("conversationType").and_then(|v| v.as_str()) == Some("1") {
635            let sender_staff_id = data
636                .get("senderStaffId")
637                .and_then(|v| v.as_str())
638                .ok_or_else(|| DingTalkError::ApiError("Missing senderStaffId".to_string()))?;
639            let url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
640            let req_body = OtoMessageRequest {
641                msg_param,
642                msg_key: "sampleMarkdown",
643                robot_code: &self.robot_code,
644                user_ids: vec![sender_staff_id],
645            };
646            let response = self
647                .client
648                .post(url)
649                .header("x-acs-dingtalk-access-token", access_token)
650                .json(&req_body)
651                .send()
652                .await?
653                .error_for_status()?
654                .text()
655                .await?;
656            Ok(response)
657        } else {
658            let conversation_id = data
659                .get("conversationId")
660                .and_then(|v| v.as_str())
661                .ok_or_else(|| DingTalkError::ApiError("Missing conversationId".to_string()))?;
662            let url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
663            let req_body = GroupMessageRequest {
664                msg_param,
665                msg_key: "sampleMarkdown",
666                robot_code: &self.robot_code,
667                open_conversation_id: conversation_id,
668            };
669            let response = self
670                .client
671                .post(url)
672                .header("x-acs-dingtalk-access-token", access_token)
673                .json(&req_body)
674                .send()
675                .await?
676                .error_for_status()?
677                .text()
678                .await?;
679            Ok(response)
680        }
681    }
682}
683
684#[derive(Deserialize)]
685struct GetTokenResponse {
686    errcode: i32,
687    errmsg: String,
688    access_token: Option<String>,
689}