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#[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#[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
131pub struct DingTalkRobot {
133 token: String,
134 secret: Option<String>,
135 client: Client,
136}
137
138impl DingTalkRobot {
139 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 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 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 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(×tamp)?;
202
203 let url = if self.secret.is_some() {
204 format!(
205 "https://oapi.dingtalk.com/robot/send?access_token={}×tamp={}&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()?; let response_text = response.text().await?;
226 Ok(response_text)
227 }
228
229 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 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 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 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 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 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#[derive(Serialize)]
431struct MsgParam {
432 title: String,
433 text: String,
434}
435
436fn 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#[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#[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
476pub struct EnterpriseDingTalkRobot {
478 appkey: String,
479 appsecret: String,
480 robot_code: String,
481 client: Client,
482}
483
484impl EnterpriseDingTalkRobot {
485 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 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 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 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 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}