use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use hmac::{Hmac, Mac};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DingTalkError {
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Timestamp generation failed")]
TimestampError(#[from] std::time::SystemTimeError),
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("HMAC error")]
HmacError,
#[error("API error: {0}")]
ApiError(String),
}
#[allow(dead_code)]
#[derive(Serialize)]
#[serde(tag = "msgtype")]
enum Message {
#[serde(rename = "text")]
Text {
text: TextContent,
#[serde(skip_serializing_if = "Option::is_none")]
at: Option<At>,
},
#[serde(rename = "link")]
Link {
link: LinkContent,
#[serde(skip_serializing_if = "Option::is_none")]
at: Option<At>,
},
#[serde(rename = "markdown")]
Markdown {
markdown: MarkdownContent,
#[serde(skip_serializing_if = "Option::is_none")]
at: Option<At>,
},
#[serde(rename = "actionCard")]
ActionCard {
#[serde(rename = "actionCard")]
action_card: ActionCardContent,
},
#[serde(rename = "feedCard")]
FeedCard {
#[serde(rename = "feedCard")]
feed_card: FeedCardContent,
},
}
#[derive(Serialize)]
struct TextContent {
content: String,
}
#[derive(Serialize)]
struct LinkContent {
title: String,
text: String,
#[serde(rename = "messageUrl")]
message_url: String,
#[serde(rename = "picUrl", skip_serializing_if = "Option::is_none")]
pic_url: Option<String>,
}
#[derive(Serialize)]
struct MarkdownContent {
title: String,
text: String,
}
#[derive(Serialize)]
struct ActionCardContent {
title: String,
text: String,
#[serde(rename = "btnOrientation", skip_serializing_if = "Option::is_none")]
btn_orientation: Option<String>,
#[serde(rename = "singleTitle", skip_serializing_if = "Option::is_none")]
single_title: Option<String>,
#[serde(rename = "singleURL", skip_serializing_if = "Option::is_none")]
single_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
btns: Option<Vec<ActionCardButton>>,
}
#[derive(Serialize)]
pub struct ActionCardButton {
title: String,
#[serde(rename = "actionURL")]
action_url: String,
}
#[derive(Serialize)]
struct FeedCardContent {
links: Vec<FeedCardLink>,
}
#[derive(Serialize)]
pub struct FeedCardLink {
title: String,
#[serde(rename = "messageURL")]
message_url: String,
#[serde(rename = "picURL")]
pic_url: String,
}
#[derive(Serialize)]
struct At {
#[serde(rename = "atMobiles", skip_serializing_if = "Option::is_none")]
at_mobiles: Option<Vec<String>>,
#[serde(rename = "atUserIds", skip_serializing_if = "Option::is_none")]
at_user_ids: Option<Vec<String>>,
#[serde(rename = "isAtAll", skip_serializing_if = "Option::is_none")]
is_at_all: Option<bool>,
}
pub struct DingTalkRobot {
token: String,
secret: Option<String>,
client: Client,
}
impl DingTalkRobot {
pub fn new(token: String, secret: Option<String>) -> Self {
DingTalkRobot {
token,
secret,
client: Client::builder()
.no_proxy()
.build()
.expect("build Client error"),
}
}
fn create_signature(&self, timestamp: &str) -> Result<String, DingTalkError> {
if let Some(ref secret) = self.secret {
let string_to_sign = format!("{}\n{}", timestamp, secret);
let key = secret.as_bytes();
let mut mac =
Hmac::<Sha256>::new_from_slice(key).map_err(|_| DingTalkError::HmacError)?;
mac.update(string_to_sign.as_bytes());
let result = mac.finalize().into_bytes();
let base64_result = STANDARD.encode(&result);
let url_encoded_result = urlencoding::encode(&base64_result).to_string();
Ok(url_encoded_result)
} else {
Ok(String::new())
}
}
async fn send_message(&self, message: &Message) -> Result<String, DingTalkError> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)?
.as_millis()
.to_string();
let sign = self.create_signature(×tamp)?;
let url = if self.secret.is_some() {
format!(
"https://oapi.dingtalk.com/robot/send?access_token={}×tamp={}&sign={}",
self.token, timestamp, sign
)
} else {
format!(
"https://oapi.dingtalk.com/robot/send?access_token={}",
self.token
)
};
println!("URL: {}", url);
let response = self
.client
.post(url)
.json(message)
.send()
.await?
.error_for_status()?;
let response_text = response.text().await?;
Ok(response_text)
}
pub async fn send_text_message(
&self,
content: &str,
at_mobiles: Option<Vec<String>>,
at_user_ids: Option<Vec<String>>,
is_at_all: Option<bool>,
) -> Result<String, DingTalkError> {
let at = if at_mobiles.is_some() || at_user_ids.is_some() || is_at_all.is_some() {
Some(At {
at_mobiles,
at_user_ids,
is_at_all,
})
} else {
None
};
let message = Message::Text {
text: TextContent {
content: content.to_string(),
},
at,
};
self.send_message(&message).await
}
pub async fn send_link_message(
&self,
title: &str,
text: &str,
message_url: &str,
pic_url: Option<&str>,
) -> Result<String, DingTalkError> {
let message = Message::Link {
link: LinkContent {
title: title.to_string(),
text: text.to_string(),
message_url: message_url.to_string(),
pic_url: pic_url.map(|s| s.to_string()),
},
at: None,
};
self.send_message(&message).await
}
pub async fn send_markdown_message(
&self,
title: &str,
text: &str,
at_mobiles: Option<Vec<String>>,
at_user_ids: Option<Vec<String>>,
is_at_all: Option<bool>,
) -> Result<String, DingTalkError> {
let at = if at_mobiles.is_some() || at_user_ids.is_some() || is_at_all.is_some() {
Some(At {
at_mobiles,
at_user_ids,
is_at_all,
})
} else {
None
};
let message = Message::Markdown {
markdown: MarkdownContent {
title: title.to_string(),
text: text.to_string(),
},
at,
};
self.send_message(&message).await
}
pub async fn send_action_card_message_single(
&self,
title: &str,
text: &str,
single_title: &str,
single_url: &str,
btn_orientation: Option<&str>,
) -> Result<String, DingTalkError> {
let message = Message::ActionCard {
action_card: ActionCardContent {
title: title.to_string(),
text: text.to_string(),
btn_orientation: btn_orientation.map(|s| s.to_string()),
single_title: Some(single_title.to_string()),
single_url: Some(single_url.to_string()),
btns: None,
},
};
self.send_message(&message).await
}
pub async fn send_action_card_message_multi(
&self,
title: &str,
text: &str,
btns: Vec<ActionCardButton>,
btn_orientation: Option<&str>,
) -> Result<String, DingTalkError> {
let message = Message::ActionCard {
action_card: ActionCardContent {
title: title.to_string(),
text: text.to_string(),
btn_orientation: btn_orientation.map(|s| s.to_string()),
single_title: None,
single_url: None,
btns: Some(btns),
},
};
self.send_message(&message).await
}
pub async fn send_feed_card_message(
&self,
links: Vec<FeedCardLink>,
) -> Result<String, DingTalkError> {
let message = Message::FeedCard {
feed_card: FeedCardContent { links },
};
self.send_message(&message).await
}
}
#[derive(Serialize)]
struct MsgParam {
title: String,
text: String,
}
fn serialize_to_json_string<S, T>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
T: ?Sized + Serialize,
{
let s = serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
serializer.serialize_str(&s)
}
#[derive(Serialize)]
struct GroupMessageRequest<'a> {
#[serde(rename = "msgParam", serialize_with = "serialize_to_json_string")]
msg_param: MsgParam,
#[serde(rename = "msgKey")]
msg_key: &'a str,
#[serde(rename = "robotCode")]
robot_code: &'a str,
#[serde(rename = "openConversationId")]
open_conversation_id: &'a str,
}
#[derive(Serialize)]
struct OtoMessageRequest<'a> {
#[serde(rename = "msgParam", serialize_with = "serialize_to_json_string")]
msg_param: MsgParam,
#[serde(rename = "msgKey")]
msg_key: &'a str,
#[serde(rename = "robotCode")]
robot_code: &'a str,
#[serde(rename = "userIds", skip_serializing_if = "Vec::is_empty")]
user_ids: Vec<&'a str>,
}
pub struct EnterpriseDingTalkRobot {
appkey: String,
appsecret: String,
robot_code: String,
client: Client,
}
impl EnterpriseDingTalkRobot {
pub fn new(appkey: String, appsecret: String, robot_code: String) -> Self {
EnterpriseDingTalkRobot {
appkey,
appsecret,
robot_code,
client: Client::builder()
.no_proxy()
.build()
.expect("build Client error"),
}
}
pub async fn get_access_token(&self) -> Result<String, DingTalkError> {
let url = format!(
"https://oapi.dingtalk.com/gettoken?appkey={}&appsecret={}",
self.appkey, self.appsecret
);
let res = self
.client
.get(&url)
.send()
.await?
.json::<GetTokenResponse>()
.await?;
if res.errcode != 0 {
return Err(DingTalkError::ApiError(res.errmsg));
}
res.access_token
.ok_or_else(|| DingTalkError::ApiError("No access token returned".to_string()))
}
pub async fn send_group_message(
&self,
open_conversation_id: &str,
title: &str,
text: &str,
) -> Result<String, DingTalkError> {
let access_token = self.get_access_token().await?;
let url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
let req_body = GroupMessageRequest {
msg_param: MsgParam {
title: title.to_string(),
text: text.to_string(),
},
msg_key: "sampleMarkdown",
robot_code: &self.robot_code,
open_conversation_id,
};
let response = self
.client
.post(url)
.header("x-acs-dingtalk-access-token", access_token)
.json(&req_body)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(response)
}
pub async fn send_oto_message(
&self,
user_id: &str,
title: &str,
text: &str,
) -> Result<String, DingTalkError> {
let access_token = self.get_access_token().await?;
let url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
let req_body = OtoMessageRequest {
msg_param: MsgParam {
title: title.to_string(),
text: text.to_string(),
},
msg_key: "sampleMarkdown",
robot_code: &self.robot_code,
user_ids: vec![user_id],
};
let response = self
.client
.post(url)
.header("x-acs-dingtalk-access-token", access_token)
.json(&req_body)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(response)
}
pub async fn reply_message(
&self,
data: &serde_json::Value,
title: &str,
text: &str,
) -> Result<String, DingTalkError> {
let access_token = self.get_access_token().await?;
let msg_param = MsgParam {
title: title.to_string(),
text: text.to_string(),
};
if data.get("conversationType").and_then(|v| v.as_str()) == Some("1") {
let sender_staff_id = data
.get("senderStaffId")
.and_then(|v| v.as_str())
.ok_or_else(|| DingTalkError::ApiError("Missing senderStaffId".to_string()))?;
let url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
let req_body = OtoMessageRequest {
msg_param,
msg_key: "sampleMarkdown",
robot_code: &self.robot_code,
user_ids: vec![sender_staff_id],
};
let response = self
.client
.post(url)
.header("x-acs-dingtalk-access-token", access_token)
.json(&req_body)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(response)
} else {
let conversation_id = data
.get("conversationId")
.and_then(|v| v.as_str())
.ok_or_else(|| DingTalkError::ApiError("Missing conversationId".to_string()))?;
let url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
let req_body = GroupMessageRequest {
msg_param,
msg_key: "sampleMarkdown",
robot_code: &self.robot_code,
open_conversation_id: conversation_id,
};
let response = self
.client
.post(url)
.header("x-acs-dingtalk-access-token", access_token)
.json(&req_body)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(response)
}
}
}
#[derive(Deserialize)]
struct GetTokenResponse {
errcode: i32,
errmsg: String,
access_token: Option<String>,
}