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    /// 扩展字段
92    #[serde(flatten)]
93    pub extensions: HashMap<String, serde_json::Value>,
94}
95
96impl ChatbotMessage {
97    /// 机器人消息回调主题
98    pub const TOPIC: &'static str = "/v1.0/im/bot/messages/get";
99    /// 机器人消息委托主题
100    pub const DELEGATE_TOPIC: &'static str = "/v1.0/im/bot/messages/delegate";
101
102    /// 从 JSON Value 构造(处理 content 字段的特殊解析逻辑)
103    pub fn from_value(value: &serde_json::Value) -> crate::Result<Self> {
104        let mut msg: Self = serde_json::from_value(value.clone())?;
105
106        // 根据 msgtype 解析 content 字段
107        if let Some(msg_type) = &msg.message_type {
108            if let Some(content) = value.get("content") {
109                match msg_type.as_str() {
110                    "picture" => {
111                        msg.image_content = serde_json::from_value(content.clone()).ok();
112                    }
113                    "richText" => {
114                        msg.rich_text_content = serde_json::from_value(content.clone()).ok();
115                    }
116                    // Rust SDK exclusive: audio message parsing
117                    "audio" => {
118                        msg.audio_content = serde_json::from_value(content.clone()).ok();
119                    }
120                    _ => {}
121                }
122            }
123        }
124
125        Ok(msg)
126    }
127
128    /// 获取文本列表
129    pub fn get_text_list(&self) -> Option<Vec<String>> {
130        match self.message_type.as_deref() {
131            Some("text") => self
132                .text
133                .as_ref()
134                .and_then(|t| t.content.clone())
135                .map(|c| vec![c]),
136            Some("richText") => self.rich_text_content.as_ref().map(|rtc| {
137                rtc.rich_text_list
138                    .iter()
139                    .filter_map(|item| item.get("text").and_then(|v| v.as_str()).map(String::from))
140                    .collect()
141            }),
142            // Rust SDK exclusive: extract recognition text from audio messages
143            Some("audio") => self
144                .audio_content
145                .as_ref()
146                .and_then(|ac| ac.recognition.clone())
147                .map(|r| vec![r]),
148            _ => None,
149        }
150    }
151
152    /// 获取图片下载码列表
153    pub fn get_image_list(&self) -> Option<Vec<String>> {
154        match self.message_type.as_deref() {
155            Some("picture") => self
156                .image_content
157                .as_ref()
158                .and_then(|ic| ic.download_code.clone())
159                .map(|dc| vec![dc]),
160            Some("richText") => self.rich_text_content.as_ref().map(|rtc| {
161                rtc.rich_text_list
162                    .iter()
163                    .filter_map(|item| {
164                        item.get("downloadCode")
165                            .and_then(|v| v.as_str())
166                            .map(String::from)
167                    })
168                    .collect()
169            }),
170            _ => None,
171        }
172    }
173}
174
175impl std::fmt::Display for ChatbotMessage {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        write!(
178            f,
179            "ChatbotMessage(message_type={:?}, text={:?}, sender_nick={:?}, conversation_title={:?})",
180            self.message_type, self.text, self.sender_nick, self.conversation_title
181        )
182    }
183}
184
185/// @用户信息
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct AtUser {
188    /// 钉钉 ID
189    #[serde(rename = "dingtalkId", skip_serializing_if = "Option::is_none")]
190    pub dingtalk_id: Option<String>,
191    /// 员工 ID
192    #[serde(rename = "staffId", skip_serializing_if = "Option::is_none")]
193    pub staff_id: Option<String>,
194    /// 扩展字段
195    #[serde(flatten)]
196    pub extensions: HashMap<String, serde_json::Value>,
197}
198
199/// 文本内容
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct TextContent {
202    /// 文本内容
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub content: Option<String>,
205    /// 扩展字段
206    #[serde(flatten)]
207    pub extensions: HashMap<String, serde_json::Value>,
208}
209
210impl std::fmt::Display for TextContent {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        write!(f, "TextContent(content={:?})", self.content)
213    }
214}
215
216/// 图片内容
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218pub struct ImageContent {
219    /// 下载码
220    #[serde(rename = "downloadCode", skip_serializing_if = "Option::is_none")]
221    pub download_code: Option<String>,
222}
223
224// ── Rust SDK exclusive: AudioContent ─────────────────────────────────
225// NOTE: This struct is Rust-SDK-only and does NOT exist in the official
226// Python SDK.  When syncing features from the Python SDK, do NOT remove
227// this struct.
228
229/// 语音消息内容(仅单聊场景下机器人可接收)
230///
231/// 钉钉服务端会自动进行语音识别(STT),识别结果通过 `recognition` 字段返回。
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233pub struct AudioContent {
234    /// 语音识别后的文本
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub recognition: Option<String>,
237    /// 语音文件下载码
238    #[serde(rename = "downloadCode", skip_serializing_if = "Option::is_none")]
239    pub download_code: Option<String>,
240    /// 语音时长(毫秒)
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub duration: Option<i64>,
243}
244
245/// 富文本内容
246#[derive(Debug, Clone, Default, Serialize, Deserialize)]
247pub struct RichTextContent {
248    /// 富文本列表
249    #[serde(rename = "richText", default)]
250    pub rich_text_list: Vec<serde_json::Value>,
251}
252
253/// 托管上下文
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
255pub struct HostingContext {
256    /// 用户 ID
257    #[serde(rename = "userId")]
258    pub user_id: String,
259    /// 昵称
260    pub nick: String,
261}
262
263/// 会话消息上下文
264#[derive(Debug, Clone, Default, Serialize, Deserialize)]
265pub struct ConversationMessage {
266    /// 已读状态
267    #[serde(rename = "readStatus", default)]
268    pub read_status: String,
269    /// 发送者用户 ID
270    #[serde(rename = "senderUserId", default)]
271    pub sender_user_id: String,
272    /// 发送时间
273    #[serde(rename = "sendTime", default)]
274    pub send_time: i64,
275}
276
277impl ConversationMessage {
278    /// 消息是否被我已读
279    pub fn read_by_me(&self) -> bool {
280        self.read_status == "2"
281    }
282}
283
284/// 构造指定单聊的 `ChatbotMessage`(用于主动发送卡片到单聊)
285pub fn reply_specified_single_chat(user_id: &str, user_nickname: &str) -> ChatbotMessage {
286    let value = serde_json::json!({
287        "senderId": user_id,
288        "senderStaffId": user_id,
289        "senderNick": user_nickname,
290        "conversationType": "1",
291        "msgId": uuid::Uuid::new_v4().to_string(),
292    });
293    serde_json::from_value(value).unwrap_or_default()
294}
295
296/// 构造指定群聊的 `ChatbotMessage`(用于主动发送卡片到群聊)
297pub fn reply_specified_group_chat(open_conversation_id: &str) -> ChatbotMessage {
298    let value = serde_json::json!({
299        "conversationId": open_conversation_id,
300        "conversationType": "2",
301        "msgId": uuid::Uuid::new_v4().to_string(),
302    });
303    serde_json::from_value(value).unwrap_or_default()
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_chatbot_message_text() {
312        let json = serde_json::json!({
313            "msgtype": "text",
314            "text": {"content": "hello world"},
315            "senderNick": "test_user",
316            "conversationType": "1",
317            "senderId": "user_001",
318            "senderStaffId": "staff_001",
319            "msgId": "msg_001"
320        });
321        let msg = ChatbotMessage::from_value(&json).unwrap();
322        assert_eq!(msg.message_type.as_deref(), Some("text"));
323        assert_eq!(
324            msg.text.as_ref().and_then(|t| t.content.as_deref()),
325            Some("hello world")
326        );
327        let texts = msg.get_text_list().unwrap();
328        assert_eq!(texts, vec!["hello world"]);
329    }
330
331    #[test]
332    fn test_chatbot_message_picture() {
333        let json = serde_json::json!({
334            "msgtype": "picture",
335            "content": {"downloadCode": "dc_001"},
336            "senderId": "user_001",
337            "msgId": "msg_002"
338        });
339        let msg = ChatbotMessage::from_value(&json).unwrap();
340        assert_eq!(msg.message_type.as_deref(), Some("picture"));
341        assert_eq!(
342            msg.image_content
343                .as_ref()
344                .and_then(|ic| ic.download_code.as_deref()),
345            Some("dc_001")
346        );
347        let images = msg.get_image_list().unwrap();
348        assert_eq!(images, vec!["dc_001"]);
349    }
350
351    #[test]
352    fn test_chatbot_message_rich_text() {
353        let json = serde_json::json!({
354            "msgtype": "richText",
355            "content": {
356                "richText": [
357                    {"text": "line1"},
358                    {"downloadCode": "img_001"},
359                    {"text": "line2"}
360                ]
361            },
362            "senderId": "user_001",
363            "msgId": "msg_003"
364        });
365        let msg = ChatbotMessage::from_value(&json).unwrap();
366        let texts = msg.get_text_list().unwrap();
367        assert_eq!(texts, vec!["line1", "line2"]);
368        let images = msg.get_image_list().unwrap();
369        assert_eq!(images, vec!["img_001"]);
370    }
371
372    #[test]
373    fn test_reply_specified_single_chat() {
374        let msg = reply_specified_single_chat("user_001", "Test User");
375        assert_eq!(msg.sender_id.as_deref(), Some("user_001"));
376        assert_eq!(msg.sender_staff_id.as_deref(), Some("user_001"));
377        assert_eq!(msg.conversation_type.as_deref(), Some("1"));
378        assert!(msg.message_id.is_some());
379    }
380
381    #[test]
382    fn test_reply_specified_group_chat() {
383        let msg = reply_specified_group_chat("conv_001");
384        assert_eq!(msg.conversation_id.as_deref(), Some("conv_001"));
385        assert_eq!(msg.conversation_type.as_deref(), Some("2"));
386        assert!(msg.message_id.is_some());
387    }
388
389    #[test]
390    fn test_conversation_message_read_by_me() {
391        let msg = ConversationMessage {
392            read_status: "2".to_owned(),
393            sender_user_id: "user_001".to_owned(),
394            send_time: 1_690_000_000,
395        };
396        assert!(msg.read_by_me());
397
398        let msg2 = ConversationMessage {
399            read_status: "1".to_owned(),
400            ..Default::default()
401        };
402        assert!(!msg2.read_by_me());
403    }
404
405    #[test]
406    fn test_at_user_serde() {
407        let json = r#"{"dingtalkId":"dt_001","staffId":"staff_001","extra":"val"}"#;
408        let user: AtUser = serde_json::from_str(json).unwrap();
409        assert_eq!(user.dingtalk_id.as_deref(), Some("dt_001"));
410        assert_eq!(user.staff_id.as_deref(), Some("staff_001"));
411        assert!(user.extensions.contains_key("extra"));
412    }
413
414    // ── Rust SDK exclusive: audio message tests ──────────────────────
415
416    #[test]
417    fn test_chatbot_message_audio() {
418        let json = serde_json::json!({
419            "msgtype": "audio",
420            "content": {
421                "duration": 4000,
422                "downloadCode": "dc_audio_001",
423                "recognition": "钉钉,让进步发生"
424            },
425            "senderId": "user_001",
426            "senderStaffId": "staff_001",
427            "conversationType": "1",
428            "msgId": "msg_audio_001"
429        });
430        let msg = ChatbotMessage::from_value(&json).unwrap();
431        assert_eq!(msg.message_type.as_deref(), Some("audio"));
432        let ac = msg.audio_content.as_ref().unwrap();
433        assert_eq!(ac.recognition.as_deref(), Some("钉钉,让进步发生"));
434        assert_eq!(ac.download_code.as_deref(), Some("dc_audio_001"));
435        assert_eq!(ac.duration, Some(4000));
436        // get_text_list should return recognition text
437        let texts = msg.get_text_list().unwrap();
438        assert_eq!(texts, vec!["钉钉,让进步发生"]);
439    }
440
441    #[test]
442    fn test_chatbot_message_audio_no_recognition() {
443        let json = serde_json::json!({
444            "msgtype": "audio",
445            "content": {
446                "duration": 2000,
447                "downloadCode": "dc_audio_002"
448            },
449            "senderId": "user_001",
450            "msgId": "msg_audio_002"
451        });
452        let msg = ChatbotMessage::from_value(&json).unwrap();
453        assert_eq!(msg.message_type.as_deref(), Some("audio"));
454        assert!(msg.audio_content.is_some());
455        // No recognition → get_text_list returns None
456        assert!(msg.get_text_list().is_none());
457    }
458}