Skip to main content

dingtalk_stream/messages/
chatbot.rs

1//! 聊天机器人消息类型,对齐 Python chatbot.py
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// 聊天机器人消息
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct ChatbotMessage {
9    /// 是否在 @列表中
10    #[serde(rename = "isInAtList", skip_serializing_if = "Option::is_none")]
11    pub is_in_at_list: Option<bool>,
12    /// Session Webhook URL
13    #[serde(rename = "sessionWebhook", skip_serializing_if = "Option::is_none")]
14    pub session_webhook: Option<String>,
15    /// 发送者昵称
16    #[serde(rename = "senderNick", skip_serializing_if = "Option::is_none")]
17    pub sender_nick: Option<String>,
18    /// 机器人代码
19    #[serde(rename = "robotCode", skip_serializing_if = "Option::is_none")]
20    pub robot_code: Option<String>,
21    /// Session Webhook 过期时间
22    #[serde(
23        rename = "sessionWebhookExpiredTime",
24        skip_serializing_if = "Option::is_none"
25    )]
26    pub session_webhook_expired_time: Option<i64>,
27    /// 消息 ID
28    #[serde(rename = "msgId", skip_serializing_if = "Option::is_none")]
29    pub message_id: Option<String>,
30    /// 发送者 ID
31    #[serde(rename = "senderId", skip_serializing_if = "Option::is_none")]
32    pub sender_id: Option<String>,
33    /// 机器人用户 ID
34    #[serde(rename = "chatbotUserId", skip_serializing_if = "Option::is_none")]
35    pub chatbot_user_id: Option<String>,
36    /// 会话 ID
37    #[serde(rename = "conversationId", skip_serializing_if = "Option::is_none")]
38    pub conversation_id: Option<String>,
39    /// 是否管理员
40    #[serde(rename = "isAdmin", skip_serializing_if = "Option::is_none")]
41    pub is_admin: Option<bool>,
42    /// 创建时间
43    #[serde(rename = "createAt", skip_serializing_if = "Option::is_none")]
44    pub create_at: Option<i64>,
45    /// 会话类型: "1"=单聊, "2"=群聊
46    #[serde(rename = "conversationType", skip_serializing_if = "Option::is_none")]
47    pub conversation_type: Option<String>,
48    /// @的用户列表
49    #[serde(rename = "atUsers", skip_serializing_if = "Option::is_none")]
50    pub at_users: Option<Vec<AtUser>>,
51    /// 机器人所属企业 ID
52    #[serde(rename = "chatbotCorpId", skip_serializing_if = "Option::is_none")]
53    pub chatbot_corp_id: Option<String>,
54    /// 发送者所属企业 ID
55    #[serde(rename = "senderCorpId", skip_serializing_if = "Option::is_none")]
56    pub sender_corp_id: Option<String>,
57    /// 会话标题
58    #[serde(rename = "conversationTitle", skip_serializing_if = "Option::is_none")]
59    pub conversation_title: Option<String>,
60    /// 消息类型: text, picture, richText
61    #[serde(rename = "msgtype", skip_serializing_if = "Option::is_none")]
62    pub message_type: Option<String>,
63    /// 文本内容
64    #[serde(rename = "text", skip_serializing_if = "Option::is_none")]
65    pub text: Option<TextContent>,
66    /// 发送者员工 ID
67    #[serde(rename = "senderStaffId", skip_serializing_if = "Option::is_none")]
68    pub sender_staff_id: Option<String>,
69    /// 托管上下文
70    #[serde(rename = "hostingContext", skip_serializing_if = "Option::is_none")]
71    pub hosting_context: Option<HostingContext>,
72    /// 会话消息上下文
73    #[serde(
74        rename = "conversationMsgContext",
75        skip_serializing_if = "Option::is_none"
76    )]
77    pub conversation_msg_context: Option<Vec<ConversationMessage>>,
78    /// 图片内容(从 content 字段解析,msgtype=picture 时)
79    #[serde(skip)]
80    pub image_content: Option<ImageContent>,
81    /// 富文本内容(从 content 字段解析,msgtype=richText 时)
82    #[serde(skip)]
83    pub rich_text_content: Option<RichTextContent>,
84    // ── Rust SDK exclusive: audio message support ────────────────────
85    // NOTE: This field is Rust-SDK-only and does NOT exist in the
86    // official Python SDK.  When syncing features from the Python SDK,
87    // do NOT remove this field.
88    /// 语音内容(从 content 字段解析,msgtype=audio 时,仅单聊支持)
89    #[serde(skip)]
90    pub audio_content: Option<AudioContent>,
91    // ── Rust SDK exclusive: file message support ─────────────────────
92    // NOTE: This field is Rust-SDK-only and does NOT exist in the
93    // official Python SDK.  When syncing features from the Python SDK,
94    // do NOT remove this field.
95    /// 文件内容(从 content 字段解析,msgtype=file 时,仅单聊支持)
96    #[serde(skip)]
97    pub file_content: Option<FileContent>,
98    // ── Rust SDK exclusive: video message support ────────────────────
99    // NOTE: This field is Rust-SDK-only and does NOT exist in the
100    // official Python SDK.  When syncing features from the Python SDK,
101    // do NOT remove this field.
102    /// 视频内容(从 content 字段解析,msgtype=video 时,仅单聊支持)
103    #[serde(skip)]
104    pub video_content: Option<VideoContent>,
105    /// 扩展字段
106    #[serde(flatten)]
107    pub extensions: HashMap<String, serde_json::Value>,
108}
109
110impl ChatbotMessage {
111    /// 机器人消息回调主题
112    pub const TOPIC: &'static str = "/v1.0/im/bot/messages/get";
113    /// 机器人消息委托主题
114    pub const DELEGATE_TOPIC: &'static str = "/v1.0/im/bot/messages/delegate";
115
116    /// 从 JSON Value 构造(处理 content 字段的特殊解析逻辑)
117    pub fn from_value(value: &serde_json::Value) -> crate::Result<Self> {
118        let mut msg: Self = serde_json::from_value(value.clone())?;
119
120        // 根据 msgtype 解析 content 字段
121        if let Some(msg_type) = &msg.message_type {
122            if let Some(content) = value.get("content") {
123                match msg_type.as_str() {
124                    "picture" => {
125                        msg.image_content = serde_json::from_value(content.clone()).ok();
126                    }
127                    "richText" => {
128                        msg.rich_text_content = serde_json::from_value(content.clone()).ok();
129                    }
130                    // Rust SDK exclusive: audio message parsing
131                    "audio" => {
132                        msg.audio_content = serde_json::from_value(content.clone()).ok();
133                    }
134                    // Rust SDK exclusive: file message parsing
135                    "file" => {
136                        msg.file_content = serde_json::from_value(content.clone()).ok();
137                    }
138                    // Rust SDK exclusive: video message parsing
139                    "video" => {
140                        msg.video_content = serde_json::from_value(content.clone()).ok();
141                    }
142                    _ => {}
143                }
144            }
145        }
146
147        Ok(msg)
148    }
149
150    /// 获取文本列表
151    pub fn get_text_list(&self) -> Option<Vec<String>> {
152        match self.message_type.as_deref() {
153            Some("text") => self
154                .text
155                .as_ref()
156                .and_then(|t| t.content.clone())
157                .map(|c| vec![c]),
158            Some("richText") => self.rich_text_content.as_ref().map(|rtc| {
159                rtc.rich_text_list
160                    .iter()
161                    .filter_map(|item| item.get("text").and_then(|v| v.as_str()).map(String::from))
162                    .collect()
163            }),
164            // Rust SDK exclusive: extract recognition text from audio messages
165            Some("audio") => self
166                .audio_content
167                .as_ref()
168                .and_then(|ac| ac.recognition.clone())
169                .map(|r| vec![r]),
170            _ => None,
171        }
172    }
173
174    /// 获取图片下载码列表
175    pub fn get_image_list(&self) -> Option<Vec<String>> {
176        match self.message_type.as_deref() {
177            Some("picture") => self
178                .image_content
179                .as_ref()
180                .and_then(|ic| ic.download_code.clone())
181                .map(|dc| vec![dc]),
182            Some("richText") => self.rich_text_content.as_ref().map(|rtc| {
183                rtc.rich_text_list
184                    .iter()
185                    .filter_map(|item| {
186                        item.get("downloadCode")
187                            .and_then(|v| v.as_str())
188                            .map(String::from)
189                    })
190                    .collect()
191            }),
192            _ => None,
193        }
194    }
195
196    // ── Rust SDK exclusive: get_all_download_codes ───────────────────
197    // NOTE: This method is Rust-SDK-only and does NOT exist in the
198    // official Python SDK.  When syncing features from the Python SDK,
199    // do NOT remove this method.
200
201    /// 获取所有媒体文件的下载码列表
202    ///
203    /// 返回 `(media_type, download_code)` 元组列表,覆盖 picture、richText 图片、
204    /// audio、video、file 五种消息类型。
205    pub fn get_all_download_codes(&self) -> Vec<(String, String)> {
206        let mut codes = Vec::new();
207        match self.message_type.as_deref() {
208            Some("picture") => {
209                if let Some(dc) = self
210                    .image_content
211                    .as_ref()
212                    .and_then(|ic| ic.download_code.as_ref())
213                {
214                    codes.push(("picture".to_owned(), dc.clone()));
215                }
216            }
217            Some("richText") => {
218                if let Some(rtc) = &self.rich_text_content {
219                    for item in &rtc.rich_text_list {
220                        if let Some(dc) = item.get("downloadCode").and_then(|v| v.as_str()) {
221                            codes.push(("picture".to_owned(), dc.to_owned()));
222                        }
223                    }
224                }
225            }
226            Some("audio") => {
227                if let Some(dc) = self
228                    .audio_content
229                    .as_ref()
230                    .and_then(|ac| ac.download_code.as_ref())
231                {
232                    codes.push(("audio".to_owned(), dc.clone()));
233                }
234            }
235            Some("file") => {
236                if let Some(dc) = self
237                    .file_content
238                    .as_ref()
239                    .and_then(|fc| fc.download_code.as_ref())
240                {
241                    codes.push(("file".to_owned(), dc.clone()));
242                }
243            }
244            Some("video") => {
245                if let Some(dc) = self
246                    .video_content
247                    .as_ref()
248                    .and_then(|vc| vc.download_code.as_ref())
249                {
250                    codes.push(("video".to_owned(), dc.clone()));
251                }
252            }
253            _ => {}
254        }
255        codes
256    }
257}
258
259impl std::fmt::Display for ChatbotMessage {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        write!(
262            f,
263            "ChatbotMessage(message_type={:?}, text={:?}, sender_nick={:?}, conversation_title={:?})",
264            self.message_type, self.text, self.sender_nick, self.conversation_title
265        )
266    }
267}
268
269/// @用户信息
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct AtUser {
272    /// 钉钉 ID
273    #[serde(rename = "dingtalkId", skip_serializing_if = "Option::is_none")]
274    pub dingtalk_id: Option<String>,
275    /// 员工 ID
276    #[serde(rename = "staffId", skip_serializing_if = "Option::is_none")]
277    pub staff_id: Option<String>,
278    /// 扩展字段
279    #[serde(flatten)]
280    pub extensions: HashMap<String, serde_json::Value>,
281}
282
283/// 文本内容
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct TextContent {
286    /// 文本内容
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub content: Option<String>,
289    /// 扩展字段
290    #[serde(flatten)]
291    pub extensions: HashMap<String, serde_json::Value>,
292}
293
294impl std::fmt::Display for TextContent {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(f, "TextContent(content={:?})", self.content)
297    }
298}
299
300/// 图片内容
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct ImageContent {
303    /// 下载码
304    #[serde(rename = "downloadCode", skip_serializing_if = "Option::is_none")]
305    pub download_code: Option<String>,
306}
307
308// ── Rust SDK exclusive: AudioContent ─────────────────────────────────
309// NOTE: This struct is Rust-SDK-only and does NOT exist in the official
310// Python SDK.  When syncing features from the Python SDK, do NOT remove
311// this struct.
312
313/// 语音消息内容(仅单聊场景下机器人可接收)
314///
315/// 钉钉服务端会自动进行语音识别(STT),识别结果通过 `recognition` 字段返回。
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317pub struct AudioContent {
318    /// 语音识别后的文本
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub recognition: Option<String>,
321    /// 语音文件下载码
322    #[serde(rename = "downloadCode", skip_serializing_if = "Option::is_none")]
323    pub download_code: Option<String>,
324    /// 语音时长(毫秒)
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub duration: Option<i64>,
327}
328
329// ── Rust SDK exclusive: FileContent ──────────────────────────────────
330// NOTE: This struct is Rust-SDK-only and does NOT exist in the official
331// Python SDK.  When syncing features from the Python SDK, do NOT remove
332// this struct.
333
334/// 文件消息内容(仅单聊场景下机器人可接收)
335#[derive(Debug, Clone, Default, Serialize, Deserialize)]
336pub struct FileContent {
337    /// 文件下载码
338    #[serde(rename = "downloadCode", skip_serializing_if = "Option::is_none")]
339    pub download_code: Option<String>,
340    /// 文件名
341    #[serde(rename = "fileName", skip_serializing_if = "Option::is_none")]
342    pub file_name: Option<String>,
343}
344
345// ── Rust SDK exclusive: VideoContent ─────────────────────────────────
346// NOTE: This struct is Rust-SDK-only and does NOT exist in the official
347// Python SDK.  When syncing features from the Python SDK, do NOT remove
348// this struct.
349
350/// 视频消息内容(仅单聊场景下机器人可接收)
351#[derive(Debug, Clone, Default, Serialize, Deserialize)]
352pub struct VideoContent {
353    /// 视频文件下载码
354    #[serde(rename = "downloadCode", skip_serializing_if = "Option::is_none")]
355    pub download_code: Option<String>,
356    /// 视频时长(毫秒)
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub duration: Option<i64>,
359    /// 视频类型
360    #[serde(rename = "videoType", skip_serializing_if = "Option::is_none")]
361    pub video_type: Option<String>,
362}
363
364/// 富文本内容
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366pub struct RichTextContent {
367    /// 富文本列表
368    #[serde(rename = "richText", default)]
369    pub rich_text_list: Vec<serde_json::Value>,
370}
371
372/// 托管上下文
373#[derive(Debug, Clone, Default, Serialize, Deserialize)]
374pub struct HostingContext {
375    /// 用户 ID
376    #[serde(rename = "userId")]
377    pub user_id: String,
378    /// 昵称
379    pub nick: String,
380}
381
382/// 会话消息上下文
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub struct ConversationMessage {
385    /// 已读状态
386    #[serde(rename = "readStatus", default)]
387    pub read_status: String,
388    /// 发送者用户 ID
389    #[serde(rename = "senderUserId", default)]
390    pub sender_user_id: String,
391    /// 发送时间
392    #[serde(rename = "sendTime", default)]
393    pub send_time: i64,
394}
395
396impl ConversationMessage {
397    /// 消息是否被我已读
398    pub fn read_by_me(&self) -> bool {
399        self.read_status == "2"
400    }
401}
402
403/// 构造指定单聊的 `ChatbotMessage`(用于主动发送卡片到单聊)
404pub fn reply_specified_single_chat(user_id: &str, user_nickname: &str) -> ChatbotMessage {
405    let value = serde_json::json!({
406        "senderId": user_id,
407        "senderStaffId": user_id,
408        "senderNick": user_nickname,
409        "conversationType": "1",
410        "msgId": uuid::Uuid::new_v4().to_string(),
411    });
412    serde_json::from_value(value).unwrap_or_default()
413}
414
415/// 构造指定群聊的 `ChatbotMessage`(用于主动发送卡片到群聊)
416pub fn reply_specified_group_chat(open_conversation_id: &str) -> ChatbotMessage {
417    let value = serde_json::json!({
418        "conversationId": open_conversation_id,
419        "conversationType": "2",
420        "msgId": uuid::Uuid::new_v4().to_string(),
421    });
422    serde_json::from_value(value).unwrap_or_default()
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_chatbot_message_text() {
431        let json = serde_json::json!({
432            "msgtype": "text",
433            "text": {"content": "hello world"},
434            "senderNick": "test_user",
435            "conversationType": "1",
436            "senderId": "user_001",
437            "senderStaffId": "staff_001",
438            "msgId": "msg_001"
439        });
440        let msg = ChatbotMessage::from_value(&json).unwrap();
441        assert_eq!(msg.message_type.as_deref(), Some("text"));
442        assert_eq!(
443            msg.text.as_ref().and_then(|t| t.content.as_deref()),
444            Some("hello world")
445        );
446        let texts = msg.get_text_list().unwrap();
447        assert_eq!(texts, vec!["hello world"]);
448    }
449
450    #[test]
451    fn test_chatbot_message_picture() {
452        let json = serde_json::json!({
453            "msgtype": "picture",
454            "content": {"downloadCode": "dc_001"},
455            "senderId": "user_001",
456            "msgId": "msg_002"
457        });
458        let msg = ChatbotMessage::from_value(&json).unwrap();
459        assert_eq!(msg.message_type.as_deref(), Some("picture"));
460        assert_eq!(
461            msg.image_content
462                .as_ref()
463                .and_then(|ic| ic.download_code.as_deref()),
464            Some("dc_001")
465        );
466        let images = msg.get_image_list().unwrap();
467        assert_eq!(images, vec!["dc_001"]);
468    }
469
470    #[test]
471    fn test_chatbot_message_rich_text() {
472        let json = serde_json::json!({
473            "msgtype": "richText",
474            "content": {
475                "richText": [
476                    {"text": "line1"},
477                    {"downloadCode": "img_001"},
478                    {"text": "line2"}
479                ]
480            },
481            "senderId": "user_001",
482            "msgId": "msg_003"
483        });
484        let msg = ChatbotMessage::from_value(&json).unwrap();
485        let texts = msg.get_text_list().unwrap();
486        assert_eq!(texts, vec!["line1", "line2"]);
487        let images = msg.get_image_list().unwrap();
488        assert_eq!(images, vec!["img_001"]);
489    }
490
491    #[test]
492    fn test_reply_specified_single_chat() {
493        let msg = reply_specified_single_chat("user_001", "Test User");
494        assert_eq!(msg.sender_id.as_deref(), Some("user_001"));
495        assert_eq!(msg.sender_staff_id.as_deref(), Some("user_001"));
496        assert_eq!(msg.conversation_type.as_deref(), Some("1"));
497        assert!(msg.message_id.is_some());
498    }
499
500    #[test]
501    fn test_reply_specified_group_chat() {
502        let msg = reply_specified_group_chat("conv_001");
503        assert_eq!(msg.conversation_id.as_deref(), Some("conv_001"));
504        assert_eq!(msg.conversation_type.as_deref(), Some("2"));
505        assert!(msg.message_id.is_some());
506    }
507
508    #[test]
509    fn test_conversation_message_read_by_me() {
510        let msg = ConversationMessage {
511            read_status: "2".to_owned(),
512            sender_user_id: "user_001".to_owned(),
513            send_time: 1_690_000_000,
514        };
515        assert!(msg.read_by_me());
516
517        let msg2 = ConversationMessage {
518            read_status: "1".to_owned(),
519            ..Default::default()
520        };
521        assert!(!msg2.read_by_me());
522    }
523
524    #[test]
525    fn test_at_user_serde() {
526        let json = r#"{"dingtalkId":"dt_001","staffId":"staff_001","extra":"val"}"#;
527        let user: AtUser = serde_json::from_str(json).unwrap();
528        assert_eq!(user.dingtalk_id.as_deref(), Some("dt_001"));
529        assert_eq!(user.staff_id.as_deref(), Some("staff_001"));
530        assert!(user.extensions.contains_key("extra"));
531    }
532
533    // ── Rust SDK exclusive: audio message tests ──────────────────────
534
535    #[test]
536    fn test_chatbot_message_audio() {
537        let json = serde_json::json!({
538            "msgtype": "audio",
539            "content": {
540                "duration": 4000,
541                "downloadCode": "dc_audio_001",
542                "recognition": "钉钉,让进步发生"
543            },
544            "senderId": "user_001",
545            "senderStaffId": "staff_001",
546            "conversationType": "1",
547            "msgId": "msg_audio_001"
548        });
549        let msg = ChatbotMessage::from_value(&json).unwrap();
550        assert_eq!(msg.message_type.as_deref(), Some("audio"));
551        let ac = msg.audio_content.as_ref().unwrap();
552        assert_eq!(ac.recognition.as_deref(), Some("钉钉,让进步发生"));
553        assert_eq!(ac.download_code.as_deref(), Some("dc_audio_001"));
554        assert_eq!(ac.duration, Some(4000));
555        // get_text_list should return recognition text
556        let texts = msg.get_text_list().unwrap();
557        assert_eq!(texts, vec!["钉钉,让进步发生"]);
558    }
559
560    #[test]
561    fn test_chatbot_message_audio_no_recognition() {
562        let json = serde_json::json!({
563            "msgtype": "audio",
564            "content": {
565                "duration": 2000,
566                "downloadCode": "dc_audio_002"
567            },
568            "senderId": "user_001",
569            "msgId": "msg_audio_002"
570        });
571        let msg = ChatbotMessage::from_value(&json).unwrap();
572        assert_eq!(msg.message_type.as_deref(), Some("audio"));
573        assert!(msg.audio_content.is_some());
574        // No recognition → get_text_list returns None
575        assert!(msg.get_text_list().is_none());
576    }
577
578    // ── Rust SDK exclusive: file message tests ───────────────────────
579
580    #[test]
581    fn test_chatbot_message_file() {
582        let json = serde_json::json!({
583            "msgtype": "file",
584            "content": {
585                "downloadCode": "dc_file_001",
586                "fileName": "report.pdf"
587            },
588            "senderId": "user_001",
589            "conversationType": "1",
590            "msgId": "msg_file_001"
591        });
592        let msg = ChatbotMessage::from_value(&json).unwrap();
593        assert_eq!(msg.message_type.as_deref(), Some("file"));
594        let fc = msg.file_content.as_ref().unwrap();
595        assert_eq!(fc.download_code.as_deref(), Some("dc_file_001"));
596        assert_eq!(fc.file_name.as_deref(), Some("report.pdf"));
597    }
598
599    #[test]
600    fn test_chatbot_message_file_partial() {
601        let json = serde_json::json!({
602            "msgtype": "file",
603            "content": { "downloadCode": "dc_file_002" },
604            "senderId": "user_001",
605            "msgId": "msg_file_002"
606        });
607        let msg = ChatbotMessage::from_value(&json).unwrap();
608        let fc = msg.file_content.as_ref().unwrap();
609        assert_eq!(fc.download_code.as_deref(), Some("dc_file_002"));
610        assert!(fc.file_name.is_none());
611    }
612
613    // ── Rust SDK exclusive: video message tests ──────────────────────
614
615    #[test]
616    fn test_chatbot_message_video() {
617        let json = serde_json::json!({
618            "msgtype": "video",
619            "content": {
620                "downloadCode": "dc_video_001",
621                "duration": 15000,
622                "videoType": "mp4"
623            },
624            "senderId": "user_001",
625            "conversationType": "1",
626            "msgId": "msg_video_001"
627        });
628        let msg = ChatbotMessage::from_value(&json).unwrap();
629        assert_eq!(msg.message_type.as_deref(), Some("video"));
630        let vc = msg.video_content.as_ref().unwrap();
631        assert_eq!(vc.download_code.as_deref(), Some("dc_video_001"));
632        assert_eq!(vc.duration, Some(15000));
633        assert_eq!(vc.video_type.as_deref(), Some("mp4"));
634    }
635
636    #[test]
637    fn test_chatbot_message_video_partial() {
638        let json = serde_json::json!({
639            "msgtype": "video",
640            "content": { "downloadCode": "dc_video_002" },
641            "senderId": "user_001",
642            "msgId": "msg_video_002"
643        });
644        let msg = ChatbotMessage::from_value(&json).unwrap();
645        let vc = msg.video_content.as_ref().unwrap();
646        assert_eq!(vc.download_code.as_deref(), Some("dc_video_002"));
647        assert!(vc.duration.is_none());
648        assert!(vc.video_type.is_none());
649    }
650
651    // ── Rust SDK exclusive: get_all_download_codes tests ─────────────
652
653    #[test]
654    fn test_get_all_download_codes_file() {
655        let json = serde_json::json!({
656            "msgtype": "file",
657            "content": { "downloadCode": "dc_001", "fileName": "test.pdf" },
658            "senderId": "user_001",
659            "msgId": "msg_001"
660        });
661        let msg = ChatbotMessage::from_value(&json).unwrap();
662        let codes = msg.get_all_download_codes();
663        assert_eq!(codes.len(), 1);
664        assert_eq!(codes[0], ("file".to_owned(), "dc_001".to_owned()));
665    }
666
667    #[test]
668    fn test_get_all_download_codes_picture() {
669        let json = serde_json::json!({
670            "msgtype": "picture",
671            "content": { "downloadCode": "dc_pic_001" },
672            "senderId": "user_001",
673            "msgId": "msg_001"
674        });
675        let msg = ChatbotMessage::from_value(&json).unwrap();
676        let codes = msg.get_all_download_codes();
677        assert_eq!(codes, vec![("picture".to_owned(), "dc_pic_001".to_owned())]);
678    }
679
680    #[test]
681    fn test_get_all_download_codes_video() {
682        let json = serde_json::json!({
683            "msgtype": "video",
684            "content": { "downloadCode": "dc_vid_001", "duration": 5000 },
685            "senderId": "user_001",
686            "msgId": "msg_001"
687        });
688        let msg = ChatbotMessage::from_value(&json).unwrap();
689        let codes = msg.get_all_download_codes();
690        assert_eq!(codes, vec![("video".to_owned(), "dc_vid_001".to_owned())]);
691    }
692
693    #[test]
694    fn test_get_all_download_codes_text_empty() {
695        let json = serde_json::json!({
696            "msgtype": "text",
697            "text": { "content": "hello" },
698            "senderId": "user_001",
699            "msgId": "msg_001"
700        });
701        let msg = ChatbotMessage::from_value(&json).unwrap();
702        assert!(msg.get_all_download_codes().is_empty());
703    }
704
705    #[test]
706    fn test_get_all_download_codes_rich_text() {
707        let json = serde_json::json!({
708            "msgtype": "richText",
709            "content": {
710                "richText": [
711                    { "text": "hello" },
712                    { "downloadCode": "dc_rt_001" },
713                    { "downloadCode": "dc_rt_002" }
714                ]
715            },
716            "senderId": "user_001",
717            "msgId": "msg_001"
718        });
719        let msg = ChatbotMessage::from_value(&json).unwrap();
720        let codes = msg.get_all_download_codes();
721        assert_eq!(codes.len(), 2);
722        assert_eq!(codes[0].0, "picture");
723        assert_eq!(codes[1].0, "picture");
724    }
725}