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