Skip to main content

dingtalk_stream/frames/down_message/
callback_message.rs

1use 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/// Callback message
13#[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}