Skip to main content

dingtalk_stream/frames/down_message/
callback_message.rs

1use crate::frames::{DingTalkGroupConversationId, DingTalkPrivateConversationId, DingTalkUserId};
2use anyhow::anyhow;
3use chrono::{TimeZone, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt::{Display, Formatter};
7use std::ops::Deref;
8use std::str::FromStr;
9use std::time::Duration;
10use crate::frames::down_message::{DownStreamMessage, MessageHeaders};
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 = "file")]
164    File { content: PayloadFile },
165    #[serde(rename = "richText")]
166    RichText { content: PayloadRichText },
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct PayloadText {
171    #[serde(rename = "content", alias = "text")]
172    pub content: String,
173}
174
175impl Display for PayloadText {
176    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
177        f.write_str(&self.content)
178    }
179}
180
181impl Deref for PayloadText {
182    type Target = str;
183
184    fn deref(&self) -> &Self::Target {
185        &self.content
186    }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct PayloadPicture {
191    #[serde(rename = "downloadCode")]
192    pub download_code: String,
193    #[serde(rename = "pictureDownloadCode")]
194    pub picture_download_code: String,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct PayloadFile {
199    #[serde(rename = "downloadCode")]
200    pub download_code: String,
201    #[serde(rename = "fileId")]
202    pub file_id: String,
203    #[serde(rename = "fileName")]
204    pub file_name: String,
205    #[serde(rename = "spaceId")]
206    pub space_id: String,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct PayloadRichText {
211    #[serde(rename = "richText")]
212    pub content: Vec<RichTextItem>,
213}
214
215impl Deref for PayloadRichText {
216    type Target = [RichTextItem];
217
218    fn deref(&self) -> &Self::Target {
219        &self.content
220    }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(untagged)]
225pub enum RichTextItem {
226    #[serde(rename = "picture")]
227    Picture(PayloadPicture),
228    #[serde(rename = "text", alias = "content")]
229    Text(PayloadText),
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        MessageData, MessagePayload, PayloadFile, PayloadPicture, PayloadRichText, PayloadText,
236        RichTextItem,
237    };
238
239    #[test]
240    fn test_text_parse() {
241        let data: MessageData = serde_json::from_str(TEXT_JSON).unwrap();
242        assert_eq!(data.msg_id.as_str(), "msgBjXREkdlZkfTfrIiQomjAw==");
243        if let Some(MessagePayload::Text {
244            text: PayloadText { content },
245        }) = data.payload
246        {
247            assert_eq!(content, "hello");
248        } else {
249            panic!("Expected text payload but got {:?}", data.payload);
250        }
251    }
252    #[test]
253    fn test_picture_parse() {
254        let data: MessageData = serde_json::from_str(PICTURE_JSON).unwrap();
255        assert_eq!(data.msg_id.as_str(), "msgmJpewjjmDF5LPJdRs9n/ZA==");
256        if let Some(MessagePayload::Picture {
257            content: PayloadPicture { download_code, .. },
258        }) = data.payload
259        {
260            assert!(download_code.starts_with("mIofN681YE3f/+m+NntqpSkhBVXbzJynU"));
261        } else {
262            panic!("Expected picture payload but got {:?}", data.payload);
263        }
264    }
265
266    #[test]
267    fn test_file_parse() {
268        let data: MessageData = serde_json::from_str(FILE_JSON).unwrap();
269        assert_eq!(data.msg_id.as_str(), "msgBCO626EXCHXfZoDioTCPxg==");
270        if let Some(MessagePayload::File {
271            content: PayloadFile { file_id, .. },
272        }) = data.payload
273        {
274            assert!(file_id.eq_ignore_ascii_case("214980176385"));
275        } else {
276            panic!("Expected picture payload but got {:?}", data.payload);
277        }
278    }
279
280    #[test]
281    fn test_rich_text_parse() {
282        let data: MessageData = serde_json::from_str(RICH_TEXT_JSON).unwrap();
283        assert_eq!(data.msg_id.as_str(), "msgGDkZWYZlvw7rFtTHcDIFWw==");
284        if let Some(MessagePayload::RichText {
285            content: PayloadRichText { content: rich_text },
286            ..
287        }) = &data.payload
288        {
289            assert!(rich_text.len() > 0);
290            if let RichTextItem::Picture(PayloadPicture { download_code, .. }) =
291                rich_text.get(0).unwrap()
292            {
293                assert!(download_code
294                    .starts_with("mIofN681YE3f/+m+NntqpeLZQiMFIZMEPWAhjFjD1g5L/SdG/3lCmLWzq"));
295            } else {
296                panic!("Expected picture payload but got {:?}", data.payload);
297            }
298            if let RichTextItem::Text(PayloadText { content }) = rich_text.get(2).unwrap() {
299                assert!(content.eq("abc"));
300            } else {
301                panic!("Expected text payload but got {:?}", data.payload);
302            }
303        } else {
304            panic!("Expected picture payload but got {:?}", data.payload);
305        }
306    }
307
308    const TEXT_JSON: &str = include_str!("../../../test_resources/cb_msg_text.json");
309    const PICTURE_JSON: &str = include_str!("../../../test_resources/cb_msg_picture.json");
310    const FILE_JSON: &str = include_str!("../../../test_resources/cb_msg_file.json");
311    const RICH_TEXT_JSON: &str = include_str!("../../../test_resources/cb_msg_rich_text.json");
312}