dingtalk_stream/frames/down_message/
callback_message.rs1use crate::frames::down_message::{DownStreamMessage, MessageHeaders};
2use crate::frames::{DingTalkGroupConversationId, DingTalkPrivateConversationId, DingTalkUserId};
3use anyhow::anyhow;
4use chrono::{TimeZone, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fmt::{Display, Formatter};
8use std::ops::Deref;
9use std::str::FromStr;
10use std::time::Duration;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CallbackMessage {
15 #[serde(rename = "specVersion")]
16 pub spec_version: Option<String>,
17 #[serde(rename = "type")]
18 pub headers: MessageHeaders,
19 pub data: Option<MessageData>,
20 #[serde(flatten)]
21 pub extensions: HashMap<String, serde_json::Value>,
22}
23
24impl TryFrom<DownStreamMessage> for CallbackMessage {
25 type Error = anyhow::Error;
26
27 fn try_from(
28 DownStreamMessage {
29 spec_version,
30 headers,
31 r#type,
32 data,
33 extensions,
34 }: DownStreamMessage,
35 ) -> crate::Result<Self> {
36 if let super::MessageType::Callback = r#type {
37 Ok(Self {
38 spec_version,
39 headers,
40 data: if let Some(data) = data {
41 serde_json::from_str(&data)?
42 } else {
43 None
44 },
45 extensions,
46 })
47 } else {
48 Err(anyhow!("expected callback message"))
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct MessageData {
55 #[serde(rename = "msgId")]
56 pub msg_id: String,
57 #[serde(flatten)]
58 pub conversation: Conversation,
59 #[serde(flatten)]
60 pub sender: MessageSender,
61 #[serde(flatten)]
62 pub session_webhook: Option<SessionWebhook>,
63 #[serde(flatten)]
64 pub chatbot: Chatbot,
65 #[serde(rename = "isAdmin")]
66 pub is_admin: Option<bool>,
67 #[serde(rename = "openThreadId")]
68 pub open_thread_id: Option<String>,
69 #[serde(rename = "senderPlatform")]
70 pub sender_platform: Option<String>,
71 #[serde(flatten)]
72 pub payload: Option<MessagePayload>,
73 #[serde(rename = "atUsers")]
74 pub at_users: Option<Vec<AtUser>>,
75 #[serde(rename = "isInAtList")]
76 pub is_in_at_list: Option<bool>,
77 #[serde(rename = "createAt")]
78 pub create_at: i64,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(tag = "conversationType")]
83pub enum Conversation {
84 #[serde(rename = "1")]
85 Private {
86 #[serde(rename = "conversationId")]
87 id: DingTalkPrivateConversationId,
88 },
89 #[serde(rename = "2")]
90 Group {
91 #[serde(rename = "conversationId")]
92 id: DingTalkGroupConversationId,
93 #[serde(rename = "conversationTitle")]
94 title: Option<String>,
95 },
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MessageSender {
100 #[serde(rename = "senderId")]
101 pub sender_id: String,
102 #[serde(rename = "senderNick")]
103 pub sender_nick: String,
104 #[serde(rename = "senderCorpId")]
105 pub sender_corp_id: Option<String>,
106 #[serde(rename = "senderStaffId")]
107 pub sender_staff_id: Option<DingTalkUserId>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct SessionWebhook {
112 #[serde(rename = "sessionWebhook")]
113 url: String,
114 #[serde(rename = "sessionWebhookExpiredTime")]
115 expired_time: i64,
116}
117
118impl SessionWebhook {
119 pub fn webhook_url(&self) -> crate::Result<url::Url> {
120 Ok(url::Url::from_str(&self.url)?)
121 }
122
123 pub fn timeout(&self) -> Option<Duration> {
124 if let chrono::LocalResult::Single(expired_time) =
125 Utc.timestamp_millis_opt(self.expired_time)
126 {
127 let now = Utc::now();
128 if expired_time > now {
129 if let Ok(duration) = (expired_time - now).to_std() {
130 return Some(duration);
131 }
132 }
133 }
134 None
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Chatbot {
140 #[serde(rename = "chatbotCorpId")]
141 pub chatbot_corp_id: Option<String>,
142 #[serde(rename = "chatbotUserId")]
143 pub chatbot_user_id: String,
144 #[serde(rename = "robotCode")]
145 pub robot_code: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct AtUser {
150 #[serde(rename = "dingtalkId")]
151 pub dingtalk_id: Option<String>,
152 #[serde(rename = "staffId")]
153 pub staff_id: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(tag = "msgtype")]
158pub enum MessagePayload {
159 #[serde(rename = "text")]
160 Text { text: PayloadText },
161 #[serde(rename = "picture")]
162 Picture { content: PayloadPicture },
163 #[serde(rename = "video")]
164 Video { content: PayloadVideo },
165 #[serde(rename = "file")]
166 File { content: PayloadFile },
167 #[serde(rename = "richText")]
168 RichText { content: PayloadRichText },
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct PayloadText {
173 #[serde(rename = "content", alias = "text")]
174 pub content: String,
175}
176
177impl Display for PayloadText {
178 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
179 f.write_str(&self.content)
180 }
181}
182
183impl Deref for PayloadText {
184 type Target = str;
185
186 fn deref(&self) -> &Self::Target {
187 &self.content
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct PayloadPicture {
193 #[serde(rename = "downloadCode")]
194 pub download_code: String,
195 #[serde(rename = "pictureDownloadCode")]
196 pub picture_download_code: String,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct PayloadVideo {
201 #[serde(rename = "downloadCode")]
202 pub download_code: String,
203 #[serde(rename = "duration")]
204 pub duration: String,
205 #[serde(rename = "videoType")]
206 pub video_type: String,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct PayloadFile {
211 #[serde(rename = "downloadCode")]
212 pub download_code: String,
213 #[serde(rename = "fileId")]
214 pub file_id: String,
215 #[serde(rename = "fileName")]
216 pub file_name: String,
217 #[serde(rename = "spaceId")]
218 pub space_id: String,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct PayloadRichText {
223 #[serde(rename = "richText")]
224 pub content: Vec<RichTextItem>,
225}
226
227impl Deref for PayloadRichText {
228 type Target = [RichTextItem];
229
230 fn deref(&self) -> &Self::Target {
231 &self.content
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(untagged)]
237pub enum RichTextItem {
238 #[serde(rename = "picture")]
239 Picture(PayloadPicture),
240 #[serde(rename = "text", alias = "content")]
241 Text(PayloadText),
242}
243
244#[cfg(test)]
245mod tests {
246 use super::{
247 MessageData, MessagePayload, PayloadFile, PayloadPicture, PayloadRichText, PayloadText,
248 RichTextItem,
249 };
250
251 #[test]
252 fn test_text_parse() {
253 let data: MessageData = serde_json::from_str(TEXT_JSON).unwrap();
254 assert_eq!(data.msg_id.as_str(), "msgBjXREkdlZkfTfrIiQomjAw==");
255 if let Some(MessagePayload::Text {
256 text: PayloadText { content },
257 }) = data.payload
258 {
259 assert_eq!(content, "hello");
260 } else {
261 panic!("Expected text payload but got {:?}", data.payload);
262 }
263 }
264 #[test]
265 fn test_picture_parse() {
266 let data: MessageData = serde_json::from_str(PICTURE_JSON).unwrap();
267 assert_eq!(data.msg_id.as_str(), "msgmJpewjjmDF5LPJdRs9n/ZA==");
268 if let Some(MessagePayload::Picture {
269 content: PayloadPicture { download_code, .. },
270 }) = data.payload
271 {
272 assert!(download_code.starts_with("mIofN681YE3f/+m+NntqpSkhBVXbzJynU"));
273 } else {
274 panic!("Expected picture payload but got {:?}", data.payload);
275 }
276 }
277
278 #[test]
279 fn test_file_parse() {
280 let data: MessageData = serde_json::from_str(FILE_JSON).unwrap();
281 assert_eq!(data.msg_id.as_str(), "msgBCO626EXCHXfZoDioTCPxg==");
282 if let Some(MessagePayload::File {
283 content: PayloadFile { file_id, .. },
284 }) = data.payload
285 {
286 assert!(file_id.eq_ignore_ascii_case("214980176385"));
287 } else {
288 panic!("Expected picture payload but got {:?}", data.payload);
289 }
290 }
291
292 #[test]
293 fn test_rich_text_parse() {
294 let data: MessageData = serde_json::from_str(RICH_TEXT_JSON).unwrap();
295 assert_eq!(data.msg_id.as_str(), "msgGDkZWYZlvw7rFtTHcDIFWw==");
296 if let Some(MessagePayload::RichText {
297 content: PayloadRichText { content: rich_text },
298 ..
299 }) = &data.payload
300 {
301 assert!(rich_text.len() > 0);
302 if let RichTextItem::Picture(PayloadPicture { download_code, .. }) =
303 rich_text.get(0).unwrap()
304 {
305 assert!(download_code
306 .starts_with("mIofN681YE3f/+m+NntqpeLZQiMFIZMEPWAhjFjD1g5L/SdG/3lCmLWzq"));
307 } else {
308 panic!("Expected picture payload but got {:?}", data.payload);
309 }
310 if let RichTextItem::Text(PayloadText { content }) = rich_text.get(2).unwrap() {
311 assert!(content.eq("abc"));
312 } else {
313 panic!("Expected text payload but got {:?}", data.payload);
314 }
315 } else {
316 panic!("Expected picture payload but got {:?}", data.payload);
317 }
318 }
319
320 const TEXT_JSON: &str = include_str!("../../../test_resources/cb_msg_text.json");
321 const PICTURE_JSON: &str = include_str!("../../../test_resources/cb_msg_picture.json");
322 const FILE_JSON: &str = include_str!("../../../test_resources/cb_msg_file.json");
323 const RICH_TEXT_JSON: &str = include_str!("../../../test_resources/cb_msg_rich_text.json");
324}